orez-sync-cf-host 0.5.17 → 0.5.19
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 +140 -10
- package/package.json +15 -4
- package/src/bun-wasm-loader.ts +16 -0
- package/src/config.ts +32 -0
- package/src/generated/sync_wasm.d.ts +37 -2
- package/src/generated/sync_wasm.js +100 -2
- 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 +498 -179
- package/src/index.ts +26 -1
- package/src/ingest-harness-worker.ts +172 -6
- package/src/mutation-error.ts +28 -0
- package/src/platform-probe-worker.ts +122 -9
- package/src/post-commit.ts +40 -0
- package/src/query-compiler.ts +16 -0
- package/src/sql-storage-adapter.ts +88 -19
- package/src/transaction-query.ts +494 -0
- package/src/types.ts +39 -17
- package/src/wasm-platform.ts +10 -0
- package/src/wasm.ts +34 -0
- package/src/write-safeguards.ts +14 -15
package/src/host.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers'
|
|
2
2
|
|
|
3
3
|
import { validatePullCaps, validateSyncHostConfig } from './config.js'
|
|
4
|
+
import { createPostCommitEffects } from './post-commit.js'
|
|
5
|
+
import { createQueryCompiler } from './query-compiler.js'
|
|
4
6
|
import {
|
|
7
|
+
decodeSqlParams,
|
|
8
|
+
SqlStorageDirect,
|
|
9
|
+
SqlStorageMutatorTransaction,
|
|
10
|
+
SqlStorageSyncDb,
|
|
11
|
+
} from './sql-storage-adapter.js'
|
|
12
|
+
import { isMutationApplicationError } from './types.js'
|
|
13
|
+
import {
|
|
14
|
+
engine_apply_snapshot_changes,
|
|
15
|
+
engine_apply_snapshot_page,
|
|
5
16
|
engine_apply_upstream,
|
|
6
|
-
engine_apply_upstream_snapshot,
|
|
7
17
|
engine_assemble_push_response,
|
|
8
|
-
|
|
18
|
+
engine_begin_snapshot_generation,
|
|
9
19
|
engine_finalize,
|
|
20
|
+
engine_finalize_snapshot_generation,
|
|
10
21
|
engine_handle_pull,
|
|
11
22
|
engine_handle_query_pull,
|
|
12
23
|
engine_init_query_schema,
|
|
@@ -16,18 +27,11 @@ import {
|
|
|
16
27
|
engine_preflight,
|
|
17
28
|
engine_prune,
|
|
18
29
|
engine_push_validate,
|
|
30
|
+
engine_read_snapshot_progress,
|
|
19
31
|
engine_record_app_error,
|
|
20
32
|
engine_state,
|
|
21
33
|
engine_version,
|
|
22
|
-
|
|
23
|
-
} from './generated/sync_wasm.js'
|
|
24
|
-
import wasmModule from './generated/sync_wasm_bg.wasm'
|
|
25
|
-
import {
|
|
26
|
-
SqlStorageDirect,
|
|
27
|
-
SqlStorageMutatorTransaction,
|
|
28
|
-
SqlStorageSyncDb,
|
|
29
|
-
} from './sql-storage-adapter.js'
|
|
30
|
-
import { MutationApplicationError } from './types.js'
|
|
34
|
+
} from './wasm.js'
|
|
31
35
|
import {
|
|
32
36
|
IngestBreakerError,
|
|
33
37
|
IngestCircuitBreaker,
|
|
@@ -36,7 +40,6 @@ import {
|
|
|
36
40
|
} from './write-safeguards.js'
|
|
37
41
|
|
|
38
42
|
import type {
|
|
39
|
-
DeferredEffect,
|
|
40
43
|
JsonValue,
|
|
41
44
|
NormalizedClaims,
|
|
42
45
|
PullCaps,
|
|
@@ -44,11 +47,11 @@ import type {
|
|
|
44
47
|
SyncHostEnv,
|
|
45
48
|
} from './types.js'
|
|
46
49
|
|
|
47
|
-
initSync({ module: wasmModule })
|
|
48
|
-
|
|
49
50
|
const CLAIMS_HEADER = 'x-orez-sync-claims'
|
|
50
51
|
const NAMESPACE_HEADER = 'x-orez-sync-namespace'
|
|
51
52
|
const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
|
|
53
|
+
const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000
|
|
54
|
+
const MIN_SNAPSHOT_PAGE_ROWS = 100
|
|
52
55
|
const DEFAULT_CAPS: PullCaps = {
|
|
53
56
|
maxChangeRows: 10_000,
|
|
54
57
|
maxChangeBytes: 2_000_000,
|
|
@@ -96,6 +99,19 @@ type ApplyUpstreamResult = {
|
|
|
96
99
|
applied: number
|
|
97
100
|
caughtUp: boolean
|
|
98
101
|
}
|
|
102
|
+
type SnapshotProgress = {
|
|
103
|
+
generation: string
|
|
104
|
+
startWatermark: string
|
|
105
|
+
table: string | null
|
|
106
|
+
cursor: string | null
|
|
107
|
+
state: 'paging' | 'catching_up'
|
|
108
|
+
catchupWatermark: string
|
|
109
|
+
}
|
|
110
|
+
type SnapshotPage = {
|
|
111
|
+
watermark: number
|
|
112
|
+
rows: Record<string, unknown>[]
|
|
113
|
+
nextCursor: string | null
|
|
114
|
+
}
|
|
99
115
|
type SocketAttachment = { clientID: string }
|
|
100
116
|
type FaultPoint =
|
|
101
117
|
| 'push_before_mutation'
|
|
@@ -253,7 +269,15 @@ export function createSyncWorker<Env extends SyncHostEnv>(
|
|
|
253
269
|
|
|
254
270
|
const route = routeAfterNamespace(new URL(request.url).pathname)
|
|
255
271
|
const isAdmin = route.startsWith('/admin/')
|
|
256
|
-
if (
|
|
272
|
+
if (route === '/wake') {
|
|
273
|
+
if (!(await config.authorizeWake(request, env))) {
|
|
274
|
+
return json({ error: 'missing wake capability' }, 401)
|
|
275
|
+
}
|
|
276
|
+
} else if (route === '/notify') {
|
|
277
|
+
if (!(await config.authorizeNotify(request, env))) {
|
|
278
|
+
return json({ error: 'forbidden' }, 403)
|
|
279
|
+
}
|
|
280
|
+
} else if (isAdmin) {
|
|
257
281
|
const authorized = config.authorizeAdmin
|
|
258
282
|
? await config.authorizeAdmin(request, env)
|
|
259
283
|
: Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
|
|
@@ -264,10 +288,6 @@ export function createSyncWorker<Env extends SyncHostEnv>(
|
|
|
264
288
|
headers.delete(CLAIMS_HEADER)
|
|
265
289
|
headers.delete(NAMESPACE_HEADER)
|
|
266
290
|
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
291
|
if (!isAdmin && route !== '/wake' && route !== '/notify') {
|
|
272
292
|
const claims = await config.authenticate(request, env)
|
|
273
293
|
if (!claims) return json({ error: 'missing authentication' }, 401)
|
|
@@ -297,6 +317,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
297
317
|
config: SyncHostConfig<Env>
|
|
298
318
|
) {
|
|
299
319
|
validateSyncHostConfig(config)
|
|
320
|
+
const compileQuery = createQueryCompiler(config.schema)
|
|
300
321
|
const defaultRetainChanges = String(config.retainChanges ?? 4_096)
|
|
301
322
|
const caps: PullCaps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps })
|
|
302
323
|
const idleTeardownMs = config.idleTeardownMs ?? 5_000
|
|
@@ -328,6 +349,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
328
349
|
#wakeRecipients = new Set<WebSocket>()
|
|
329
350
|
#wakePromise: Promise<void> | null = null
|
|
330
351
|
#ingestPromise: Promise<number> | null = null
|
|
352
|
+
#queryPullLocks = new Map<string, Promise<void>>()
|
|
331
353
|
#recordingIngestBillable = false
|
|
332
354
|
#ingestBreaker = new IngestCircuitBreaker({
|
|
333
355
|
budgetRows: ingestBudgetRows,
|
|
@@ -344,8 +366,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
344
366
|
}
|
|
345
367
|
this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten)
|
|
346
368
|
this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten)
|
|
347
|
-
this.#mutatorSql = new SqlStorageMutatorTransaction(
|
|
348
|
-
this.#
|
|
369
|
+
this.#mutatorSql = new SqlStorageMutatorTransaction(
|
|
370
|
+
this.#directSql,
|
|
371
|
+
(ast, format) => this.#wasm(() => compileQuery(ast, format)),
|
|
372
|
+
config.transactionQueryBudget
|
|
349
373
|
)
|
|
350
374
|
ctx.blockConcurrencyWhile(async () => {
|
|
351
375
|
ctx.storage.transactionSync(() => {
|
|
@@ -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
|
|
@@ -738,6 +1030,13 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
738
1030
|
return this.#ingestPromise
|
|
739
1031
|
}
|
|
740
1032
|
|
|
1033
|
+
#ingestAfterCurrent(upstreamPath: string | null): Promise<number> {
|
|
1034
|
+
const current = this.#ingestPromise
|
|
1035
|
+
return current
|
|
1036
|
+
? current.then(() => this.#ingest(upstreamPath))
|
|
1037
|
+
: this.#ingest(upstreamPath)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
741
1040
|
async #fetchDelegatedPush(
|
|
742
1041
|
endpoint: URL,
|
|
743
1042
|
headers: Headers,
|
|
@@ -804,23 +1103,6 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
804
1103
|
)
|
|
805
1104
|
}
|
|
806
1105
|
|
|
807
|
-
async #runEffects(effects: DeferredEffect[]): Promise<void> {
|
|
808
|
-
for (const effect of effects) {
|
|
809
|
-
try {
|
|
810
|
-
await effect()
|
|
811
|
-
} catch (error) {
|
|
812
|
-
this.#counters.externalEffectFailures++
|
|
813
|
-
console.error(
|
|
814
|
-
JSON.stringify({
|
|
815
|
-
event: 'sync_external_effect_error',
|
|
816
|
-
hostVersion: config.hostVersion,
|
|
817
|
-
error: errorMessage(error),
|
|
818
|
-
})
|
|
819
|
-
)
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
1106
|
#enqueueWake(originClientID: string): Promise<void> {
|
|
825
1107
|
this.#wakeOrigins.add(originClientID)
|
|
826
1108
|
for (const socket of this.ctx.getWebSockets()) {
|
|
@@ -905,84 +1187,94 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
905
1187
|
if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
|
|
906
1188
|
throw new TypeError('queryTransformVersion must be a non-negative safe integer')
|
|
907
1189
|
}
|
|
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}`)
|
|
1190
|
+
const releaseQueryPull =
|
|
1191
|
+
queryAware && config.resolveQuery
|
|
1192
|
+
? await this.#acquireQueryPullLock(
|
|
1193
|
+
typeof body.clientGroupID === 'string' ? body.clientGroupID : ''
|
|
1194
|
+
)
|
|
1195
|
+
: null
|
|
1196
|
+
let response: Record<string, unknown>
|
|
1197
|
+
try {
|
|
1198
|
+
if (queryAware && body.queries) {
|
|
1199
|
+
const queries = body.queries as {
|
|
1200
|
+
version?: unknown
|
|
1201
|
+
patch?: unknown
|
|
1202
|
+
}
|
|
1203
|
+
if (Array.isArray(queries.patch)) {
|
|
1204
|
+
const patch = []
|
|
1205
|
+
for (const operation of queries.patch) {
|
|
1206
|
+
if (!operation || typeof operation !== 'object') {
|
|
1207
|
+
patch.push(operation)
|
|
1208
|
+
continue
|
|
939
1209
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1210
|
+
const op = operation as Record<string, unknown>
|
|
1211
|
+
if (op.op === 'put') {
|
|
1212
|
+
if (!config.resolveQuery || typeof op.name !== 'string') {
|
|
1213
|
+
throw requestError('query put requires a server-resolved named query')
|
|
1214
|
+
}
|
|
1215
|
+
if (!Array.isArray(op.args)) {
|
|
1216
|
+
throw requestError('named query args must be an array')
|
|
1217
|
+
}
|
|
1218
|
+
const args = op.args as JsonValue[]
|
|
1219
|
+
let ast: JsonValue
|
|
1220
|
+
try {
|
|
1221
|
+
// resolveQuery may be async and needs `env` (a consumer can
|
|
1222
|
+
// delegate the transform to its app's real synced-queries
|
|
1223
|
+
// endpoint over an app service binding — authenticate runs in the
|
|
1224
|
+
// worker isolate, but the query loop runs here in the DO, so the
|
|
1225
|
+
// binding must come from the DO's own env, not a shared global).
|
|
1226
|
+
ast = await config.resolveQuery(op.name, args, claims, this.env)
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
throw requestError(`unknown or unsupported named query: ${op.name}`)
|
|
1229
|
+
}
|
|
1230
|
+
patch.push({
|
|
1231
|
+
op: 'put',
|
|
1232
|
+
hash: op.hash,
|
|
1233
|
+
ast,
|
|
1234
|
+
transformVersion,
|
|
1235
|
+
})
|
|
1236
|
+
} else patch.push(operation)
|
|
1237
|
+
}
|
|
1238
|
+
body = { ...body, queries: { ...queries, patch } }
|
|
947
1239
|
}
|
|
948
|
-
body = { ...body, queries: { ...queries, patch } }
|
|
949
1240
|
}
|
|
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
|
-
|
|
1241
|
+
if (queryAware) {
|
|
1242
|
+
body = { ...body, _serverQueryTransformVersion: transformVersion }
|
|
1243
|
+
}
|
|
1244
|
+
const clientID = typeof body.clientID === 'string' ? body.clientID : ''
|
|
1245
|
+
this.#pulling.add(clientID)
|
|
1246
|
+
try {
|
|
1247
|
+
const txStarted = performance.now()
|
|
1248
|
+
const duringFault = this.#takeFault('pull_during_tx')
|
|
1249
|
+
response = this.ctx.storage.transactionSync(() => {
|
|
1250
|
+
const result = this.#wasm(() =>
|
|
1251
|
+
queryAware
|
|
1252
|
+
? engine_handle_query_pull(
|
|
1253
|
+
this.#engineDb,
|
|
1254
|
+
config.schema,
|
|
1255
|
+
this.#retainChanges(),
|
|
1256
|
+
body,
|
|
1257
|
+
claims.userID
|
|
1258
|
+
)
|
|
1259
|
+
: engine_handle_pull(
|
|
1260
|
+
this.#engineDb,
|
|
1261
|
+
config.schema,
|
|
1262
|
+
this.#visibility(claims),
|
|
1263
|
+
caps,
|
|
1264
|
+
this.#retainChanges(),
|
|
1265
|
+
body,
|
|
1266
|
+
claims.userID
|
|
1267
|
+
)
|
|
1268
|
+
) as Record<string, unknown>
|
|
1269
|
+
if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
|
|
1270
|
+
return result
|
|
1271
|
+
})
|
|
1272
|
+
transactionMs = performance.now() - txStarted
|
|
1273
|
+
} finally {
|
|
1274
|
+
this.#pulling.delete(clientID)
|
|
1275
|
+
}
|
|
984
1276
|
} finally {
|
|
985
|
-
|
|
1277
|
+
releaseQueryPull?.()
|
|
986
1278
|
}
|
|
987
1279
|
const afterPullFault = this.#takeFault('pull_after_commit')
|
|
988
1280
|
if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
|
|
@@ -995,7 +1287,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
995
1287
|
).length
|
|
996
1288
|
: 0
|
|
997
1289
|
this.#counters.queryRecompilations += queryPuts
|
|
998
|
-
const state = this.#
|
|
1290
|
+
const state = this.#engineStateBestEffort()
|
|
999
1291
|
this.#log({
|
|
1000
1292
|
namespaceHash: namespace,
|
|
1001
1293
|
requestKind: 'pull',
|
|
@@ -1021,14 +1313,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1021
1313
|
const status = statusOf(error)
|
|
1022
1314
|
if (status === 409) this.#counters.resets++
|
|
1023
1315
|
if (status === 500) this.#counters.invariantFailures++
|
|
1316
|
+
const state = this.#engineStateBestEffort()
|
|
1024
1317
|
this.#log({
|
|
1025
1318
|
namespaceHash: namespace,
|
|
1026
1319
|
requestKind: 'pull',
|
|
1027
1320
|
resultClass: status === 409 ? 'reset' : 'error',
|
|
1028
1321
|
inputCookie: body?.cookie ?? null,
|
|
1029
1322
|
outputCookie: null,
|
|
1030
|
-
retainedFloor:
|
|
1031
|
-
currentWatermark:
|
|
1323
|
+
retainedFloor: state?.floor ?? null,
|
|
1324
|
+
currentWatermark: state?.watermark ?? null,
|
|
1032
1325
|
changeRowsScanned: null,
|
|
1033
1326
|
changeRowsIncluded: 0,
|
|
1034
1327
|
queriesRecomputed: 0,
|
|
@@ -1059,7 +1352,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1059
1352
|
// Workerd requires the request stream to be consumed before the DO
|
|
1060
1353
|
// returns a response. Discard it without parsing or logging payloads.
|
|
1061
1354
|
await request.arrayBuffer()
|
|
1062
|
-
const state = this.#
|
|
1355
|
+
const state = this.#engineStateBestEffort()
|
|
1063
1356
|
this.#log({
|
|
1064
1357
|
namespaceHash: namespace,
|
|
1065
1358
|
requestKind: 'push',
|
|
@@ -1094,7 +1387,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1094
1387
|
|
|
1095
1388
|
const endpoint = new URL(
|
|
1096
1389
|
`${upstreamPath ?? ''}${config.mutateUrl}`,
|
|
1097
|
-
'https://upstream.invalid'
|
|
1390
|
+
config.mutateOrigin ?? 'https://upstream.invalid'
|
|
1098
1391
|
)
|
|
1099
1392
|
const headers = new Headers(request.headers)
|
|
1100
1393
|
headers.delete(CLAIMS_HEADER)
|
|
@@ -1105,7 +1398,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1105
1398
|
endpoint,
|
|
1106
1399
|
headers,
|
|
1107
1400
|
bytes,
|
|
1108
|
-
this.#engineState()
|
|
1401
|
+
this.#engineState().upstreamWatermark === '0'
|
|
1109
1402
|
)
|
|
1110
1403
|
if (!upstreamResponse.ok) {
|
|
1111
1404
|
return new Response(upstreamResponse.body, upstreamResponse)
|
|
@@ -1134,7 +1427,18 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1134
1427
|
result.id?.clientID === mutation.clientID &&
|
|
1135
1428
|
String(result.id?.id) === mutation.id
|
|
1136
1429
|
)
|
|
1137
|
-
if (!ack)
|
|
1430
|
+
if (!ack) {
|
|
1431
|
+
throw new Error(
|
|
1432
|
+
`delegated push did not acknowledge ${mutation.clientID}:${mutation.id}`
|
|
1433
|
+
)
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
// the delegated app response is causally visible through DATA by
|
|
1437
|
+
// contract. start an ingest round after that response, even if an
|
|
1438
|
+
// older round is still in flight, then journal lmids. every capped
|
|
1439
|
+
// log prefix therefore preserves effects-before-ack.
|
|
1440
|
+
await this.#ingestAfterCurrent(upstreamPath)
|
|
1441
|
+
for (const mutation of plan.mutations) {
|
|
1138
1442
|
this.ctx.storage.transactionSync(() => {
|
|
1139
1443
|
const decision = this.#wasm(() =>
|
|
1140
1444
|
engine_preflight(
|
|
@@ -1163,7 +1467,6 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1163
1467
|
this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
|
|
1164
1468
|
)
|
|
1165
1469
|
}
|
|
1166
|
-
await this.#ingest(upstreamPath)
|
|
1167
1470
|
return json({ pushResponse: delegatedResponse })
|
|
1168
1471
|
} catch (error) {
|
|
1169
1472
|
const status = statusOf(error)
|
|
@@ -1180,7 +1483,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1180
1483
|
|
|
1181
1484
|
const results: MutationResult[] = []
|
|
1182
1485
|
for (const mutation of plan.mutations) {
|
|
1183
|
-
const deferred
|
|
1486
|
+
const deferred = createPostCommitEffects()
|
|
1184
1487
|
try {
|
|
1185
1488
|
const txStarted = performance.now()
|
|
1186
1489
|
// consume the before-commit fault OUTSIDE the transaction it is
|
|
@@ -1191,7 +1494,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1191
1494
|
const preflight = await this.ctx.storage.transaction(async () => {
|
|
1192
1495
|
// Storage transactions may retry their closure. Never carry a
|
|
1193
1496
|
// deferred effect from an abandoned attempt into the commit.
|
|
1194
|
-
deferred.
|
|
1497
|
+
deferred.beginAttempt()
|
|
1195
1498
|
const decision = this.#wasm(() =>
|
|
1196
1499
|
engine_preflight(
|
|
1197
1500
|
this.#engineDb,
|
|
@@ -1208,9 +1511,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1208
1511
|
claims,
|
|
1209
1512
|
clientID: mutation.clientID,
|
|
1210
1513
|
mutationID: mutation.id,
|
|
1211
|
-
defer
|
|
1212
|
-
deferred.push(effect)
|
|
1213
|
-
},
|
|
1514
|
+
defer: deferred.defer,
|
|
1214
1515
|
})
|
|
1215
1516
|
if (beforeCommitFault)
|
|
1216
1517
|
throw this.#faultError(
|
|
@@ -1253,9 +1554,18 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1253
1554
|
// waitUntil anchors the coalescing timer across request completion,
|
|
1254
1555
|
// while the next serialized client push can join the same batch.
|
|
1255
1556
|
this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
|
|
1256
|
-
await
|
|
1557
|
+
await deferred.runAfterCommit((error) => {
|
|
1558
|
+
this.#counters.externalEffectFailures++
|
|
1559
|
+
console.error(
|
|
1560
|
+
JSON.stringify({
|
|
1561
|
+
event: 'sync_external_effect_error',
|
|
1562
|
+
hostVersion: config.hostVersion,
|
|
1563
|
+
error: errorMessage(error),
|
|
1564
|
+
})
|
|
1565
|
+
)
|
|
1566
|
+
})
|
|
1257
1567
|
} catch (error) {
|
|
1258
|
-
const isAppError = error
|
|
1568
|
+
const isAppError = isMutationApplicationError(error)
|
|
1259
1569
|
if (!isAppError) throw error
|
|
1260
1570
|
this.#counters.applicationErrors++
|
|
1261
1571
|
resultClass = 'application_error'
|
|
@@ -1301,7 +1611,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1301
1611
|
throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
|
|
1302
1612
|
|
|
1303
1613
|
const response = this.#wasm(() => engine_assemble_push_response(results))
|
|
1304
|
-
const state = this.#
|
|
1614
|
+
const state = this.#engineStateBestEffort()
|
|
1305
1615
|
this.#log({
|
|
1306
1616
|
namespaceHash: namespace,
|
|
1307
1617
|
requestKind: 'push',
|
|
@@ -1330,14 +1640,15 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1330
1640
|
} catch (error) {
|
|
1331
1641
|
const status = statusOf(error)
|
|
1332
1642
|
if (status === 500) this.#counters.invariantFailures++
|
|
1643
|
+
const state = this.#engineStateBestEffort()
|
|
1333
1644
|
this.#log({
|
|
1334
1645
|
namespaceHash: namespace,
|
|
1335
1646
|
requestKind: 'push',
|
|
1336
1647
|
resultClass: 'error',
|
|
1337
1648
|
inputCookie: null,
|
|
1338
1649
|
outputCookie: null,
|
|
1339
|
-
retainedFloor:
|
|
1340
|
-
currentWatermark:
|
|
1650
|
+
retainedFloor: state?.floor ?? null,
|
|
1651
|
+
currentWatermark: state?.watermark ?? null,
|
|
1341
1652
|
changeRowsScanned: 0,
|
|
1342
1653
|
changeRowsIncluded: 0,
|
|
1343
1654
|
queriesRecomputed: 0,
|
|
@@ -1402,7 +1713,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1402
1713
|
heapUsedBytes: heap?.usedJSHeapSize ?? null,
|
|
1403
1714
|
heapTotalBytes: heap?.totalJSHeapSize ?? null,
|
|
1404
1715
|
heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
|
|
1405
|
-
engine: this.#
|
|
1716
|
+
engine: this.#engineStateBestEffort(),
|
|
1406
1717
|
counters: this.#counters,
|
|
1407
1718
|
ingestBreaker: this.#ingestBreaker.status(),
|
|
1408
1719
|
})
|
|
@@ -1410,9 +1721,22 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1410
1721
|
}
|
|
1411
1722
|
if (route === '/admin/sql') {
|
|
1412
1723
|
return request.json().then((body) => {
|
|
1413
|
-
const { query } = body as { query?: string }
|
|
1724
|
+
const { params, query } = body as { params?: unknown; query?: string }
|
|
1414
1725
|
if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
|
|
1415
|
-
|
|
1726
|
+
try {
|
|
1727
|
+
return json({ rows: this.#directSql.query(query, decodeSqlParams(params)) })
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
if (error instanceof TypeError && error.message.startsWith('params')) {
|
|
1730
|
+
return json({ error: `invalid params: ${error.message}` }, 400)
|
|
1731
|
+
}
|
|
1732
|
+
if (
|
|
1733
|
+
error instanceof TypeError &&
|
|
1734
|
+
error.message === 'transaction SQL is host-owned and forbidden'
|
|
1735
|
+
) {
|
|
1736
|
+
return json({ error: error.message }, 400)
|
|
1737
|
+
}
|
|
1738
|
+
throw error
|
|
1739
|
+
}
|
|
1416
1740
|
})
|
|
1417
1741
|
}
|
|
1418
1742
|
if (route === '/admin/invalidate') {
|
|
@@ -1427,14 +1751,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1427
1751
|
}
|
|
1428
1752
|
return (async () => {
|
|
1429
1753
|
try {
|
|
1430
|
-
const beforeUpstreamWatermark = this.#engineState()
|
|
1754
|
+
const beforeUpstreamWatermark = this.#engineState().upstreamWatermark
|
|
1431
1755
|
const applied = await this.#ingest(upstreamPath, true)
|
|
1432
1756
|
const engine = this.#engineState()
|
|
1433
1757
|
return json({
|
|
1434
1758
|
ok: true,
|
|
1435
1759
|
applied,
|
|
1436
1760
|
beforeUpstreamWatermark,
|
|
1437
|
-
afterUpstreamWatermark: engine
|
|
1761
|
+
afterUpstreamWatermark: engine.upstreamWatermark,
|
|
1438
1762
|
engine,
|
|
1439
1763
|
})
|
|
1440
1764
|
} catch (error) {
|
|
@@ -1447,12 +1771,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1447
1771
|
return json({ ok: true })
|
|
1448
1772
|
}
|
|
1449
1773
|
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()
|
|
1774
|
+
this.ctx.abort('admin requested durable object restart')
|
|
1456
1775
|
return json({ ok: true, bootID: this.#bootID })
|
|
1457
1776
|
}
|
|
1458
1777
|
if (route === '/admin/visibility') {
|