Skip to content

Commit 97d37f2

Browse files
committed
fix(gateway): reconcile durable storage on admission
Upgrade supported QuotaTracker, IdempotencyStore, and RateLimiter SQLite schemas atomically. Reconcile when each object wakes, preserve validated rows, and retain the guarded maintenance path.
1 parent ecd8af1 commit 97d37f2

7 files changed

Lines changed: 72 additions & 52 deletions

File tree

apps/gateway-worker/README.md

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -88,34 +88,18 @@ request is made while either RLS transaction is open.
8888
Gateway emits `first_byok_key_added` after the first successful provider-key save
8989
and accepts authenticated `/v1/user-events` activation pings from the real web UI.
9090

91-
Production releases use `CHEATCODE_RELEASE_GATE` as a fail-closed deployment
92-
barrier. The deploy operation first publishes the final gateway bundle with the
93-
gate set to `closed`; every public route, including `/health`, returns a
94-
non-cacheable `503`. Agent and webhooks are then deployed with their own gates
95-
set to `draining`; the gateway health body proves both service-bound downstream
96-
SHAs and gates. The release drains AgentRun and every webhook, ops, and
97-
resource-deletion Workflow before redeploying both services `closed` and allowing
98-
DDL. In steady state, the public 200 `/health` response also fails closed unless
99-
both downstream services report `open` at the gateway's exact release SHA. After closed reconciliation,
100-
contractions, and Vercel promotion, agent and webhooks reopen first and gateway
101-
opens last. Internal lifecycle work reaches quota state through the webhooks
102-
Worker's direct cross-Worker Durable Object binding, so gateway has no maintenance
103-
bypass route during the closed window.
104-
105-
If a barrier step fails, the deploy operation re-deploys and verifies all three
106-
writer gates closed before stopping. If recovery cannot be verified, writer state
107-
is reported as unconfirmed and requires immediate inspection. Once closed,
108-
recover by rerunning the complete deployment from the same immutable commit. If
109-
that release cannot continue, keep the gateway closed and dispatch a reviewed,
110-
forward-compatible `stage-closed` release that explicitly names the superseded
111-
closed SHA. Never recover a schema contraction by deploying older code or bypass
112-
convergence by flipping the gate in the dashboard.
113-
114-
The HTTP barrier stops new public work, and the draining agent/webhook gates fence
115-
new admissions while pinned Workflow and Durable Object continuations finish. The
116-
coordinated release drains relational AgentRun state and every retained writer
117-
Workflow before moving those services to `closed` and running DDL. Durable Object schema changes use
118-
explicit in-place reconciliation; the gate alone is not an atomic migration.
91+
Production deploys bind an immutable `CHEATCODE_RELEASE_SHA` into every affected
92+
Worker. Forward-compatible Durable Object storage changes reconcile transactionally
93+
when an existing object is first admitted, before the operation reaches an await.
94+
This keeps dormant objects deployable without a fleet-wide maintenance pass while
95+
preserving every validated source row. The signed internal storage route and
96+
`CHEATCODE_RELEASE_GATE=closed` remain available for explicit bulk verification
97+
and schema contractions that require a coordinated maintenance window.
98+
99+
`CHEATCODE_RELEASE_GATE=open` is the steady state. A closed gateway rejects public
100+
work, and agent/webhook draining or closed gates fence new writer admissions.
101+
The gate is an operational barrier; SQLite reconciliation itself is performed by
102+
the object's synchronous transaction and verified against the exact schema.
119103

120104
`IdempotencyStore` owns one exact SQLite table shape in its stable namespace and
121105
reconciles dormant objects to that shape when they are next activated. Run

apps/gateway-worker/src/durable-objects/idempotency-storage.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
assertExactSqliteSchema,
33
assertSqliteRowCountPreserved,
44
type ExpectedSqliteObject,
5+
reconcileExactSqliteStorage,
56
setCurrentSqliteStorageVersion,
67
} from "@cheatcode/durable-storage";
78

@@ -46,17 +47,29 @@ const IDEMPOTENCY_STORAGE_SCHEMA: readonly ExpectedSqliteObject[] = [
4647
},
4748
];
4849

49-
/** Reconciles every dormant object to the one current persisted schema. */
5050
export function initializeIdempotencyStorage(ctx: DurableObjectState): void {
5151
normalizeIdempotencyStorage(ctx, false);
5252
assertIdempotencyStorage(ctx);
5353
}
5454

55+
/** Opens an existing object on the exact schema before serving its next request. */
56+
export function ensureIdempotencyStorage(ctx: DurableObjectState): void {
57+
if (!hasIdempotencyStorage(ctx)) {
58+
initializeIdempotencyStorage(ctx);
59+
return;
60+
}
61+
reconcileExactSqliteStorage(
62+
"reconcile",
63+
() => assertIdempotencyStorage(ctx),
64+
() => reconcileIdempotencyStorage(ctx),
65+
);
66+
}
67+
5568
export function hasIdempotencyStorage(ctx: DurableObjectState): boolean {
5669
return tableColumns(ctx, "idempotency_entry").length > 0;
5770
}
5871

59-
/** One-shot cutover normalizer; a later release removes this force-rebuild entrypoint. */
72+
/** Rebuilds a supported predecessor schema while preserving every stored row. */
6073
export function reconcileIdempotencyStorage(ctx: DurableObjectState): void {
6174
normalizeIdempotencyStorage(ctx, true);
6275
assertExactSqliteSchema(ctx, IDEMPOTENCY_STORAGE_SCHEMA);

apps/gateway-worker/src/durable-objects/idempotency.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
} from "./idempotency-contract";
1919
import {
2020
assertIdempotencyStorage,
21+
ensureIdempotencyStorage,
2122
hasIdempotencyStorage,
22-
initializeIdempotencyStorage,
2323
reconcileIdempotencyStorage,
2424
} from "./idempotency-storage";
2525
import {
@@ -92,7 +92,7 @@ export class IdempotencyStore extends DurableObject<IdempotencyEnv> {
9292
await rearmClosedGatewayDurableObjectAlarm(this.ctx);
9393
return;
9494
}
95-
this.isStorageInitialized = true;
95+
this.ensureStorage();
9696
this.deleteExpired(Date.now());
9797
await this.scheduleNextAlarm();
9898
}
@@ -186,11 +186,7 @@ export class IdempotencyStore extends DurableObject<IdempotencyEnv> {
186186
if (this.isStorageInitialized) {
187187
return;
188188
}
189-
if (hasIdempotencyStorage(this.ctx)) {
190-
assertIdempotencyStorage(this.ctx);
191-
} else {
192-
initializeIdempotencyStorage(this.ctx);
193-
}
189+
ensureIdempotencyStorage(this.ctx);
194190
this.isStorageInitialized = true;
195191
}
196192
}

apps/gateway-worker/src/durable-objects/quota-tracker-storage.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
assertExactSqliteSchema,
33
assertSqliteRowCountPreserved,
44
type ExpectedSqliteObject,
5+
reconcileExactSqliteStorage,
56
setCurrentSqliteStorageVersion,
67
} from "@cheatcode/durable-storage";
78
import { QUOTA_FEATURES } from "@cheatcode/types/quota";
@@ -75,7 +76,27 @@ const QUOTA_STORAGE_SCHEMA: readonly ExpectedSqliteObject[] = [
7576
},
7677
];
7778

78-
/** Force-normalizes all quota tables after the release barrier has drained every caller. */
79+
/**
80+
* Opens quota storage and transactionally upgrades an older supported schema.
81+
*
82+
* Durable Object input gates serialize this synchronous admission path. The
83+
* guarded maintenance route still calls the same reconciler for planned bulk
84+
* verification, but ordinary forward-compatible releases cannot strand a
85+
* dormant user object on its previous schema.
86+
*/
87+
export function ensureQuotaTrackerStorage(ctx: DurableObjectState): void {
88+
if (!hasQuotaTrackerStorage(ctx)) {
89+
initializeQuotaTrackerStorage(ctx);
90+
return;
91+
}
92+
reconcileExactSqliteStorage(
93+
"reconcile",
94+
() => assertQuotaTrackerStorage(ctx),
95+
() => reconcileQuotaTrackerStorage(ctx),
96+
);
97+
}
98+
99+
/** Force-normalizes all quota tables while preserving every valid source row. */
79100
export function reconcileQuotaTrackerStorage(ctx: DurableObjectState): void {
80101
ensureSourceTables(ctx);
81102
const limitColumns = ctx.storage.sql.exec("PRAGMA table_info(limit_override)").toArray();

apps/gateway-worker/src/durable-objects/quota-tracker.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ import {
3333
} from "./quota-tracker-contract";
3434
import {
3535
assertQuotaTrackerStorage,
36+
ensureQuotaTrackerStorage,
3637
hasQuotaTrackerStorage,
37-
initializeQuotaTrackerStorage,
3838
reconcileQuotaTrackerStorage,
3939
} from "./quota-tracker-storage";
4040
import {
@@ -326,7 +326,7 @@ export class QuotaTracker extends DurableObject<QuotaTrackerEnv> {
326326
await rearmClosedGatewayDurableObjectAlarm(this.ctx);
327327
return;
328328
}
329-
this.isStorageInitialized = true;
329+
this.ensureStorage();
330330
this.ctx.storage.sql.exec(
331331
"DELETE FROM counter WHERE updated_at < ?",
332332
Date.now() - QUOTA_TRACKER_RETENTION_MS,
@@ -347,11 +347,7 @@ export class QuotaTracker extends DurableObject<QuotaTrackerEnv> {
347347
if (this.isStorageInitialized) {
348348
return;
349349
}
350-
if (hasQuotaTrackerStorage(this.ctx)) {
351-
assertQuotaTrackerStorage(this.ctx);
352-
} else {
353-
initializeQuotaTrackerStorage(this.ctx);
354-
}
350+
ensureQuotaTrackerStorage(this.ctx);
355351
this.isStorageInitialized = true;
356352
}
357353

apps/gateway-worker/src/durable-objects/rate-limiter-storage.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
assertExactSqliteSchema,
33
assertSqliteRowCountPreserved,
44
type ExpectedSqliteObject,
5+
reconcileExactSqliteStorage,
56
setCurrentSqliteStorageVersion,
67
} from "@cheatcode/durable-storage";
78

@@ -25,6 +26,19 @@ export function initializeRateLimiterStorage(ctx: DurableObjectState): void {
2526
assertRateLimiterStorage(ctx);
2627
}
2728

29+
/** Opens an existing object on the exact schema before serving its next request. */
30+
export function ensureRateLimiterStorage(ctx: DurableObjectState): void {
31+
if (!hasRateLimiterStorage(ctx)) {
32+
initializeRateLimiterStorage(ctx);
33+
return;
34+
}
35+
reconcileExactSqliteStorage(
36+
"reconcile",
37+
() => assertRateLimiterStorage(ctx),
38+
() => reconcileRateLimiterStorage(ctx),
39+
);
40+
}
41+
2842
export function hasRateLimiterStorage(ctx: DurableObjectState): boolean {
2943
return ctx.storage.sql.exec("PRAGMA table_info(bucket)").toArray().length > 0;
3044
}

apps/gateway-worker/src/durable-objects/rate-limiter.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import {
1616
} from "./rate-limit-contract";
1717
import {
1818
assertRateLimiterStorage,
19+
ensureRateLimiterStorage,
1920
hasRateLimiterStorage,
20-
initializeRateLimiterStorage,
2121
reconcileRateLimiterStorage,
2222
} from "./rate-limiter-storage";
2323
import {
@@ -126,7 +126,7 @@ export class RateLimiter extends DurableObject<RateLimiterEnv> {
126126
await rearmClosedGatewayDurableObjectAlarm(this.ctx);
127127
return;
128128
}
129-
this.isStorageInitialized = true;
129+
this.ensureStorage();
130130
this.ctx.storage.sql.exec(
131131
"DELETE FROM bucket WHERE last_refill_ms < ?",
132132
Date.now() - RATE_LIMITER_RETENTION_MS,
@@ -143,11 +143,7 @@ export class RateLimiter extends DurableObject<RateLimiterEnv> {
143143
if (this.isStorageInitialized) {
144144
return;
145145
}
146-
if (hasRateLimiterStorage(this.ctx)) {
147-
assertRateLimiterStorage(this.ctx);
148-
} else {
149-
initializeRateLimiterStorage(this.ctx);
150-
}
146+
ensureRateLimiterStorage(this.ctx);
151147
this.isStorageInitialized = true;
152148
}
153149

0 commit comments

Comments
 (0)