orez-sync-cf-host 0.5.16 → 0.5.18
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 +66 -3
- package/package.json +1 -1
- package/src/config.ts +23 -0
- package/src/generated/sync_wasm.d.ts +35 -0
- package/src/generated/sync_wasm.js +97 -0
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/harness-config.ts +105 -1
- package/src/harness-worker.ts +22 -2
- package/src/host.ts +446 -137
- package/src/ingest-harness-worker.ts +75 -4
- package/src/types.ts +9 -1
- package/src/write-safeguards.ts +14 -15
package/src/host.ts
CHANGED
|
@@ -2,11 +2,14 @@ import { DurableObject } from 'cloudflare:workers'
|
|
|
2
2
|
|
|
3
3
|
import { validatePullCaps, validateSyncHostConfig } from './config.js'
|
|
4
4
|
import {
|
|
5
|
+
engine_apply_snapshot_changes,
|
|
6
|
+
engine_apply_snapshot_page,
|
|
5
7
|
engine_apply_upstream,
|
|
6
|
-
engine_apply_upstream_snapshot,
|
|
7
8
|
engine_assemble_push_response,
|
|
9
|
+
engine_begin_snapshot_generation,
|
|
8
10
|
engine_compile_query,
|
|
9
11
|
engine_finalize,
|
|
12
|
+
engine_finalize_snapshot_generation,
|
|
10
13
|
engine_handle_pull,
|
|
11
14
|
engine_handle_query_pull,
|
|
12
15
|
engine_init_query_schema,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
engine_preflight,
|
|
17
20
|
engine_prune,
|
|
18
21
|
engine_push_validate,
|
|
22
|
+
engine_read_snapshot_progress,
|
|
19
23
|
engine_record_app_error,
|
|
20
24
|
engine_state,
|
|
21
25
|
engine_version,
|
|
@@ -49,6 +53,8 @@ initSync({ module: wasmModule })
|
|
|
49
53
|
const CLAIMS_HEADER = 'x-orez-sync-claims'
|
|
50
54
|
const NAMESPACE_HEADER = 'x-orez-sync-namespace'
|
|
51
55
|
const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
|
|
56
|
+
const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000
|
|
57
|
+
const MIN_SNAPSHOT_PAGE_ROWS = 100
|
|
52
58
|
const DEFAULT_CAPS: PullCaps = {
|
|
53
59
|
maxChangeRows: 10_000,
|
|
54
60
|
maxChangeBytes: 2_000_000,
|
|
@@ -96,6 +102,19 @@ type ApplyUpstreamResult = {
|
|
|
96
102
|
applied: number
|
|
97
103
|
caughtUp: boolean
|
|
98
104
|
}
|
|
105
|
+
type SnapshotProgress = {
|
|
106
|
+
generation: string
|
|
107
|
+
startWatermark: string
|
|
108
|
+
table: string | null
|
|
109
|
+
cursor: string | null
|
|
110
|
+
state: 'paging' | 'catching_up'
|
|
111
|
+
catchupWatermark: string
|
|
112
|
+
}
|
|
113
|
+
type SnapshotPage = {
|
|
114
|
+
watermark: number
|
|
115
|
+
rows: Record<string, unknown>[]
|
|
116
|
+
nextCursor: string | null
|
|
117
|
+
}
|
|
99
118
|
type SocketAttachment = { clientID: string }
|
|
100
119
|
type FaultPoint =
|
|
101
120
|
| 'push_before_mutation'
|
|
@@ -253,7 +272,15 @@ export function createSyncWorker<Env extends SyncHostEnv>(
|
|
|
253
272
|
|
|
254
273
|
const route = routeAfterNamespace(new URL(request.url).pathname)
|
|
255
274
|
const isAdmin = route.startsWith('/admin/')
|
|
256
|
-
if (
|
|
275
|
+
if (route === '/wake') {
|
|
276
|
+
if (!(await config.authorizeWake(request, env))) {
|
|
277
|
+
return json({ error: 'missing wake capability' }, 401)
|
|
278
|
+
}
|
|
279
|
+
} else if (route === '/notify') {
|
|
280
|
+
if (!(await config.authorizeNotify(request, env))) {
|
|
281
|
+
return json({ error: 'forbidden' }, 403)
|
|
282
|
+
}
|
|
283
|
+
} else if (isAdmin) {
|
|
257
284
|
const authorized = config.authorizeAdmin
|
|
258
285
|
? await config.authorizeAdmin(request, env)
|
|
259
286
|
: Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
|
|
@@ -264,10 +291,6 @@ export function createSyncWorker<Env extends SyncHostEnv>(
|
|
|
264
291
|
headers.delete(CLAIMS_HEADER)
|
|
265
292
|
headers.delete(NAMESPACE_HEADER)
|
|
266
293
|
headers.delete(UPSTREAM_PATH_HEADER)
|
|
267
|
-
// Wake sockets carry only a client ID and the literal "wake" hint; they
|
|
268
|
-
// expose no rows, claims, or mutation state. Keep this advisory channel
|
|
269
|
-
// compatible with the vendored transport, which cannot attach an HTTP
|
|
270
|
-
// Authorization header to its native WebSocket constructor.
|
|
271
294
|
if (!isAdmin && route !== '/wake' && route !== '/notify') {
|
|
272
295
|
const claims = await config.authenticate(request, env)
|
|
273
296
|
if (!claims) return json({ error: 'missing authentication' }, 401)
|
|
@@ -328,6 +351,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
328
351
|
#wakeRecipients = new Set<WebSocket>()
|
|
329
352
|
#wakePromise: Promise<void> | null = null
|
|
330
353
|
#ingestPromise: Promise<number> | null = null
|
|
354
|
+
#queryPullLocks = new Map<string, Promise<void>>()
|
|
331
355
|
#recordingIngestBillable = false
|
|
332
356
|
#ingestBreaker = new IngestCircuitBreaker({
|
|
333
357
|
budgetRows: ingestBudgetRows,
|
|
@@ -391,6 +415,22 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
391
415
|
return call()
|
|
392
416
|
}
|
|
393
417
|
|
|
418
|
+
async #acquireQueryPullLock(clientGroupID: string): Promise<() => void> {
|
|
419
|
+
const previous = this.#queryPullLocks.get(clientGroupID)
|
|
420
|
+
let release!: () => void
|
|
421
|
+
const current = new Promise<void>((resolve) => {
|
|
422
|
+
release = resolve
|
|
423
|
+
})
|
|
424
|
+
this.#queryPullLocks.set(clientGroupID, current)
|
|
425
|
+
if (previous) await previous
|
|
426
|
+
return () => {
|
|
427
|
+
release()
|
|
428
|
+
if (this.#queryPullLocks.get(clientGroupID) === current) {
|
|
429
|
+
this.#queryPullLocks.delete(clientGroupID)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
394
434
|
#simulateIdleTeardown(now: number): void {
|
|
395
435
|
if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
|
|
396
436
|
this.#bootID = crypto.randomUUID()
|
|
@@ -505,9 +545,13 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
505
545
|
)
|
|
506
546
|
}
|
|
507
547
|
|
|
508
|
-
#engineState(): EngineState
|
|
548
|
+
#engineState(): EngineState {
|
|
549
|
+
return this.#wasm(() => engine_state(this.#engineDb)) as EngineState
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#engineStateBestEffort(): EngineState | null {
|
|
509
553
|
try {
|
|
510
|
-
return this.#
|
|
554
|
+
return this.#engineState()
|
|
511
555
|
} catch {
|
|
512
556
|
return null
|
|
513
557
|
}
|
|
@@ -533,7 +577,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
533
577
|
async #upstreamWriteBudgetStatus(): Promise<Response> {
|
|
534
578
|
if (!config.upstream) return json({ error: 'upstream is not configured' }, 404)
|
|
535
579
|
const path = this.#controlGet('upstreamPath')
|
|
536
|
-
if (
|
|
580
|
+
if (path === null) return json({ error: 'upstream path is not known yet' }, 409)
|
|
537
581
|
const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid')
|
|
538
582
|
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
539
583
|
headers: { host: endpoint.host },
|
|
@@ -553,7 +597,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
553
597
|
#rememberUpstreamPath(request: Request): string | null {
|
|
554
598
|
if (!config.upstream) return null
|
|
555
599
|
const path = request.headers.get(UPSTREAM_PATH_HEADER)
|
|
556
|
-
if (path) {
|
|
600
|
+
if (path !== null) {
|
|
557
601
|
this.#controlSet('upstreamPath', path)
|
|
558
602
|
return path
|
|
559
603
|
}
|
|
@@ -591,9 +635,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
591
635
|
return apply()
|
|
592
636
|
} catch (error) {
|
|
593
637
|
this.#recordingIngestBillable = false
|
|
594
|
-
|
|
638
|
+
const status = this.#ingestBreaker.status()
|
|
639
|
+
// a breaker thrown by the sql adapter crosses rust as an engine error,
|
|
640
|
+
// so the durable breaker state is the authoritative classification.
|
|
641
|
+
if (
|
|
642
|
+
error instanceof IngestBreakerError ||
|
|
643
|
+
(status.tripped && status.reason === 'ingestBudgetExceeded')
|
|
644
|
+
) {
|
|
595
645
|
this.#persistIngestBreaker()
|
|
596
|
-
const status = this.#ingestBreaker.status()
|
|
597
646
|
console.error(
|
|
598
647
|
JSON.stringify({
|
|
599
648
|
event: 'sync_upstream_ingest_breaker_tripped',
|
|
@@ -609,37 +658,124 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
609
658
|
}
|
|
610
659
|
}
|
|
611
660
|
|
|
612
|
-
|
|
661
|
+
#snapshotProgress(): SnapshotProgress | null {
|
|
662
|
+
return this.#wasm(() =>
|
|
663
|
+
engine_read_snapshot_progress(this.#engineDb)
|
|
664
|
+
) as SnapshotProgress | null
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
#resetSnapshotBillingWindow(): void {
|
|
668
|
+
// every page is an independently committed write unit. metering it in a
|
|
669
|
+
// fresh window keeps a rebuild larger than the breaker ceiling resumable
|
|
670
|
+
// while preserving the ceiling for each transaction.
|
|
671
|
+
this.#ingestBreaker.reopen()
|
|
672
|
+
this.#controlDelete(
|
|
673
|
+
'ingestBreakerReason',
|
|
674
|
+
'ingestBreakerRetryAt',
|
|
675
|
+
'ingestBreakerTrips'
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#snapshotRetryLimit(
|
|
680
|
+
error: unknown,
|
|
681
|
+
limit: number,
|
|
682
|
+
fields: Record<string, unknown>
|
|
683
|
+
): number {
|
|
684
|
+
const status = statusOf(error)
|
|
685
|
+
if (!(error instanceof IngestBreakerError) && status < 500) throw error
|
|
686
|
+
if (limit <= MIN_SNAPSHOT_PAGE_ROWS) {
|
|
687
|
+
throw Object.assign(
|
|
688
|
+
new Error(
|
|
689
|
+
`snapshot page failed at minimum limit ${MIN_SNAPSHOT_PAGE_ROWS}: ${errorMessage(error)}`
|
|
690
|
+
),
|
|
691
|
+
{ status, cause: error }
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
const nextLimit = Math.max(MIN_SNAPSHOT_PAGE_ROWS, Math.floor(limit / 2))
|
|
695
|
+
console.warn(
|
|
696
|
+
JSON.stringify({
|
|
697
|
+
event: 'sync_upstream_snapshot_page_retry',
|
|
698
|
+
...fields,
|
|
699
|
+
limit,
|
|
700
|
+
nextLimit,
|
|
701
|
+
status,
|
|
702
|
+
error: errorMessage(error),
|
|
703
|
+
})
|
|
704
|
+
)
|
|
705
|
+
return nextLimit
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async #fetchSnapshotPage(
|
|
613
709
|
path: string,
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
710
|
+
table: string,
|
|
711
|
+
cursor: string | null,
|
|
712
|
+
limit: number
|
|
713
|
+
): Promise<SnapshotPage> {
|
|
617
714
|
const endpoint = new URL(`${path}/snapshot`, 'https://upstream.invalid')
|
|
715
|
+
endpoint.searchParams.set('table', table)
|
|
716
|
+
endpoint.searchParams.set('limit', String(limit))
|
|
717
|
+
if (cursor !== null) endpoint.searchParams.set('cursor', cursor)
|
|
618
718
|
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
619
719
|
headers: { host: endpoint.host },
|
|
620
720
|
})
|
|
621
721
|
if (!response.ok) {
|
|
622
|
-
throw
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
)
|
|
722
|
+
throw requestError(
|
|
723
|
+
`upstream snapshot page returned ${response.status}`,
|
|
724
|
+
response.status >= 500 ? 502 : response.status
|
|
630
725
|
)
|
|
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
726
|
}
|
|
642
|
-
|
|
727
|
+
const page = (await response.json()) as Partial<SnapshotPage>
|
|
728
|
+
if (
|
|
729
|
+
!Number.isSafeInteger(page.watermark) ||
|
|
730
|
+
Number(page.watermark) < 0 ||
|
|
731
|
+
!Array.isArray(page.rows) ||
|
|
732
|
+
(page.nextCursor !== null && typeof page.nextCursor !== 'string')
|
|
733
|
+
) {
|
|
734
|
+
throw new Error('invalid upstream snapshot page response')
|
|
735
|
+
}
|
|
736
|
+
return page as SnapshotPage
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async #beginSnapshotGeneration(path: string): Promise<{
|
|
740
|
+
progress: SnapshotProgress
|
|
741
|
+
page: SnapshotPage
|
|
742
|
+
pageLimit: number
|
|
743
|
+
}> {
|
|
744
|
+
const table = Object.keys(config.schema.tables).sort()[0]
|
|
745
|
+
if (!table) throw requestError('paged snapshots require a modeled table', 500)
|
|
746
|
+
let pageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS
|
|
747
|
+
let page: SnapshotPage
|
|
748
|
+
for (;;) {
|
|
749
|
+
try {
|
|
750
|
+
page = await this.#fetchSnapshotPage(path, table, null, pageLimit)
|
|
751
|
+
break
|
|
752
|
+
} catch (error) {
|
|
753
|
+
pageLimit = this.#snapshotRetryLimit(error, pageLimit, {
|
|
754
|
+
phase: 'snapshot_page_fetch',
|
|
755
|
+
table,
|
|
756
|
+
cursor: null,
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
this.#resetSnapshotBillingWindow()
|
|
761
|
+
const progress = this.#withIngestBilling(
|
|
762
|
+
{
|
|
763
|
+
phase: 'snapshot_begin',
|
|
764
|
+
table,
|
|
765
|
+
startWatermark: page.watermark,
|
|
766
|
+
},
|
|
767
|
+
() =>
|
|
768
|
+
this.ctx.storage.transactionSync(() =>
|
|
769
|
+
this.#wasm(() =>
|
|
770
|
+
engine_begin_snapshot_generation(
|
|
771
|
+
this.#engineDb,
|
|
772
|
+
config.schema,
|
|
773
|
+
String(page.watermark)
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
) as SnapshotProgress
|
|
778
|
+
return { progress, page, pageLimit }
|
|
643
779
|
}
|
|
644
780
|
|
|
645
781
|
#ingest(upstreamPath?: string | null, forceSnapshot = false): Promise<number> {
|
|
@@ -653,21 +789,172 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
653
789
|
? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
|
|
654
790
|
: this.#ingestPromise
|
|
655
791
|
}
|
|
656
|
-
this.#ingestBreaker.assertReady()
|
|
657
792
|
const path = upstreamPath ?? this.#controlGet('upstreamPath')
|
|
658
|
-
if (
|
|
793
|
+
if (path === null) {
|
|
659
794
|
return forceSnapshot
|
|
660
795
|
? Promise.reject(requestError('upstream path is not available'))
|
|
661
796
|
: Promise.resolve(0)
|
|
662
797
|
}
|
|
663
798
|
this.#ingestPromise = (async () => {
|
|
799
|
+
let progress = this.#snapshotProgress()
|
|
800
|
+
this.#ingestBreaker.assertReady()
|
|
801
|
+
const startingWatermark = this.#engineState().watermark
|
|
664
802
|
let total = 0
|
|
803
|
+
let pendingPage: SnapshotPage | null = null
|
|
804
|
+
let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS
|
|
805
|
+
let snapshotCompleted = false
|
|
665
806
|
for (;;) {
|
|
666
|
-
|
|
807
|
+
if (progress?.state === 'paging') {
|
|
808
|
+
const activeProgress = progress
|
|
809
|
+
const table = activeProgress.table
|
|
810
|
+
if (table === null) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`snapshot generation ${activeProgress.generation} is paging without a table`
|
|
813
|
+
)
|
|
814
|
+
}
|
|
815
|
+
let page: SnapshotPage | null = pendingPage
|
|
816
|
+
try {
|
|
817
|
+
page ??= await this.#fetchSnapshotPage(
|
|
818
|
+
path,
|
|
819
|
+
table,
|
|
820
|
+
activeProgress.cursor,
|
|
821
|
+
snapshotPageLimit
|
|
822
|
+
)
|
|
823
|
+
const pageToApply = page
|
|
824
|
+
this.#resetSnapshotBillingWindow()
|
|
825
|
+
const nextProgress = this.#withIngestBilling(
|
|
826
|
+
{
|
|
827
|
+
phase: 'snapshot_page_apply',
|
|
828
|
+
generation: activeProgress.generation,
|
|
829
|
+
table,
|
|
830
|
+
cursor: activeProgress.cursor,
|
|
831
|
+
pageRows: pageToApply.rows.length,
|
|
832
|
+
pageLimit: snapshotPageLimit,
|
|
833
|
+
},
|
|
834
|
+
() =>
|
|
835
|
+
this.ctx.storage.transactionSync(() =>
|
|
836
|
+
this.#wasm(() =>
|
|
837
|
+
engine_apply_snapshot_page(
|
|
838
|
+
this.#engineDb,
|
|
839
|
+
config.schema,
|
|
840
|
+
activeProgress.generation,
|
|
841
|
+
table,
|
|
842
|
+
pageToApply.rows,
|
|
843
|
+
pageToApply.nextCursor
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
) as SnapshotProgress
|
|
848
|
+
total += pageToApply.rows.length
|
|
849
|
+
this.#recordIngestLogicalRows(pageToApply.rows.length)
|
|
850
|
+
progress = nextProgress
|
|
851
|
+
pendingPage = null
|
|
852
|
+
} catch (error) {
|
|
853
|
+
snapshotPageLimit = this.#snapshotRetryLimit(error, snapshotPageLimit, {
|
|
854
|
+
phase: page === null ? 'snapshot_page_fetch' : 'snapshot_page_apply',
|
|
855
|
+
generation: activeProgress.generation,
|
|
856
|
+
table,
|
|
857
|
+
cursor: activeProgress.cursor,
|
|
858
|
+
})
|
|
859
|
+
pendingPage = null
|
|
860
|
+
}
|
|
861
|
+
continue
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (progress?.state === 'catching_up') {
|
|
865
|
+
const activeProgress = progress
|
|
866
|
+
const cursor = activeProgress.catchupWatermark
|
|
867
|
+
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
|
|
868
|
+
endpoint.searchParams.set('since', cursor)
|
|
869
|
+
endpoint.searchParams.set('limit', String(upstreamLimit))
|
|
870
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
871
|
+
headers: { host: endpoint.host },
|
|
872
|
+
})
|
|
873
|
+
if (response.status === 410) {
|
|
874
|
+
const begun = await this.#beginSnapshotGeneration(path)
|
|
875
|
+
progress = begun.progress
|
|
876
|
+
pendingPage = begun.page
|
|
877
|
+
snapshotPageLimit = begun.pageLimit
|
|
878
|
+
continue
|
|
879
|
+
}
|
|
880
|
+
if (!response.ok) {
|
|
881
|
+
throw new Error(`upstream snapshot catch-up returned ${response.status}`)
|
|
882
|
+
}
|
|
883
|
+
const batch = (await response.json()) as UpstreamBatch
|
|
884
|
+
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
885
|
+
throw new Error('invalid upstream changes response')
|
|
886
|
+
}
|
|
887
|
+
this.#resetSnapshotBillingWindow()
|
|
888
|
+
const result = this.#withIngestBilling(
|
|
889
|
+
{
|
|
890
|
+
phase: 'snapshot_catchup',
|
|
891
|
+
generation: activeProgress.generation,
|
|
892
|
+
cursor,
|
|
893
|
+
batchWatermark: batch.watermark,
|
|
894
|
+
changeRows: batch.changes.length,
|
|
895
|
+
},
|
|
896
|
+
() =>
|
|
897
|
+
this.ctx.storage.transactionSync(() =>
|
|
898
|
+
this.#wasm(() =>
|
|
899
|
+
engine_apply_snapshot_changes(
|
|
900
|
+
this.#engineDb,
|
|
901
|
+
config.schema,
|
|
902
|
+
activeProgress.generation,
|
|
903
|
+
batch
|
|
904
|
+
)
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
) as ApplyUpstreamResult
|
|
908
|
+
total += result.applied
|
|
909
|
+
this.#recordIngestLogicalRows(result.applied)
|
|
910
|
+
if (result.caughtUp) {
|
|
911
|
+
this.#resetSnapshotBillingWindow()
|
|
912
|
+
this.#withIngestBilling(
|
|
913
|
+
{
|
|
914
|
+
phase: 'snapshot_finalize',
|
|
915
|
+
generation: activeProgress.generation,
|
|
916
|
+
watermark: result.watermark,
|
|
917
|
+
},
|
|
918
|
+
() =>
|
|
919
|
+
this.ctx.storage.transactionSync(() =>
|
|
920
|
+
this.#wasm(() =>
|
|
921
|
+
engine_finalize_snapshot_generation(
|
|
922
|
+
this.#engineDb,
|
|
923
|
+
config.schema,
|
|
924
|
+
activeProgress.generation,
|
|
925
|
+
String(result.watermark)
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
)
|
|
930
|
+
progress = null
|
|
931
|
+
snapshotCompleted = true
|
|
932
|
+
break
|
|
933
|
+
}
|
|
934
|
+
if (String(result.watermark) === cursor) {
|
|
935
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
936
|
+
phase: 'snapshot_catchup',
|
|
937
|
+
generation: activeProgress.generation,
|
|
938
|
+
cursor,
|
|
939
|
+
batchWatermark: batch.watermark,
|
|
940
|
+
changeRows: batch.changes.length,
|
|
941
|
+
applied: result.applied,
|
|
942
|
+
})
|
|
943
|
+
}
|
|
944
|
+
progress = {
|
|
945
|
+
...progress,
|
|
946
|
+
catchupWatermark: String(result.watermark),
|
|
947
|
+
}
|
|
948
|
+
continue
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const cursor = this.#engineState().upstreamWatermark
|
|
667
952
|
if (forceSnapshot) {
|
|
668
953
|
forceSnapshot = false
|
|
669
|
-
const
|
|
670
|
-
|
|
954
|
+
const begun = await this.#beginSnapshotGeneration(path)
|
|
955
|
+
progress = begun.progress
|
|
956
|
+
pendingPage = begun.page
|
|
957
|
+
snapshotPageLimit = begun.pageLimit
|
|
671
958
|
continue
|
|
672
959
|
}
|
|
673
960
|
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
|
|
@@ -677,8 +964,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
677
964
|
headers: { host: endpoint.host },
|
|
678
965
|
})
|
|
679
966
|
if (response.status === 410) {
|
|
680
|
-
const
|
|
681
|
-
|
|
967
|
+
const begun = await this.#beginSnapshotGeneration(path)
|
|
968
|
+
progress = begun.progress
|
|
969
|
+
pendingPage = begun.page
|
|
970
|
+
snapshotPageLimit = begun.pageLimit
|
|
682
971
|
continue
|
|
683
972
|
}
|
|
684
973
|
if (!response.ok) {
|
|
@@ -704,7 +993,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
704
993
|
) as ApplyUpstreamResult
|
|
705
994
|
total += result.applied
|
|
706
995
|
this.#recordIngestLogicalRows(result.applied)
|
|
707
|
-
const nextCursor = this.#engineState()
|
|
996
|
+
const nextCursor = this.#engineState().upstreamWatermark
|
|
708
997
|
if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
|
|
709
998
|
this.#tripIngest('ingestCursorStalled', {
|
|
710
999
|
phase: 'changes',
|
|
@@ -730,7 +1019,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
730
1019
|
}
|
|
731
1020
|
}
|
|
732
1021
|
this.#recoverIngestBreaker()
|
|
733
|
-
|
|
1022
|
+
const endingWatermark = this.#engineState().watermark
|
|
1023
|
+
if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
|
|
1024
|
+
await this.#enqueueWake('__upstream__')
|
|
1025
|
+
}
|
|
734
1026
|
return total
|
|
735
1027
|
})().finally(() => {
|
|
736
1028
|
this.#ingestPromise = null
|
|
@@ -905,84 +1197,94 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
905
1197
|
if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
|
|
906
1198
|
throw new TypeError('queryTransformVersion must be a non-negative safe integer')
|
|
907
1199
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
928
|
-
const args = op.args as JsonValue[]
|
|
929
|
-
let ast: JsonValue
|
|
930
|
-
try {
|
|
931
|
-
// resolveQuery may be async and needs `env` (a consumer can
|
|
932
|
-
// delegate the transform to its app's real synced-queries
|
|
933
|
-
// endpoint over an app service binding — authenticate runs in the
|
|
934
|
-
// worker isolate, but the query loop runs here in the DO, so the
|
|
935
|
-
// binding must come from the DO's own env, not a shared global).
|
|
936
|
-
ast = await config.resolveQuery(op.name, args, claims, this.env)
|
|
937
|
-
} catch (error) {
|
|
938
|
-
throw requestError(`unknown or unsupported named query: ${op.name}`)
|
|
1200
|
+
const releaseQueryPull =
|
|
1201
|
+
queryAware && config.resolveQuery
|
|
1202
|
+
? await this.#acquireQueryPullLock(
|
|
1203
|
+
typeof body.clientGroupID === 'string' ? body.clientGroupID : ''
|
|
1204
|
+
)
|
|
1205
|
+
: null
|
|
1206
|
+
let response: Record<string, unknown>
|
|
1207
|
+
try {
|
|
1208
|
+
if (queryAware && body.queries) {
|
|
1209
|
+
const queries = body.queries as {
|
|
1210
|
+
version?: unknown
|
|
1211
|
+
patch?: unknown
|
|
1212
|
+
}
|
|
1213
|
+
if (Array.isArray(queries.patch)) {
|
|
1214
|
+
const patch = []
|
|
1215
|
+
for (const operation of queries.patch) {
|
|
1216
|
+
if (!operation || typeof operation !== 'object') {
|
|
1217
|
+
patch.push(operation)
|
|
1218
|
+
continue
|
|
939
1219
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1220
|
+
const op = operation as Record<string, unknown>
|
|
1221
|
+
if (op.op === 'put') {
|
|
1222
|
+
if (!config.resolveQuery || typeof op.name !== 'string') {
|
|
1223
|
+
throw requestError('query put requires a server-resolved named query')
|
|
1224
|
+
}
|
|
1225
|
+
if (!Array.isArray(op.args)) {
|
|
1226
|
+
throw requestError('named query args must be an array')
|
|
1227
|
+
}
|
|
1228
|
+
const args = op.args as JsonValue[]
|
|
1229
|
+
let ast: JsonValue
|
|
1230
|
+
try {
|
|
1231
|
+
// resolveQuery may be async and needs `env` (a consumer can
|
|
1232
|
+
// delegate the transform to its app's real synced-queries
|
|
1233
|
+
// endpoint over an app service binding — authenticate runs in the
|
|
1234
|
+
// worker isolate, but the query loop runs here in the DO, so the
|
|
1235
|
+
// binding must come from the DO's own env, not a shared global).
|
|
1236
|
+
ast = await config.resolveQuery(op.name, args, claims, this.env)
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
throw requestError(`unknown or unsupported named query: ${op.name}`)
|
|
1239
|
+
}
|
|
1240
|
+
patch.push({
|
|
1241
|
+
op: 'put',
|
|
1242
|
+
hash: op.hash,
|
|
1243
|
+
ast,
|
|
1244
|
+
transformVersion,
|
|
1245
|
+
})
|
|
1246
|
+
} else patch.push(operation)
|
|
1247
|
+
}
|
|
1248
|
+
body = { ...body, queries: { ...queries, patch } }
|
|
947
1249
|
}
|
|
948
|
-
body = { ...body, queries: { ...queries, patch } }
|
|
949
1250
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
|
|
1251
|
+
if (queryAware) {
|
|
1252
|
+
body = { ...body, _serverQueryTransformVersion: transformVersion }
|
|
1253
|
+
}
|
|
1254
|
+
const clientID = typeof body.clientID === 'string' ? body.clientID : ''
|
|
1255
|
+
this.#pulling.add(clientID)
|
|
1256
|
+
try {
|
|
1257
|
+
const txStarted = performance.now()
|
|
1258
|
+
const duringFault = this.#takeFault('pull_during_tx')
|
|
1259
|
+
response = this.ctx.storage.transactionSync(() => {
|
|
1260
|
+
const result = this.#wasm(() =>
|
|
1261
|
+
queryAware
|
|
1262
|
+
? engine_handle_query_pull(
|
|
1263
|
+
this.#engineDb,
|
|
1264
|
+
config.schema,
|
|
1265
|
+
this.#retainChanges(),
|
|
1266
|
+
body,
|
|
1267
|
+
claims.userID
|
|
1268
|
+
)
|
|
1269
|
+
: engine_handle_pull(
|
|
1270
|
+
this.#engineDb,
|
|
1271
|
+
config.schema,
|
|
1272
|
+
this.#visibility(claims),
|
|
1273
|
+
caps,
|
|
1274
|
+
this.#retainChanges(),
|
|
1275
|
+
body,
|
|
1276
|
+
claims.userID
|
|
1277
|
+
)
|
|
1278
|
+
) as Record<string, unknown>
|
|
1279
|
+
if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
|
|
1280
|
+
return result
|
|
1281
|
+
})
|
|
1282
|
+
transactionMs = performance.now() - txStarted
|
|
1283
|
+
} finally {
|
|
1284
|
+
this.#pulling.delete(clientID)
|
|
1285
|
+
}
|
|
984
1286
|
} finally {
|
|
985
|
-
|
|
1287
|
+
releaseQueryPull?.()
|
|
986
1288
|
}
|
|
987
1289
|
const afterPullFault = this.#takeFault('pull_after_commit')
|
|
988
1290
|
if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
|
|
@@ -995,7 +1297,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
995
1297
|
).length
|
|
996
1298
|
: 0
|
|
997
1299
|
this.#counters.queryRecompilations += queryPuts
|
|
998
|
-
const state = this.#
|
|
1300
|
+
const state = this.#engineStateBestEffort()
|
|
999
1301
|
this.#log({
|
|
1000
1302
|
namespaceHash: namespace,
|
|
1001
1303
|
requestKind: 'pull',
|
|
@@ -1021,14 +1323,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1021
1323
|
const status = statusOf(error)
|
|
1022
1324
|
if (status === 409) this.#counters.resets++
|
|
1023
1325
|
if (status === 500) this.#counters.invariantFailures++
|
|
1326
|
+
const state = this.#engineStateBestEffort()
|
|
1024
1327
|
this.#log({
|
|
1025
1328
|
namespaceHash: namespace,
|
|
1026
1329
|
requestKind: 'pull',
|
|
1027
1330
|
resultClass: status === 409 ? 'reset' : 'error',
|
|
1028
1331
|
inputCookie: body?.cookie ?? null,
|
|
1029
1332
|
outputCookie: null,
|
|
1030
|
-
retainedFloor:
|
|
1031
|
-
currentWatermark:
|
|
1333
|
+
retainedFloor: state?.floor ?? null,
|
|
1334
|
+
currentWatermark: state?.watermark ?? null,
|
|
1032
1335
|
changeRowsScanned: null,
|
|
1033
1336
|
changeRowsIncluded: 0,
|
|
1034
1337
|
queriesRecomputed: 0,
|
|
@@ -1059,7 +1362,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1059
1362
|
// Workerd requires the request stream to be consumed before the DO
|
|
1060
1363
|
// returns a response. Discard it without parsing or logging payloads.
|
|
1061
1364
|
await request.arrayBuffer()
|
|
1062
|
-
const state = this.#
|
|
1365
|
+
const state = this.#engineStateBestEffort()
|
|
1063
1366
|
this.#log({
|
|
1064
1367
|
namespaceHash: namespace,
|
|
1065
1368
|
requestKind: 'push',
|
|
@@ -1094,7 +1397,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1094
1397
|
|
|
1095
1398
|
const endpoint = new URL(
|
|
1096
1399
|
`${upstreamPath ?? ''}${config.mutateUrl}`,
|
|
1097
|
-
'https://upstream.invalid'
|
|
1400
|
+
config.mutateOrigin ?? 'https://upstream.invalid'
|
|
1098
1401
|
)
|
|
1099
1402
|
const headers = new Headers(request.headers)
|
|
1100
1403
|
headers.delete(CLAIMS_HEADER)
|
|
@@ -1105,7 +1408,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1105
1408
|
endpoint,
|
|
1106
1409
|
headers,
|
|
1107
1410
|
bytes,
|
|
1108
|
-
this.#engineState()
|
|
1411
|
+
this.#engineState().upstreamWatermark === '0'
|
|
1109
1412
|
)
|
|
1110
1413
|
if (!upstreamResponse.ok) {
|
|
1111
1414
|
return new Response(upstreamResponse.body, upstreamResponse)
|
|
@@ -1301,7 +1604,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1301
1604
|
throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
|
|
1302
1605
|
|
|
1303
1606
|
const response = this.#wasm(() => engine_assemble_push_response(results))
|
|
1304
|
-
const state = this.#
|
|
1607
|
+
const state = this.#engineStateBestEffort()
|
|
1305
1608
|
this.#log({
|
|
1306
1609
|
namespaceHash: namespace,
|
|
1307
1610
|
requestKind: 'push',
|
|
@@ -1330,14 +1633,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1330
1633
|
} catch (error) {
|
|
1331
1634
|
const status = statusOf(error)
|
|
1332
1635
|
if (status === 500) this.#counters.invariantFailures++
|
|
1636
|
+
const state = this.#engineStateBestEffort()
|
|
1333
1637
|
this.#log({
|
|
1334
1638
|
namespaceHash: namespace,
|
|
1335
1639
|
requestKind: 'push',
|
|
1336
1640
|
resultClass: 'error',
|
|
1337
1641
|
inputCookie: null,
|
|
1338
1642
|
outputCookie: null,
|
|
1339
|
-
retainedFloor:
|
|
1340
|
-
currentWatermark:
|
|
1643
|
+
retainedFloor: state?.floor ?? null,
|
|
1644
|
+
currentWatermark: state?.watermark ?? null,
|
|
1341
1645
|
changeRowsScanned: 0,
|
|
1342
1646
|
changeRowsIncluded: 0,
|
|
1343
1647
|
queriesRecomputed: 0,
|
|
@@ -1402,7 +1706,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1402
1706
|
heapUsedBytes: heap?.usedJSHeapSize ?? null,
|
|
1403
1707
|
heapTotalBytes: heap?.totalJSHeapSize ?? null,
|
|
1404
1708
|
heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
|
|
1405
|
-
engine: this.#
|
|
1709
|
+
engine: this.#engineStateBestEffort(),
|
|
1406
1710
|
counters: this.#counters,
|
|
1407
1711
|
ingestBreaker: this.#ingestBreaker.status(),
|
|
1408
1712
|
})
|
|
@@ -1412,7 +1716,17 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1412
1716
|
return request.json().then((body) => {
|
|
1413
1717
|
const { query } = body as { query?: string }
|
|
1414
1718
|
if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
|
|
1415
|
-
|
|
1719
|
+
try {
|
|
1720
|
+
return json({ rows: this.#directSql.query(query) })
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
if (
|
|
1723
|
+
error instanceof TypeError &&
|
|
1724
|
+
error.message === 'transaction SQL is host-owned and forbidden'
|
|
1725
|
+
) {
|
|
1726
|
+
return json({ error: error.message }, 400)
|
|
1727
|
+
}
|
|
1728
|
+
throw error
|
|
1729
|
+
}
|
|
1416
1730
|
})
|
|
1417
1731
|
}
|
|
1418
1732
|
if (route === '/admin/invalidate') {
|
|
@@ -1427,14 +1741,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1427
1741
|
}
|
|
1428
1742
|
return (async () => {
|
|
1429
1743
|
try {
|
|
1430
|
-
const beforeUpstreamWatermark = this.#engineState()
|
|
1744
|
+
const beforeUpstreamWatermark = this.#engineState().upstreamWatermark
|
|
1431
1745
|
const applied = await this.#ingest(upstreamPath, true)
|
|
1432
1746
|
const engine = this.#engineState()
|
|
1433
1747
|
return json({
|
|
1434
1748
|
ok: true,
|
|
1435
1749
|
applied,
|
|
1436
1750
|
beforeUpstreamWatermark,
|
|
1437
|
-
afterUpstreamWatermark: engine
|
|
1751
|
+
afterUpstreamWatermark: engine.upstreamWatermark,
|
|
1438
1752
|
engine,
|
|
1439
1753
|
})
|
|
1440
1754
|
} catch (error) {
|
|
@@ -1447,12 +1761,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1447
1761
|
return json({ ok: true })
|
|
1448
1762
|
}
|
|
1449
1763
|
if (route === '/admin/restart') {
|
|
1450
|
-
this
|
|
1451
|
-
this.#hibernations++
|
|
1452
|
-
this.#counters = freshCounters()
|
|
1453
|
-
this.#pulling.clear()
|
|
1454
|
-
this.#wakeOrigins.clear()
|
|
1455
|
-
this.#wakeRecipients.clear()
|
|
1764
|
+
this.ctx.abort('admin requested durable object restart')
|
|
1456
1765
|
return json({ ok: true, bootID: this.#bootID })
|
|
1457
1766
|
}
|
|
1458
1767
|
if (route === '/admin/visibility') {
|