orez-sync-cf-host 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/harness-config.ts +0 -1
- package/src/host.ts +83 -38
- package/src/ingest-harness-worker.ts +37 -0
- package/src/platform-probe-worker.ts +8 -7
package/README.md
CHANGED
|
@@ -12,9 +12,10 @@ to a per-namespace Durable Object, and never logs tokens, mutation arguments, or
|
|
|
12
12
|
row contents. Pull runs `engine_handle_pull` inside `transactionSync`; push runs
|
|
13
13
|
Rust `push_validate`/`preflight`/`finalize`/`record_app_error` steps around the
|
|
14
14
|
registered asynchronous TypeScript mutator inside `ctx.storage.transaction`.
|
|
15
|
-
Every SQL cursor is materialized before an await.
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
Every SQL cursor is materialized before an await. Mutators may await only their
|
|
16
|
+
`MutatorSql` operations; timers, fetches, and other external work belong in
|
|
17
|
+
`context.defer`, which runs only after commit. Application failures use the
|
|
18
|
+
required second transaction to advance the LMID marker.
|
|
18
19
|
|
|
19
20
|
## Wake channel and eviction
|
|
20
21
|
|
|
@@ -54,6 +55,14 @@ The runner accepts only loopback or the `lslcf.workers.dev` test worker and does
|
|
|
54
55
|
not mutate a production route. These controls are mechanisms, not authorization
|
|
55
56
|
to perform a production cutover.
|
|
56
57
|
|
|
58
|
+
`POST /admin/resnapshot` is available only when the consumer configured an
|
|
59
|
+
upstream data service. It reads that namespace's authoritative `/snapshot`,
|
|
60
|
+
atomically replaces the derived application tables, then consumes `/changes`
|
|
61
|
+
until caught up. Engine metadata, client last-mutation IDs, operator controls,
|
|
62
|
+
and the authoritative upstream database are preserved. The JSON response
|
|
63
|
+
includes before/after upstream watermarks and the number of snapshot plus
|
|
64
|
+
catch-up rows applied.
|
|
65
|
+
|
|
57
66
|
## Counter and HTTP wire representation
|
|
58
67
|
|
|
59
68
|
Inside the wasm/JavaScript engine boundary, cookies, watermarks, and LMIDs are
|
package/package.json
CHANGED
|
Binary file
|
package/src/harness-config.ts
CHANGED
package/src/host.ts
CHANGED
|
@@ -609,16 +609,67 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
609
609
|
}
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
-
#
|
|
613
|
-
|
|
614
|
-
|
|
612
|
+
async #applyUpstreamSnapshot(
|
|
613
|
+
path: string,
|
|
614
|
+
cursor: string,
|
|
615
|
+
allowSameCursor: boolean
|
|
616
|
+
): Promise<ApplyUpstreamResult> {
|
|
617
|
+
const endpoint = new URL(`${path}/snapshot`, 'https://upstream.invalid')
|
|
618
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
619
|
+
headers: { host: endpoint.host },
|
|
620
|
+
})
|
|
621
|
+
if (!response.ok) {
|
|
622
|
+
throw new Error(`upstream snapshot returned ${response.status}`)
|
|
623
|
+
}
|
|
624
|
+
const snapshot = await response.json()
|
|
625
|
+
const rebuilt = this.#withIngestBilling({ phase: 'snapshot', cursor }, () =>
|
|
626
|
+
this.ctx.storage.transactionSync(() =>
|
|
627
|
+
this.#wasm(() =>
|
|
628
|
+
engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
) as ApplyUpstreamResult
|
|
632
|
+
this.#recordIngestLogicalRows(rebuilt.applied)
|
|
633
|
+
const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
|
|
634
|
+
if (!allowSameCursor && String(nextCursor) === String(cursor)) {
|
|
635
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
636
|
+
phase: 'snapshot',
|
|
637
|
+
cursor,
|
|
638
|
+
resultWatermark: rebuilt.watermark,
|
|
639
|
+
applied: rebuilt.applied,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
return rebuilt
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#ingest(upstreamPath?: string | null, forceSnapshot = false): Promise<number> {
|
|
646
|
+
if (!config.upstream) {
|
|
647
|
+
return forceSnapshot
|
|
648
|
+
? Promise.reject(requestError('upstream is not configured'))
|
|
649
|
+
: Promise.resolve(0)
|
|
650
|
+
}
|
|
651
|
+
if (this.#ingestPromise) {
|
|
652
|
+
return forceSnapshot
|
|
653
|
+
? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
|
|
654
|
+
: this.#ingestPromise
|
|
655
|
+
}
|
|
615
656
|
this.#ingestBreaker.assertReady()
|
|
616
657
|
const path = upstreamPath ?? this.#controlGet('upstreamPath')
|
|
617
|
-
if (!path)
|
|
658
|
+
if (!path) {
|
|
659
|
+
return forceSnapshot
|
|
660
|
+
? Promise.reject(requestError('upstream path is not available'))
|
|
661
|
+
: Promise.resolve(0)
|
|
662
|
+
}
|
|
618
663
|
this.#ingestPromise = (async () => {
|
|
619
664
|
let total = 0
|
|
620
665
|
for (;;) {
|
|
621
666
|
const cursor = this.#engineState()?.upstreamWatermark ?? '0'
|
|
667
|
+
if (forceSnapshot) {
|
|
668
|
+
forceSnapshot = false
|
|
669
|
+
const rebuilt = await this.#applyUpstreamSnapshot(path, cursor, true)
|
|
670
|
+
total += rebuilt.applied
|
|
671
|
+
continue
|
|
672
|
+
}
|
|
622
673
|
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
|
|
623
674
|
endpoint.searchParams.set('watermark', cursor)
|
|
624
675
|
endpoint.searchParams.set('limit', String(upstreamLimit))
|
|
@@ -626,39 +677,8 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
626
677
|
headers: { host: endpoint.host },
|
|
627
678
|
})
|
|
628
679
|
if (response.status === 410) {
|
|
629
|
-
const
|
|
630
|
-
const snapshotResponse = await this.#serviceBinding().fetch(
|
|
631
|
-
snapshotEndpoint.toString(),
|
|
632
|
-
{ headers: { host: snapshotEndpoint.host } }
|
|
633
|
-
)
|
|
634
|
-
if (!snapshotResponse.ok) {
|
|
635
|
-
throw new Error(`upstream snapshot returned ${snapshotResponse.status}`)
|
|
636
|
-
}
|
|
637
|
-
const snapshot = await snapshotResponse.json()
|
|
638
|
-
const rebuilt = this.#withIngestBilling(
|
|
639
|
-
{ phase: 'snapshot', cursor },
|
|
640
|
-
() =>
|
|
641
|
-
this.ctx.storage.transactionSync(() =>
|
|
642
|
-
this.#wasm(() =>
|
|
643
|
-
engine_apply_upstream_snapshot(
|
|
644
|
-
this.#engineDb,
|
|
645
|
-
config.schema,
|
|
646
|
-
snapshot
|
|
647
|
-
)
|
|
648
|
-
)
|
|
649
|
-
) as ApplyUpstreamResult
|
|
650
|
-
)
|
|
680
|
+
const rebuilt = await this.#applyUpstreamSnapshot(path, cursor, false)
|
|
651
681
|
total += rebuilt.applied
|
|
652
|
-
this.#recordIngestLogicalRows(rebuilt.applied)
|
|
653
|
-
const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
|
|
654
|
-
if (String(nextCursor) === String(cursor)) {
|
|
655
|
-
this.#tripIngest('ingestCursorStalled', {
|
|
656
|
-
phase: 'snapshot',
|
|
657
|
-
cursor,
|
|
658
|
-
resultWatermark: rebuilt.watermark,
|
|
659
|
-
applied: rebuilt.applied,
|
|
660
|
-
})
|
|
661
|
-
}
|
|
662
682
|
continue
|
|
663
683
|
}
|
|
664
684
|
if (!response.ok) {
|
|
@@ -1345,7 +1365,11 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1345
1365
|
return new Response(null, { status: 101, webSocket: client })
|
|
1346
1366
|
}
|
|
1347
1367
|
|
|
1348
|
-
#admin(
|
|
1368
|
+
#admin(
|
|
1369
|
+
route: string,
|
|
1370
|
+
request: Request,
|
|
1371
|
+
upstreamPath: string | null
|
|
1372
|
+
): Promise<Response> | Response {
|
|
1349
1373
|
if (route === '/admin/health') return json({ ok: true })
|
|
1350
1374
|
if (route === '/admin/upstream-write-budget' && request.method === 'GET')
|
|
1351
1375
|
return this.#upstreamWriteBudgetStatus()
|
|
@@ -1391,6 +1415,27 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1391
1415
|
)
|
|
1392
1416
|
return json({ ok: true, engine: this.#engineState() })
|
|
1393
1417
|
}
|
|
1418
|
+
if (route === '/admin/resnapshot') {
|
|
1419
|
+
if (request.method !== 'POST') {
|
|
1420
|
+
return json({ error: 'method not allowed' }, 405)
|
|
1421
|
+
}
|
|
1422
|
+
return (async () => {
|
|
1423
|
+
try {
|
|
1424
|
+
const beforeUpstreamWatermark = this.#engineState()?.upstreamWatermark ?? null
|
|
1425
|
+
const applied = await this.#ingest(upstreamPath, true)
|
|
1426
|
+
const engine = this.#engineState()
|
|
1427
|
+
return json({
|
|
1428
|
+
ok: true,
|
|
1429
|
+
applied,
|
|
1430
|
+
beforeUpstreamWatermark,
|
|
1431
|
+
afterUpstreamWatermark: engine?.upstreamWatermark ?? null,
|
|
1432
|
+
engine,
|
|
1433
|
+
})
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
return json(errorBody(error), statusOf(error))
|
|
1436
|
+
}
|
|
1437
|
+
})()
|
|
1438
|
+
}
|
|
1394
1439
|
if (route === '/admin/drop-next-push-response') {
|
|
1395
1440
|
this.#dropNextPushResponse = true
|
|
1396
1441
|
return json({ ok: true })
|
|
@@ -1498,7 +1543,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1498
1543
|
const route = routeAfterNamespace(new URL(request.url).pathname)
|
|
1499
1544
|
const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown'
|
|
1500
1545
|
const upstreamPath = this.#rememberUpstreamPath(request)
|
|
1501
|
-
if (route.startsWith('/admin/')) return this.#admin(route, request)
|
|
1546
|
+
if (route.startsWith('/admin/')) return this.#admin(route, request, upstreamPath)
|
|
1502
1547
|
|
|
1503
1548
|
if (route === '/wake' && request.method === 'GET') return this.#wake(request)
|
|
1504
1549
|
if (route === '/notify' && request.method === 'POST') {
|
|
@@ -31,6 +31,8 @@ const runawayNamespaces = new Set<string>()
|
|
|
31
31
|
const numericTextNamespaces = new Set<string>()
|
|
32
32
|
const jsonValueNamespaces = new Set<string>()
|
|
33
33
|
const hydratedNamespaces = new Set<string>()
|
|
34
|
+
const heldSnapshots = new Set<string>()
|
|
35
|
+
const activeSnapshots = new Set<string>()
|
|
34
36
|
let delegatedFailuresRemaining = 0
|
|
35
37
|
let delegatedAttempts = 0
|
|
36
38
|
let delegatedPushFailedRemaining = 0
|
|
@@ -208,6 +210,16 @@ export class DataService extends WorkerEntrypoint<Env> {
|
|
|
208
210
|
)
|
|
209
211
|
}
|
|
210
212
|
const response = await upstreamFetch(request, this.env)
|
|
213
|
+
if (pathname.endsWith('/snapshot')) {
|
|
214
|
+
if (heldSnapshots.has(namespace)) {
|
|
215
|
+
activeSnapshots.add(namespace)
|
|
216
|
+
try {
|
|
217
|
+
while (heldSnapshots.has(namespace)) await scheduler.wait(10)
|
|
218
|
+
} finally {
|
|
219
|
+
activeSnapshots.delete(namespace)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
211
223
|
if (pathname.endsWith('/changes') && response.ok) hydratedNamespaces.add(namespace)
|
|
212
224
|
return response
|
|
213
225
|
}
|
|
@@ -253,6 +265,31 @@ const syncWorker = createSyncWorker(config)
|
|
|
253
265
|
export default {
|
|
254
266
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
255
267
|
const url = new URL(request.url)
|
|
268
|
+
if (url.pathname.startsWith('/snapshot-control/')) {
|
|
269
|
+
const namespace = url.pathname.slice('/snapshot-control/'.length)
|
|
270
|
+
if (request.method === 'GET') {
|
|
271
|
+
return Promise.resolve(
|
|
272
|
+
Response.json({
|
|
273
|
+
active: activeSnapshots.has(namespace),
|
|
274
|
+
held: heldSnapshots.has(namespace),
|
|
275
|
+
})
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
return request
|
|
279
|
+
.json()
|
|
280
|
+
.catch(() => ({}))
|
|
281
|
+
.then((body) => {
|
|
282
|
+
if ((body as { hold?: unknown }).hold === true) {
|
|
283
|
+
heldSnapshots.add(namespace)
|
|
284
|
+
} else {
|
|
285
|
+
heldSnapshots.delete(namespace)
|
|
286
|
+
}
|
|
287
|
+
return Response.json({
|
|
288
|
+
active: activeSnapshots.has(namespace),
|
|
289
|
+
held: heldSnapshots.has(namespace),
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
}
|
|
256
293
|
if (url.pathname.startsWith('/json-values-control/')) {
|
|
257
294
|
const namespace = url.pathname.slice('/json-values-control/'.length)
|
|
258
295
|
return request
|
|
@@ -79,7 +79,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
|
|
|
79
79
|
const { balance } = this.ctx.storage.sql
|
|
80
80
|
.exec("SELECT balance FROM accounts WHERE id = 'primary'")
|
|
81
81
|
.one() as { balance: number }
|
|
82
|
-
await
|
|
82
|
+
await Promise.resolve()
|
|
83
83
|
this.ctx.storage.sql.exec(
|
|
84
84
|
"UPDATE accounts SET balance = ? WHERE id = 'primary'",
|
|
85
85
|
balance + 10
|
|
@@ -98,7 +98,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
|
|
|
98
98
|
this.ctx.storage.sql.exec(
|
|
99
99
|
"UPDATE accounts SET balance = balance - 5 WHERE id = 'primary'"
|
|
100
100
|
)
|
|
101
|
-
await
|
|
101
|
+
await Promise.resolve()
|
|
102
102
|
this.ctx.storage.sql.exec(
|
|
103
103
|
'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
|
|
104
104
|
'primary',
|
|
@@ -121,7 +121,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
|
|
|
121
121
|
"UPDATE accounts SET balance = ? WHERE id = 'primary'",
|
|
122
122
|
balance + 777
|
|
123
123
|
)
|
|
124
|
-
await
|
|
124
|
+
await Promise.resolve()
|
|
125
125
|
this.ctx.storage.sql.exec(
|
|
126
126
|
'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
|
|
127
127
|
'primary',
|
|
@@ -144,9 +144,10 @@ export class ProbeDurableObject extends DurableObject<Env> {
|
|
|
144
144
|
const expected = push_preflight(this.#db, mutationID)
|
|
145
145
|
wasmMs += performance.now() - wasmStarted
|
|
146
146
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
147
|
+
// MutatorSql methods resolve through the microtask queue. Cross that
|
|
148
|
+
// same boundary here without admitting timers or other external work
|
|
149
|
+
// into the storage transaction.
|
|
150
|
+
await Promise.resolve()
|
|
150
151
|
await this.#mutate(name, deferred)
|
|
151
152
|
|
|
152
153
|
wasmStarted = performance.now()
|
|
@@ -236,7 +237,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
|
|
|
236
237
|
this.ctx.storage.sql.exec(
|
|
237
238
|
"UPDATE accounts SET balance = balance + 123 WHERE id = 'primary'"
|
|
238
239
|
)
|
|
239
|
-
await
|
|
240
|
+
await Promise.resolve()
|
|
240
241
|
this.ctx.storage.sql.exec(
|
|
241
242
|
'INSERT INTO outbox (topic, payload) VALUES (?, ?)',
|
|
242
243
|
'must.rollback',
|