orez-sync-cf-host 0.4.52 → 0.4.53
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/package.json +2 -2
- package/src/config.ts +23 -0
- package/src/host.ts +227 -11
- package/src/ingest-harness-worker.ts +76 -1
- package/src/types.ts +20 -0
- package/src/write-safeguards.ts +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orez-sync-cf-host",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.53",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"bundle": "bun run build:wasm && wrangler deploy --dry-run --outdir dist",
|
|
12
12
|
"measure": "bun run build:wasm && bun measure.mjs",
|
|
13
13
|
"test": "bun run test:config && bun run test:platform && bun run test:integration && bun run test:ingest && bun run test:restart",
|
|
14
|
-
"test:config": "bun test config.test.mjs",
|
|
14
|
+
"test:config": "bun test config.test.mjs write-safeguards.test.mjs",
|
|
15
15
|
"test:integration": "bun run build:wasm && bun integration-test.mjs",
|
|
16
16
|
"test:ingest": "bun run build:wasm && bun ingest-test.mjs",
|
|
17
17
|
"test:restart": "bun restart-test.mjs",
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,9 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
|
|
|
21
21
|
if (config.mutateBinding !== undefined && !hasDelegate) {
|
|
22
22
|
throw new TypeError('sync host config mutateBinding requires mutateUrl')
|
|
23
23
|
}
|
|
24
|
+
if (config.delegatedPushRetry !== undefined && !hasDelegate) {
|
|
25
|
+
throw new TypeError('sync host config delegatedPushRetry requires mutateUrl')
|
|
26
|
+
}
|
|
24
27
|
if (config.mutateBinding !== undefined && !config.mutateBinding) {
|
|
25
28
|
throw new TypeError('sync host config mutateBinding must not be empty')
|
|
26
29
|
}
|
|
@@ -42,6 +45,26 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
|
|
|
42
45
|
if (!Number.isSafeInteger(interval) || interval < 1_000) {
|
|
43
46
|
throw new TypeError('upstream.intervalMs must be a safe integer >= 1000')
|
|
44
47
|
}
|
|
48
|
+
for (const [name, value] of Object.entries({
|
|
49
|
+
ingestBudgetRows: config.upstream.ingestBudgetRows ?? 250_000,
|
|
50
|
+
ingestBudgetWindowMs: config.upstream.ingestBudgetWindowMs ?? 300_000,
|
|
51
|
+
ingestBackoffMs: config.upstream.ingestBackoffMs ?? 1_000,
|
|
52
|
+
ingestMaxBackoffMs: config.upstream.ingestMaxBackoffMs ?? 60_000,
|
|
53
|
+
})) {
|
|
54
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
55
|
+
throw new TypeError(`upstream.${name} must be a positive safe integer`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (config.delegatedPushRetry) {
|
|
59
|
+
for (const [name, value] of Object.entries({
|
|
60
|
+
maxAttempts: config.delegatedPushRetry.maxAttempts ?? 3,
|
|
61
|
+
initialBackoffMs: config.delegatedPushRetry.initialBackoffMs ?? 100,
|
|
62
|
+
maxBackoffMs: config.delegatedPushRetry.maxBackoffMs ?? 1_000,
|
|
63
|
+
timeoutMs: config.delegatedPushRetry.timeoutMs ?? 5_000,
|
|
64
|
+
})) {
|
|
65
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
66
|
+
throw new TypeError(`delegatedPushRetry.${name} must be a positive safe integer`)
|
|
67
|
+
}
|
|
45
68
|
}
|
|
46
69
|
return config
|
|
47
70
|
}
|
package/src/host.ts
CHANGED
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
SqlStorageSyncDb,
|
|
29
29
|
} from './sql-storage-adapter.js'
|
|
30
30
|
import { MutationApplicationError } from './types.js'
|
|
31
|
+
import {
|
|
32
|
+
IngestBreakerError,
|
|
33
|
+
IngestCircuitBreaker,
|
|
34
|
+
retryDelayMs,
|
|
35
|
+
shouldRetryDelegatedPush,
|
|
36
|
+
} from './write-safeguards.js'
|
|
31
37
|
|
|
32
38
|
import type {
|
|
33
39
|
DeferredEffect,
|
|
@@ -141,6 +147,18 @@ function errorMessage(error: unknown): string {
|
|
|
141
147
|
return error instanceof Error ? error.message : String(error)
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
function errorBody(error: unknown): Record<string, unknown> {
|
|
151
|
+
if (error instanceof IngestBreakerError) {
|
|
152
|
+
return {
|
|
153
|
+
error: error.error,
|
|
154
|
+
windowRows: error.windowRows,
|
|
155
|
+
budget: error.budget,
|
|
156
|
+
retryAfterMs: error.retryAfterMs,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { error: errorMessage(error) }
|
|
160
|
+
}
|
|
161
|
+
|
|
144
162
|
function requestError(message: string, status = 400): Error & { status: number } {
|
|
145
163
|
return Object.assign(new Error(message), { status })
|
|
146
164
|
}
|
|
@@ -268,6 +286,14 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
268
286
|
const wakeCoalesceMs = config.wakeCoalesceMs ?? 25
|
|
269
287
|
const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000
|
|
270
288
|
const upstreamLimit = config.upstream?.changeLimit ?? 1_000
|
|
289
|
+
const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 250_000
|
|
290
|
+
const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000
|
|
291
|
+
const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000
|
|
292
|
+
const ingestMaxBackoffMs = config.upstream?.ingestMaxBackoffMs ?? 60_000
|
|
293
|
+
const delegateMaxAttempts = config.delegatedPushRetry?.maxAttempts ?? 3
|
|
294
|
+
const delegateInitialBackoffMs = config.delegatedPushRetry?.initialBackoffMs ?? 100
|
|
295
|
+
const delegateMaxBackoffMs = config.delegatedPushRetry?.maxBackoffMs ?? 1_000
|
|
296
|
+
const delegateTimeoutMs = config.delegatedPushRetry?.timeoutMs ?? 5_000
|
|
271
297
|
|
|
272
298
|
return class SyncDurableObject extends DurableObject<Env> {
|
|
273
299
|
readonly #engineDb: SqlStorageSyncDb
|
|
@@ -283,6 +309,13 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
283
309
|
#wakeRecipients = new Set<WebSocket>()
|
|
284
310
|
#wakePromise: Promise<void> | null = null
|
|
285
311
|
#ingestPromise: Promise<number> | null = null
|
|
312
|
+
#ingestBreaker = new IngestCircuitBreaker({
|
|
313
|
+
budgetRows: ingestBudgetRows,
|
|
314
|
+
windowMs: ingestBudgetWindowMs,
|
|
315
|
+
initialBackoffMs: ingestBackoffMs,
|
|
316
|
+
maxBackoffMs: ingestMaxBackoffMs,
|
|
317
|
+
now: () => Date.now(),
|
|
318
|
+
})
|
|
286
319
|
|
|
287
320
|
constructor(ctx: DurableObjectState, env: Env) {
|
|
288
321
|
super(ctx, env)
|
|
@@ -301,6 +334,17 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
301
334
|
this.#directSql.exec(
|
|
302
335
|
"INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')"
|
|
303
336
|
)
|
|
337
|
+
const ingestBreakerReason = this.#controlGet('ingestBreakerReason')
|
|
338
|
+
if (
|
|
339
|
+
ingestBreakerReason === 'ingestBudgetExceeded' ||
|
|
340
|
+
ingestBreakerReason === 'ingestCursorStalled'
|
|
341
|
+
) {
|
|
342
|
+
this.#ingestBreaker.restore(
|
|
343
|
+
ingestBreakerReason,
|
|
344
|
+
Number(this.#controlGet('ingestBreakerRetryAt')),
|
|
345
|
+
Number(this.#controlGet('ingestBreakerTrips'))
|
|
346
|
+
)
|
|
347
|
+
}
|
|
304
348
|
this.#wasm(() => engine_init_schema(this.#engineDb, config.schema))
|
|
305
349
|
if (config.queryAware || config.resolveQuery)
|
|
306
350
|
this.#wasm(() => engine_init_query_schema(this.#engineDb))
|
|
@@ -368,6 +412,34 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
368
412
|
)
|
|
369
413
|
}
|
|
370
414
|
|
|
415
|
+
#controlDelete(...keys: string[]): void {
|
|
416
|
+
if (keys.length === 0) return
|
|
417
|
+
this.#directSql.exec(
|
|
418
|
+
`DELETE FROM _zsync_host_control WHERE key IN (${keys.map(() => '?').join(', ')})`,
|
|
419
|
+
keys
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#persistIngestBreaker(): void {
|
|
424
|
+
const status = this.#ingestBreaker.status()
|
|
425
|
+
if (!status.reason || status.retryAt === null) return
|
|
426
|
+
this.#controlSet('ingestBreakerReason', status.reason)
|
|
427
|
+
this.#controlSet('ingestBreakerRetryAt', String(status.retryAt))
|
|
428
|
+
this.#controlSet('ingestBreakerTrips', String(status.consecutiveTrips))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#recoverIngestBreaker(): void {
|
|
432
|
+
const wasTripped = this.#ingestBreaker.status().reason !== null
|
|
433
|
+
this.#ingestBreaker.recovered()
|
|
434
|
+
if (wasTripped) {
|
|
435
|
+
this.#controlDelete(
|
|
436
|
+
'ingestBreakerReason',
|
|
437
|
+
'ingestBreakerRetryAt',
|
|
438
|
+
'ingestBreakerTrips'
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
371
443
|
#writerEnabled(): boolean {
|
|
372
444
|
return this.#controlGet('writerEnabled') === '1'
|
|
373
445
|
}
|
|
@@ -437,9 +509,49 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
437
509
|
return this.#controlGet('upstreamPath')
|
|
438
510
|
}
|
|
439
511
|
|
|
512
|
+
#tripIngest(
|
|
513
|
+
reason: 'ingestBudgetExceeded' | 'ingestCursorStalled',
|
|
514
|
+
fields: Record<string, unknown>
|
|
515
|
+
): never {
|
|
516
|
+
try {
|
|
517
|
+
return this.#ingestBreaker.trip(reason)
|
|
518
|
+
} catch (error) {
|
|
519
|
+
this.#persistIngestBreaker()
|
|
520
|
+
const status = this.#ingestBreaker.status()
|
|
521
|
+
console.error(
|
|
522
|
+
JSON.stringify({
|
|
523
|
+
event: 'sync_upstream_ingest_breaker_tripped',
|
|
524
|
+
...status,
|
|
525
|
+
reason,
|
|
526
|
+
...fields,
|
|
527
|
+
})
|
|
528
|
+
)
|
|
529
|
+
throw error
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#recordIngestRows(rows: number, fields: Record<string, unknown>): void {
|
|
534
|
+
try {
|
|
535
|
+
this.#ingestBreaker.record(rows)
|
|
536
|
+
} catch (error) {
|
|
537
|
+
this.#persistIngestBreaker()
|
|
538
|
+
const status = this.#ingestBreaker.status()
|
|
539
|
+
console.error(
|
|
540
|
+
JSON.stringify({
|
|
541
|
+
event: 'sync_upstream_ingest_breaker_tripped',
|
|
542
|
+
...status,
|
|
543
|
+
reason: 'ingestBudgetExceeded',
|
|
544
|
+
...fields,
|
|
545
|
+
})
|
|
546
|
+
)
|
|
547
|
+
throw error
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
440
551
|
#ingest(upstreamPath?: string | null): Promise<number> {
|
|
441
552
|
if (!config.upstream) return Promise.resolve(0)
|
|
442
553
|
if (this.#ingestPromise) return this.#ingestPromise
|
|
554
|
+
this.#ingestBreaker.assertReady()
|
|
443
555
|
const path = upstreamPath ?? this.#controlGet('upstreamPath')
|
|
444
556
|
if (!path) return Promise.resolve(0)
|
|
445
557
|
this.#ingestPromise = (async () => {
|
|
@@ -468,6 +580,20 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
468
580
|
)
|
|
469
581
|
) as ApplyUpstreamResult
|
|
470
582
|
total += rebuilt.applied
|
|
583
|
+
this.#recordIngestRows(rebuilt.applied, {
|
|
584
|
+
phase: 'snapshot',
|
|
585
|
+
cursor,
|
|
586
|
+
resultWatermark: rebuilt.watermark,
|
|
587
|
+
})
|
|
588
|
+
const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
|
|
589
|
+
if (String(nextCursor) === String(cursor)) {
|
|
590
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
591
|
+
phase: 'snapshot',
|
|
592
|
+
cursor,
|
|
593
|
+
resultWatermark: rebuilt.watermark,
|
|
594
|
+
applied: rebuilt.applied,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
471
597
|
continue
|
|
472
598
|
}
|
|
473
599
|
if (!response.ok) {
|
|
@@ -481,11 +607,33 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
481
607
|
this.#wasm(() => engine_apply_upstream(this.#engineDb, config.schema, batch))
|
|
482
608
|
) as ApplyUpstreamResult
|
|
483
609
|
total += result.applied
|
|
610
|
+
this.#recordIngestRows(result.applied, {
|
|
611
|
+
phase: 'changes',
|
|
612
|
+
cursor,
|
|
613
|
+
batchWatermark: batch.watermark,
|
|
614
|
+
})
|
|
615
|
+
const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
|
|
616
|
+
if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
|
|
617
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
618
|
+
phase: 'changes',
|
|
619
|
+
cursor,
|
|
620
|
+
batchWatermark: batch.watermark,
|
|
621
|
+
changeRows: batch.changes.length,
|
|
622
|
+
applied: result.applied,
|
|
623
|
+
})
|
|
624
|
+
}
|
|
484
625
|
if (result.caughtUp) break
|
|
485
626
|
if (result.applied === 0) {
|
|
486
|
-
|
|
627
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
628
|
+
phase: 'changes',
|
|
629
|
+
cursor,
|
|
630
|
+
batchWatermark: batch.watermark,
|
|
631
|
+
changeRows: batch.changes.length,
|
|
632
|
+
applied: result.applied,
|
|
633
|
+
})
|
|
487
634
|
}
|
|
488
635
|
}
|
|
636
|
+
this.#recoverIngestBreaker()
|
|
489
637
|
if (total > 0) await this.#enqueueWake('__upstream__')
|
|
490
638
|
return total
|
|
491
639
|
})().finally(() => {
|
|
@@ -494,6 +642,58 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
494
642
|
return this.#ingestPromise
|
|
495
643
|
}
|
|
496
644
|
|
|
645
|
+
async #fetchDelegatedPush(
|
|
646
|
+
endpoint: URL,
|
|
647
|
+
headers: Headers,
|
|
648
|
+
body: ArrayBuffer
|
|
649
|
+
): Promise<Response> {
|
|
650
|
+
const binding = this.#serviceBinding(
|
|
651
|
+
config.mutateBinding ?? config.upstream?.binding
|
|
652
|
+
)
|
|
653
|
+
let lastError: unknown = null
|
|
654
|
+
for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
|
|
655
|
+
let response: Response | null = null
|
|
656
|
+
try {
|
|
657
|
+
response = await binding.fetch(endpoint.toString(), {
|
|
658
|
+
method: 'POST',
|
|
659
|
+
headers,
|
|
660
|
+
body,
|
|
661
|
+
signal: AbortSignal.timeout(delegateTimeoutMs),
|
|
662
|
+
})
|
|
663
|
+
} catch (error) {
|
|
664
|
+
lastError = error
|
|
665
|
+
}
|
|
666
|
+
if (
|
|
667
|
+
!shouldRetryDelegatedPush(
|
|
668
|
+
response?.status ?? null,
|
|
669
|
+
attempt,
|
|
670
|
+
delegateMaxAttempts
|
|
671
|
+
)
|
|
672
|
+
) {
|
|
673
|
+
if (response) return response
|
|
674
|
+
throw lastError
|
|
675
|
+
}
|
|
676
|
+
await response?.body?.cancel()
|
|
677
|
+
const delayMs = retryDelayMs(
|
|
678
|
+
attempt,
|
|
679
|
+
delegateInitialBackoffMs,
|
|
680
|
+
delegateMaxBackoffMs
|
|
681
|
+
)
|
|
682
|
+
console.warn(
|
|
683
|
+
JSON.stringify({
|
|
684
|
+
event: 'sync_delegated_push_retry',
|
|
685
|
+
attempt,
|
|
686
|
+
maxAttempts: delegateMaxAttempts,
|
|
687
|
+
status: response?.status ?? null,
|
|
688
|
+
delayMs,
|
|
689
|
+
error: response ? null : errorMessage(lastError),
|
|
690
|
+
})
|
|
691
|
+
)
|
|
692
|
+
await scheduler.wait(delayMs)
|
|
693
|
+
}
|
|
694
|
+
throw lastError ?? new Error('delegated push retry exhausted')
|
|
695
|
+
}
|
|
696
|
+
|
|
497
697
|
#log(fields: Record<string, unknown>): void {
|
|
498
698
|
console.log(
|
|
499
699
|
JSON.stringify({
|
|
@@ -800,13 +1000,11 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
800
1000
|
headers.delete(NAMESPACE_HEADER)
|
|
801
1001
|
headers.delete(UPSTREAM_PATH_HEADER)
|
|
802
1002
|
headers.set('host', endpoint.host)
|
|
803
|
-
const upstreamResponse = await this.#
|
|
804
|
-
|
|
805
|
-
).fetch(endpoint.toString(), {
|
|
806
|
-
method: 'POST',
|
|
1003
|
+
const upstreamResponse = await this.#fetchDelegatedPush(
|
|
1004
|
+
endpoint,
|
|
807
1005
|
headers,
|
|
808
|
-
|
|
809
|
-
|
|
1006
|
+
bytes
|
|
1007
|
+
)
|
|
810
1008
|
if (!upstreamResponse.ok) {
|
|
811
1009
|
return new Response(upstreamResponse.body, upstreamResponse)
|
|
812
1010
|
}
|
|
@@ -860,7 +1058,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
860
1058
|
return json({ pushResponse: upstreamBody.pushResponse ?? upstreamBody })
|
|
861
1059
|
} catch (error) {
|
|
862
1060
|
const status = statusOf(error)
|
|
863
|
-
return json(
|
|
1061
|
+
return json(errorBody(error), status)
|
|
864
1062
|
}
|
|
865
1063
|
}
|
|
866
1064
|
try {
|
|
@@ -1083,6 +1281,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1083
1281
|
heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
|
|
1084
1282
|
engine: this.#engineState(),
|
|
1085
1283
|
counters: this.#counters,
|
|
1284
|
+
ingestBreaker: this.#ingestBreaker.status(),
|
|
1086
1285
|
})
|
|
1087
1286
|
}
|
|
1088
1287
|
if (route === '/admin/sql') {
|
|
@@ -1151,6 +1350,17 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1151
1350
|
return json({ ok: true, writerEnabled: enabled })
|
|
1152
1351
|
})
|
|
1153
1352
|
}
|
|
1353
|
+
if (route === '/admin/ingest-breaker') {
|
|
1354
|
+
if (request.method === 'GET') return json(this.#ingestBreaker.status())
|
|
1355
|
+
this.#ingestBreaker.reopen()
|
|
1356
|
+
this.#controlDelete(
|
|
1357
|
+
'ingestBreakerReason',
|
|
1358
|
+
'ingestBreakerRetryAt',
|
|
1359
|
+
'ingestBreakerTrips'
|
|
1360
|
+
)
|
|
1361
|
+
console.log(JSON.stringify({ event: 'sync_upstream_ingest_breaker_reopened' }))
|
|
1362
|
+
return json({ ok: true, ...this.#ingestBreaker.status() })
|
|
1363
|
+
}
|
|
1154
1364
|
if (route === '/admin/fault') {
|
|
1155
1365
|
return request
|
|
1156
1366
|
.json()
|
|
@@ -1202,7 +1412,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1202
1412
|
const applied = await this.#ingest(upstreamPath)
|
|
1203
1413
|
return json({ ok: true, applied })
|
|
1204
1414
|
} catch (error) {
|
|
1205
|
-
return json(
|
|
1415
|
+
return json(errorBody(error), statusOf(error))
|
|
1206
1416
|
}
|
|
1207
1417
|
}
|
|
1208
1418
|
|
|
@@ -1212,7 +1422,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1212
1422
|
try {
|
|
1213
1423
|
await this.#ingest(upstreamPath)
|
|
1214
1424
|
} catch (error) {
|
|
1215
|
-
|
|
1425
|
+
// Workerd requires a forwarded request body to be consumed even when
|
|
1426
|
+
// ingest fails before the pull handler parses it.
|
|
1427
|
+
await request.arrayBuffer()
|
|
1428
|
+
return json(errorBody(error), statusOf(error))
|
|
1216
1429
|
}
|
|
1217
1430
|
return this.#pull(request, claims, namespace)
|
|
1218
1431
|
}
|
|
@@ -1234,7 +1447,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1234
1447
|
})
|
|
1235
1448
|
)
|
|
1236
1449
|
} finally {
|
|
1237
|
-
|
|
1450
|
+
const retryAfterMs = this.#ingestBreaker.status().retryAfterMs
|
|
1451
|
+
await this.ctx.storage.setAlarm(
|
|
1452
|
+
Date.now() + Math.max(upstreamIntervalMs, retryAfterMs)
|
|
1453
|
+
)
|
|
1238
1454
|
}
|
|
1239
1455
|
}
|
|
1240
1456
|
|
|
@@ -27,11 +27,21 @@ interface Env extends SyncHostEnv {
|
|
|
27
27
|
UPSTREAM_DO: DurableObjectNamespace
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const runawayNamespaces = new Set<string>()
|
|
31
|
+
let delegatedFailuresRemaining = 0
|
|
32
|
+
let delegatedAttempts = 0
|
|
33
|
+
|
|
30
34
|
const config: SyncHostConfig<Env> = {
|
|
31
35
|
hostVersion: 'upstream-ingest-harness',
|
|
32
36
|
schema,
|
|
33
37
|
mutateUrl: '/api/zero/push',
|
|
34
38
|
mutateBinding: 'APP',
|
|
39
|
+
delegatedPushRetry: {
|
|
40
|
+
maxAttempts: 3,
|
|
41
|
+
initialBackoffMs: 10,
|
|
42
|
+
maxBackoffMs: 20,
|
|
43
|
+
timeoutMs: 1_000,
|
|
44
|
+
},
|
|
35
45
|
upstream: {
|
|
36
46
|
binding: 'DATA',
|
|
37
47
|
namespacePath: (namespace) => `/${namespace}`,
|
|
@@ -85,12 +95,36 @@ async function upstreamFetch(request: Request, env: Env): Promise<Response> {
|
|
|
85
95
|
/** Self service-binding target backed by the real ZeroSqlDO. */
|
|
86
96
|
export class DataService extends WorkerEntrypoint<Env> {
|
|
87
97
|
fetch(request: Request): Promise<Response> {
|
|
88
|
-
const
|
|
98
|
+
const url = new URL(request.url)
|
|
99
|
+
const pathname = url.pathname
|
|
89
100
|
if (!pathname.endsWith('/changes') && !pathname.endsWith('/snapshot')) {
|
|
90
101
|
return Promise.resolve(
|
|
91
102
|
new Response('DATA route rejected non-feed request', { status: 418 })
|
|
92
103
|
)
|
|
93
104
|
}
|
|
105
|
+
const namespace = pathname.split('/')[1] ?? ''
|
|
106
|
+
if (pathname.endsWith('/changes') && runawayNamespaces.has(namespace)) {
|
|
107
|
+
return Promise.resolve(
|
|
108
|
+
Response.json({
|
|
109
|
+
watermark: 100,
|
|
110
|
+
changes: [
|
|
111
|
+
{
|
|
112
|
+
watermark: 1,
|
|
113
|
+
tableName: 'item',
|
|
114
|
+
op: 'INSERT',
|
|
115
|
+
rowData: {
|
|
116
|
+
id: 'runaway-replay',
|
|
117
|
+
label: 'replayed without cursor progress',
|
|
118
|
+
rank: 1,
|
|
119
|
+
done: false,
|
|
120
|
+
meta: null,
|
|
121
|
+
},
|
|
122
|
+
oldData: null,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
})
|
|
126
|
+
)
|
|
127
|
+
}
|
|
94
128
|
return upstreamFetch(request, this.env)
|
|
95
129
|
}
|
|
96
130
|
}
|
|
@@ -102,6 +136,13 @@ export class AppService extends WorkerEntrypoint<Env> {
|
|
|
102
136
|
new Response('APP route rejected non-push request', { status: 418 })
|
|
103
137
|
)
|
|
104
138
|
}
|
|
139
|
+
delegatedAttempts++
|
|
140
|
+
if (delegatedFailuresRemaining > 0) {
|
|
141
|
+
delegatedFailuresRemaining--
|
|
142
|
+
return Promise.resolve(
|
|
143
|
+
Response.json({ error: 'synthetic delegated push failure' }, { status: 503 })
|
|
144
|
+
)
|
|
145
|
+
}
|
|
105
146
|
return upstreamFetch(request, this.env)
|
|
106
147
|
}
|
|
107
148
|
}
|
|
@@ -110,6 +151,40 @@ const syncWorker = createSyncWorker(config)
|
|
|
110
151
|
export default {
|
|
111
152
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
112
153
|
const url = new URL(request.url)
|
|
154
|
+
if (url.pathname.startsWith('/runaway-control/')) {
|
|
155
|
+
const namespace = url.pathname.slice('/runaway-control/'.length)
|
|
156
|
+
return request
|
|
157
|
+
.json()
|
|
158
|
+
.catch(() => ({}))
|
|
159
|
+
.then((body) => {
|
|
160
|
+
if ((body as { enabled?: unknown }).enabled === true)
|
|
161
|
+
runawayNamespaces.add(namespace)
|
|
162
|
+
else runawayNamespaces.delete(namespace)
|
|
163
|
+
return Response.json({
|
|
164
|
+
ok: true,
|
|
165
|
+
namespace,
|
|
166
|
+
enabled: runawayNamespaces.has(namespace),
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
if (url.pathname === '/delegation-control') {
|
|
171
|
+
if (request.method === 'GET') {
|
|
172
|
+
return Promise.resolve(
|
|
173
|
+
Response.json({ delegatedFailuresRemaining, delegatedAttempts })
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return request
|
|
177
|
+
.json()
|
|
178
|
+
.catch(() => ({}))
|
|
179
|
+
.then((body) => {
|
|
180
|
+
delegatedFailuresRemaining = Math.max(
|
|
181
|
+
0,
|
|
182
|
+
Number((body as { failures?: unknown }).failures) || 0
|
|
183
|
+
)
|
|
184
|
+
delegatedAttempts = 0
|
|
185
|
+
return Response.json({ delegatedFailuresRemaining, delegatedAttempts })
|
|
186
|
+
})
|
|
187
|
+
}
|
|
113
188
|
if (url.pathname.startsWith('/upstream/')) {
|
|
114
189
|
url.pathname = url.pathname.slice('/upstream'.length)
|
|
115
190
|
return upstreamFetch(new Request(url, request), env)
|
package/src/types.ts
CHANGED
|
@@ -116,6 +116,25 @@ export type UpstreamConfig = {
|
|
|
116
116
|
changeLimit?: number
|
|
117
117
|
/** Durable Object alarm safety net. Defaults to 15 seconds. */
|
|
118
118
|
intervalMs?: number
|
|
119
|
+
/** Applied upstream rows allowed per rolling window. Defaults to 250,000. */
|
|
120
|
+
ingestBudgetRows?: number
|
|
121
|
+
/** Rolling ingest budget window. Defaults to five minutes. */
|
|
122
|
+
ingestBudgetWindowMs?: number
|
|
123
|
+
/** Initial breaker cooldown. Defaults to one second. */
|
|
124
|
+
ingestBackoffMs?: number
|
|
125
|
+
/** Maximum breaker cooldown. Defaults to one minute. */
|
|
126
|
+
ingestMaxBackoffMs?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type DelegatedPushRetryConfig = {
|
|
130
|
+
/** Total attempts including the first request. Defaults to 3. */
|
|
131
|
+
maxAttempts?: number
|
|
132
|
+
/** Initial exponential delay. Defaults to 100ms. */
|
|
133
|
+
initialBackoffMs?: number
|
|
134
|
+
/** Delay cap. Defaults to 1,000ms. */
|
|
135
|
+
maxBackoffMs?: number
|
|
136
|
+
/** Per-attempt service-binding timeout. Defaults to 5,000ms. */
|
|
137
|
+
timeoutMs?: number
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv> = {
|
|
@@ -126,6 +145,7 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv> = {
|
|
|
126
145
|
mutateUrl?: string
|
|
127
146
|
/** Env binding for delegated pushes; defaults to upstream.binding. */
|
|
128
147
|
mutateBinding?: string
|
|
148
|
+
delegatedPushRetry?: DelegatedPushRetryConfig
|
|
129
149
|
/** Required for delegated push; forbidden with local mutators (no dual apply). */
|
|
130
150
|
upstream?: UpstreamConfig
|
|
131
151
|
/** Application DDL and optional seed, called before sync-core schema init. */
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export type IngestBreakerReason = 'ingestBudgetExceeded' | 'ingestCursorStalled'
|
|
2
|
+
|
|
3
|
+
export type IngestBreakerOptions = {
|
|
4
|
+
budgetRows: number
|
|
5
|
+
windowMs: number
|
|
6
|
+
initialBackoffMs: number
|
|
7
|
+
maxBackoffMs: number
|
|
8
|
+
now: () => number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type IngestBreakerStatus = {
|
|
12
|
+
windowRows: number
|
|
13
|
+
budget: number
|
|
14
|
+
windowMs: number
|
|
15
|
+
tripped: boolean
|
|
16
|
+
reason: IngestBreakerReason | null
|
|
17
|
+
retryAt: number | null
|
|
18
|
+
retryAfterMs: number
|
|
19
|
+
consecutiveTrips: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Sample = { at: number; rows: number }
|
|
23
|
+
|
|
24
|
+
export class IngestBreakerError extends Error {
|
|
25
|
+
readonly status = 429
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
readonly error: IngestBreakerReason,
|
|
29
|
+
readonly windowRows: number,
|
|
30
|
+
readonly budget: number,
|
|
31
|
+
readonly retryAfterMs: number
|
|
32
|
+
) {
|
|
33
|
+
super(`${error}: ${windowRows}/${budget} rows; retry after ${retryAfterMs}ms`)
|
|
34
|
+
this.name = 'IngestBreakerError'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class IngestCircuitBreaker {
|
|
39
|
+
readonly #options: IngestBreakerOptions
|
|
40
|
+
#samples: Sample[] = []
|
|
41
|
+
#windowRows = 0
|
|
42
|
+
#reason: IngestBreakerReason | null = null
|
|
43
|
+
#retryAt: number | null = null
|
|
44
|
+
#consecutiveTrips = 0
|
|
45
|
+
|
|
46
|
+
constructor(options: IngestBreakerOptions) {
|
|
47
|
+
for (const [name, value] of Object.entries(options)) {
|
|
48
|
+
if (name === 'now') continue
|
|
49
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1)
|
|
50
|
+
throw new TypeError(`${name} must be a positive safe integer`)
|
|
51
|
+
}
|
|
52
|
+
this.#options = options
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#prune(now: number): void {
|
|
56
|
+
const cutoff = now - this.#options.windowMs
|
|
57
|
+
let remove = 0
|
|
58
|
+
while (remove < this.#samples.length && this.#samples[remove]!.at <= cutoff) {
|
|
59
|
+
this.#windowRows -= this.#samples[remove]!.rows
|
|
60
|
+
remove++
|
|
61
|
+
}
|
|
62
|
+
if (remove > 0) this.#samples.splice(0, remove)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
status(): IngestBreakerStatus {
|
|
66
|
+
const now = this.#options.now()
|
|
67
|
+
this.#prune(now)
|
|
68
|
+
const retryAfterMs = Math.max(0, (this.#retryAt ?? now) - now)
|
|
69
|
+
return {
|
|
70
|
+
windowRows: this.#windowRows,
|
|
71
|
+
budget: this.#options.budgetRows,
|
|
72
|
+
windowMs: this.#options.windowMs,
|
|
73
|
+
tripped: this.#reason !== null && retryAfterMs > 0,
|
|
74
|
+
reason: this.#reason,
|
|
75
|
+
retryAt: this.#retryAt,
|
|
76
|
+
retryAfterMs,
|
|
77
|
+
consecutiveTrips: this.#consecutiveTrips,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
assertReady(): void {
|
|
82
|
+
const status = this.status()
|
|
83
|
+
if (status.tripped && status.reason) {
|
|
84
|
+
throw new IngestBreakerError(
|
|
85
|
+
status.reason,
|
|
86
|
+
status.windowRows,
|
|
87
|
+
status.budget,
|
|
88
|
+
status.retryAfterMs
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
record(rows: number): void {
|
|
94
|
+
this.assertReady()
|
|
95
|
+
if (!Number.isSafeInteger(rows) || rows <= 0) return
|
|
96
|
+
const now = this.#options.now()
|
|
97
|
+
this.#prune(now)
|
|
98
|
+
const last = this.#samples[this.#samples.length - 1]
|
|
99
|
+
if (last?.at === now) last.rows += rows
|
|
100
|
+
else this.#samples.push({ at: now, rows })
|
|
101
|
+
this.#windowRows += rows
|
|
102
|
+
if (this.#windowRows > this.#options.budgetRows) this.trip('ingestBudgetExceeded')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
trip(reason: IngestBreakerReason): never {
|
|
106
|
+
const now = this.#options.now()
|
|
107
|
+
this.#consecutiveTrips++
|
|
108
|
+
const delay = Math.min(
|
|
109
|
+
this.#options.maxBackoffMs,
|
|
110
|
+
this.#options.initialBackoffMs * 2 ** (this.#consecutiveTrips - 1)
|
|
111
|
+
)
|
|
112
|
+
this.#reason = reason
|
|
113
|
+
this.#retryAt = now + delay
|
|
114
|
+
throw new IngestBreakerError(
|
|
115
|
+
reason,
|
|
116
|
+
this.#windowRows,
|
|
117
|
+
this.#options.budgetRows,
|
|
118
|
+
delay
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
recovered(): void {
|
|
123
|
+
this.#reason = null
|
|
124
|
+
this.#retryAt = null
|
|
125
|
+
this.#consecutiveTrips = 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
restore(reason: IngestBreakerReason, retryAt: number, consecutiveTrips: number): void {
|
|
129
|
+
if (!Number.isFinite(retryAt) || retryAt <= 0) return
|
|
130
|
+
this.#reason = reason
|
|
131
|
+
this.#retryAt = retryAt
|
|
132
|
+
this.#consecutiveTrips = Math.max(1, Math.floor(consecutiveTrips) || 1)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
reopen(): void {
|
|
136
|
+
this.#samples = []
|
|
137
|
+
this.#windowRows = 0
|
|
138
|
+
this.recovered()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function retryDelayMs(
|
|
143
|
+
attempt: number,
|
|
144
|
+
initialBackoffMs: number,
|
|
145
|
+
maxBackoffMs: number
|
|
146
|
+
): number {
|
|
147
|
+
return Math.min(maxBackoffMs, initialBackoffMs * 2 ** Math.max(0, attempt - 1))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function shouldRetryDelegatedPush(
|
|
151
|
+
responseStatus: number | null,
|
|
152
|
+
attempt: number,
|
|
153
|
+
maxAttempts: number
|
|
154
|
+
): boolean {
|
|
155
|
+
if (attempt >= maxAttempts) return false
|
|
156
|
+
return responseStatus === null || responseStatus === 429 || responseStatus >= 500
|
|
157
|
+
}
|