orez-sync-cf-host 0.4.52 → 0.4.54
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 +283 -20
- package/src/ingest-harness-worker.ts +83 -3
- package/src/sql-storage-adapter.ts +23 -6
- package/src/types.ts +20 -0
- package/src/write-safeguards.ts +249 -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.54",
|
|
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 ?? 150_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 ?? 150_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,11 +309,22 @@ 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
|
+
#recordingIngestBillable = false
|
|
313
|
+
#ingestBreaker = new IngestCircuitBreaker({
|
|
314
|
+
budgetRows: ingestBudgetRows,
|
|
315
|
+
windowMs: ingestBudgetWindowMs,
|
|
316
|
+
initialBackoffMs: ingestBackoffMs,
|
|
317
|
+
maxBackoffMs: ingestMaxBackoffMs,
|
|
318
|
+
now: () => Date.now(),
|
|
319
|
+
})
|
|
286
320
|
|
|
287
321
|
constructor(ctx: DurableObjectState, env: Env) {
|
|
288
322
|
super(ctx, env)
|
|
289
|
-
|
|
290
|
-
|
|
323
|
+
const recordRowsWritten = (rows: number) => {
|
|
324
|
+
if (this.#recordingIngestBillable) this.#ingestBreaker.recordBillable(rows)
|
|
325
|
+
}
|
|
326
|
+
this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten)
|
|
327
|
+
this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten)
|
|
291
328
|
this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast) =>
|
|
292
329
|
this.#wasm(() => engine_compile_query(config.schema, ast))
|
|
293
330
|
)
|
|
@@ -301,6 +338,17 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
301
338
|
this.#directSql.exec(
|
|
302
339
|
"INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')"
|
|
303
340
|
)
|
|
341
|
+
const ingestBreakerReason = this.#controlGet('ingestBreakerReason')
|
|
342
|
+
if (
|
|
343
|
+
ingestBreakerReason === 'ingestBudgetExceeded' ||
|
|
344
|
+
ingestBreakerReason === 'ingestCursorStalled'
|
|
345
|
+
) {
|
|
346
|
+
this.#ingestBreaker.restore(
|
|
347
|
+
ingestBreakerReason,
|
|
348
|
+
Number(this.#controlGet('ingestBreakerRetryAt')),
|
|
349
|
+
Number(this.#controlGet('ingestBreakerTrips'))
|
|
350
|
+
)
|
|
351
|
+
}
|
|
304
352
|
this.#wasm(() => engine_init_schema(this.#engineDb, config.schema))
|
|
305
353
|
if (config.queryAware || config.resolveQuery)
|
|
306
354
|
this.#wasm(() => engine_init_query_schema(this.#engineDb))
|
|
@@ -368,6 +416,34 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
368
416
|
)
|
|
369
417
|
}
|
|
370
418
|
|
|
419
|
+
#controlDelete(...keys: string[]): void {
|
|
420
|
+
if (keys.length === 0) return
|
|
421
|
+
this.#directSql.exec(
|
|
422
|
+
`DELETE FROM _zsync_host_control WHERE key IN (${keys.map(() => '?').join(', ')})`,
|
|
423
|
+
keys
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#persistIngestBreaker(): void {
|
|
428
|
+
const status = this.#ingestBreaker.status()
|
|
429
|
+
if (!status.reason || status.retryAt === null) return
|
|
430
|
+
this.#controlSet('ingestBreakerReason', status.reason)
|
|
431
|
+
this.#controlSet('ingestBreakerRetryAt', String(status.retryAt))
|
|
432
|
+
this.#controlSet('ingestBreakerTrips', String(status.consecutiveTrips))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#recoverIngestBreaker(): void {
|
|
436
|
+
const wasTripped = this.#ingestBreaker.status().reason !== null
|
|
437
|
+
this.#ingestBreaker.recovered()
|
|
438
|
+
if (wasTripped) {
|
|
439
|
+
this.#controlDelete(
|
|
440
|
+
'ingestBreakerReason',
|
|
441
|
+
'ingestBreakerRetryAt',
|
|
442
|
+
'ingestBreakerTrips'
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
371
447
|
#writerEnabled(): boolean {
|
|
372
448
|
return this.#controlGet('writerEnabled') === '1'
|
|
373
449
|
}
|
|
@@ -427,6 +503,26 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
427
503
|
}
|
|
428
504
|
}
|
|
429
505
|
|
|
506
|
+
async #upstreamWriteBudgetStatus(): Promise<Response> {
|
|
507
|
+
if (!config.upstream) return json({ error: 'upstream is not configured' }, 404)
|
|
508
|
+
const path = this.#controlGet('upstreamPath')
|
|
509
|
+
if (!path) return json({ error: 'upstream path is not known yet' }, 409)
|
|
510
|
+
const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid')
|
|
511
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
512
|
+
headers: { host: endpoint.host },
|
|
513
|
+
})
|
|
514
|
+
if (!response.ok) {
|
|
515
|
+
return json(
|
|
516
|
+
{
|
|
517
|
+
error: 'upstream write-budget status unavailable',
|
|
518
|
+
upstreamStatus: response.status,
|
|
519
|
+
},
|
|
520
|
+
502
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
return json(await response.json())
|
|
524
|
+
}
|
|
525
|
+
|
|
430
526
|
#rememberUpstreamPath(request: Request): string | null {
|
|
431
527
|
if (!config.upstream) return null
|
|
432
528
|
const path = request.headers.get(UPSTREAM_PATH_HEADER)
|
|
@@ -437,9 +533,59 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
437
533
|
return this.#controlGet('upstreamPath')
|
|
438
534
|
}
|
|
439
535
|
|
|
536
|
+
#tripIngest(
|
|
537
|
+
reason: 'ingestBudgetExceeded' | 'ingestCursorStalled',
|
|
538
|
+
fields: Record<string, unknown>
|
|
539
|
+
): never {
|
|
540
|
+
try {
|
|
541
|
+
return this.#ingestBreaker.trip(reason)
|
|
542
|
+
} catch (error) {
|
|
543
|
+
this.#persistIngestBreaker()
|
|
544
|
+
const status = this.#ingestBreaker.status()
|
|
545
|
+
console.error(
|
|
546
|
+
JSON.stringify({
|
|
547
|
+
event: 'sync_upstream_ingest_breaker_tripped',
|
|
548
|
+
...status,
|
|
549
|
+
reason,
|
|
550
|
+
...fields,
|
|
551
|
+
})
|
|
552
|
+
)
|
|
553
|
+
throw error
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#recordIngestLogicalRows(rows: number): void {
|
|
558
|
+
this.#ingestBreaker.recordLogical(rows)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#withIngestBilling<T>(fields: Record<string, unknown>, apply: () => T): T {
|
|
562
|
+
this.#recordingIngestBillable = true
|
|
563
|
+
try {
|
|
564
|
+
return apply()
|
|
565
|
+
} catch (error) {
|
|
566
|
+
this.#recordingIngestBillable = false
|
|
567
|
+
if (error instanceof IngestBreakerError) {
|
|
568
|
+
this.#persistIngestBreaker()
|
|
569
|
+
const status = this.#ingestBreaker.status()
|
|
570
|
+
console.error(
|
|
571
|
+
JSON.stringify({
|
|
572
|
+
event: 'sync_upstream_ingest_breaker_tripped',
|
|
573
|
+
...status,
|
|
574
|
+
reason: 'ingestBudgetExceeded',
|
|
575
|
+
...fields,
|
|
576
|
+
})
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
throw error
|
|
580
|
+
} finally {
|
|
581
|
+
this.#recordingIngestBillable = false
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
440
585
|
#ingest(upstreamPath?: string | null): Promise<number> {
|
|
441
586
|
if (!config.upstream) return Promise.resolve(0)
|
|
442
587
|
if (this.#ingestPromise) return this.#ingestPromise
|
|
588
|
+
this.#ingestBreaker.assertReady()
|
|
443
589
|
const path = upstreamPath ?? this.#controlGet('upstreamPath')
|
|
444
590
|
if (!path) return Promise.resolve(0)
|
|
445
591
|
this.#ingestPromise = (async () => {
|
|
@@ -462,12 +608,30 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
462
608
|
throw new Error(`upstream snapshot returned ${snapshotResponse.status}`)
|
|
463
609
|
}
|
|
464
610
|
const snapshot = await snapshotResponse.json()
|
|
465
|
-
const rebuilt = this
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
611
|
+
const rebuilt = this.#withIngestBilling(
|
|
612
|
+
{ phase: 'snapshot', cursor },
|
|
613
|
+
() =>
|
|
614
|
+
this.ctx.storage.transactionSync(() =>
|
|
615
|
+
this.#wasm(() =>
|
|
616
|
+
engine_apply_upstream_snapshot(
|
|
617
|
+
this.#engineDb,
|
|
618
|
+
config.schema,
|
|
619
|
+
snapshot
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
) as ApplyUpstreamResult
|
|
623
|
+
)
|
|
470
624
|
total += rebuilt.applied
|
|
625
|
+
this.#recordIngestLogicalRows(rebuilt.applied)
|
|
626
|
+
const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
|
|
627
|
+
if (String(nextCursor) === String(cursor)) {
|
|
628
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
629
|
+
phase: 'snapshot',
|
|
630
|
+
cursor,
|
|
631
|
+
resultWatermark: rebuilt.watermark,
|
|
632
|
+
applied: rebuilt.applied,
|
|
633
|
+
})
|
|
634
|
+
}
|
|
471
635
|
continue
|
|
472
636
|
}
|
|
473
637
|
if (!response.ok) {
|
|
@@ -477,15 +641,44 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
477
641
|
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
478
642
|
throw new Error('invalid upstream changes response')
|
|
479
643
|
}
|
|
480
|
-
const result = this
|
|
481
|
-
|
|
644
|
+
const result = this.#withIngestBilling(
|
|
645
|
+
{
|
|
646
|
+
phase: 'changes',
|
|
647
|
+
cursor,
|
|
648
|
+
batchWatermark: batch.watermark,
|
|
649
|
+
changeRows: batch.changes.length,
|
|
650
|
+
},
|
|
651
|
+
() =>
|
|
652
|
+
this.ctx.storage.transactionSync(() =>
|
|
653
|
+
this.#wasm(() =>
|
|
654
|
+
engine_apply_upstream(this.#engineDb, config.schema, batch)
|
|
655
|
+
)
|
|
656
|
+
)
|
|
482
657
|
) as ApplyUpstreamResult
|
|
483
658
|
total += result.applied
|
|
659
|
+
this.#recordIngestLogicalRows(result.applied)
|
|
660
|
+
const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
|
|
661
|
+
if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
|
|
662
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
663
|
+
phase: 'changes',
|
|
664
|
+
cursor,
|
|
665
|
+
batchWatermark: batch.watermark,
|
|
666
|
+
changeRows: batch.changes.length,
|
|
667
|
+
applied: result.applied,
|
|
668
|
+
})
|
|
669
|
+
}
|
|
484
670
|
if (result.caughtUp) break
|
|
485
671
|
if (result.applied === 0) {
|
|
486
|
-
|
|
672
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
673
|
+
phase: 'changes',
|
|
674
|
+
cursor,
|
|
675
|
+
batchWatermark: batch.watermark,
|
|
676
|
+
changeRows: batch.changes.length,
|
|
677
|
+
applied: result.applied,
|
|
678
|
+
})
|
|
487
679
|
}
|
|
488
680
|
}
|
|
681
|
+
this.#recoverIngestBreaker()
|
|
489
682
|
if (total > 0) await this.#enqueueWake('__upstream__')
|
|
490
683
|
return total
|
|
491
684
|
})().finally(() => {
|
|
@@ -494,6 +687,58 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
494
687
|
return this.#ingestPromise
|
|
495
688
|
}
|
|
496
689
|
|
|
690
|
+
async #fetchDelegatedPush(
|
|
691
|
+
endpoint: URL,
|
|
692
|
+
headers: Headers,
|
|
693
|
+
body: ArrayBuffer
|
|
694
|
+
): Promise<Response> {
|
|
695
|
+
const binding = this.#serviceBinding(
|
|
696
|
+
config.mutateBinding ?? config.upstream?.binding
|
|
697
|
+
)
|
|
698
|
+
let lastError: unknown = null
|
|
699
|
+
for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
|
|
700
|
+
let response: Response | null = null
|
|
701
|
+
try {
|
|
702
|
+
response = await binding.fetch(endpoint.toString(), {
|
|
703
|
+
method: 'POST',
|
|
704
|
+
headers,
|
|
705
|
+
body,
|
|
706
|
+
signal: AbortSignal.timeout(delegateTimeoutMs),
|
|
707
|
+
})
|
|
708
|
+
} catch (error) {
|
|
709
|
+
lastError = error
|
|
710
|
+
}
|
|
711
|
+
if (
|
|
712
|
+
!shouldRetryDelegatedPush(
|
|
713
|
+
response?.status ?? null,
|
|
714
|
+
attempt,
|
|
715
|
+
delegateMaxAttempts
|
|
716
|
+
)
|
|
717
|
+
) {
|
|
718
|
+
if (response) return response
|
|
719
|
+
throw lastError
|
|
720
|
+
}
|
|
721
|
+
await response?.body?.cancel()
|
|
722
|
+
const delayMs = retryDelayMs(
|
|
723
|
+
attempt,
|
|
724
|
+
delegateInitialBackoffMs,
|
|
725
|
+
delegateMaxBackoffMs
|
|
726
|
+
)
|
|
727
|
+
console.warn(
|
|
728
|
+
JSON.stringify({
|
|
729
|
+
event: 'sync_delegated_push_retry',
|
|
730
|
+
attempt,
|
|
731
|
+
maxAttempts: delegateMaxAttempts,
|
|
732
|
+
status: response?.status ?? null,
|
|
733
|
+
delayMs,
|
|
734
|
+
error: response ? null : errorMessage(lastError),
|
|
735
|
+
})
|
|
736
|
+
)
|
|
737
|
+
await scheduler.wait(delayMs)
|
|
738
|
+
}
|
|
739
|
+
throw lastError ?? new Error('delegated push retry exhausted')
|
|
740
|
+
}
|
|
741
|
+
|
|
497
742
|
#log(fields: Record<string, unknown>): void {
|
|
498
743
|
console.log(
|
|
499
744
|
JSON.stringify({
|
|
@@ -800,13 +1045,11 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
800
1045
|
headers.delete(NAMESPACE_HEADER)
|
|
801
1046
|
headers.delete(UPSTREAM_PATH_HEADER)
|
|
802
1047
|
headers.set('host', endpoint.host)
|
|
803
|
-
const upstreamResponse = await this.#
|
|
804
|
-
|
|
805
|
-
).fetch(endpoint.toString(), {
|
|
806
|
-
method: 'POST',
|
|
1048
|
+
const upstreamResponse = await this.#fetchDelegatedPush(
|
|
1049
|
+
endpoint,
|
|
807
1050
|
headers,
|
|
808
|
-
|
|
809
|
-
|
|
1051
|
+
bytes
|
|
1052
|
+
)
|
|
810
1053
|
if (!upstreamResponse.ok) {
|
|
811
1054
|
return new Response(upstreamResponse.body, upstreamResponse)
|
|
812
1055
|
}
|
|
@@ -860,7 +1103,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
860
1103
|
return json({ pushResponse: upstreamBody.pushResponse ?? upstreamBody })
|
|
861
1104
|
} catch (error) {
|
|
862
1105
|
const status = statusOf(error)
|
|
863
|
-
return json(
|
|
1106
|
+
return json(errorBody(error), status)
|
|
864
1107
|
}
|
|
865
1108
|
}
|
|
866
1109
|
try {
|
|
@@ -1060,6 +1303,8 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1060
1303
|
|
|
1061
1304
|
#admin(route: string, request: Request): Promise<Response> | Response {
|
|
1062
1305
|
if (route === '/admin/health') return json({ ok: true })
|
|
1306
|
+
if (route === '/admin/upstream-write-budget' && request.method === 'GET')
|
|
1307
|
+
return this.#upstreamWriteBudgetStatus()
|
|
1063
1308
|
if (route === '/admin/status') {
|
|
1064
1309
|
const heap = (
|
|
1065
1310
|
performance as Performance & {
|
|
@@ -1083,6 +1328,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1083
1328
|
heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
|
|
1084
1329
|
engine: this.#engineState(),
|
|
1085
1330
|
counters: this.#counters,
|
|
1331
|
+
ingestBreaker: this.#ingestBreaker.status(),
|
|
1086
1332
|
})
|
|
1087
1333
|
}
|
|
1088
1334
|
if (route === '/admin/sql') {
|
|
@@ -1151,6 +1397,17 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1151
1397
|
return json({ ok: true, writerEnabled: enabled })
|
|
1152
1398
|
})
|
|
1153
1399
|
}
|
|
1400
|
+
if (route === '/admin/ingest-breaker') {
|
|
1401
|
+
if (request.method === 'GET') return json(this.#ingestBreaker.status())
|
|
1402
|
+
this.#ingestBreaker.reopen()
|
|
1403
|
+
this.#controlDelete(
|
|
1404
|
+
'ingestBreakerReason',
|
|
1405
|
+
'ingestBreakerRetryAt',
|
|
1406
|
+
'ingestBreakerTrips'
|
|
1407
|
+
)
|
|
1408
|
+
console.log(JSON.stringify({ event: 'sync_upstream_ingest_breaker_reopened' }))
|
|
1409
|
+
return json({ ok: true, ...this.#ingestBreaker.status() })
|
|
1410
|
+
}
|
|
1154
1411
|
if (route === '/admin/fault') {
|
|
1155
1412
|
return request
|
|
1156
1413
|
.json()
|
|
@@ -1202,7 +1459,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1202
1459
|
const applied = await this.#ingest(upstreamPath)
|
|
1203
1460
|
return json({ ok: true, applied })
|
|
1204
1461
|
} catch (error) {
|
|
1205
|
-
return json(
|
|
1462
|
+
return json(errorBody(error), statusOf(error))
|
|
1206
1463
|
}
|
|
1207
1464
|
}
|
|
1208
1465
|
|
|
@@ -1212,7 +1469,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1212
1469
|
try {
|
|
1213
1470
|
await this.#ingest(upstreamPath)
|
|
1214
1471
|
} catch (error) {
|
|
1215
|
-
|
|
1472
|
+
// Workerd requires a forwarded request body to be consumed even when
|
|
1473
|
+
// ingest fails before the pull handler parses it.
|
|
1474
|
+
await request.arrayBuffer()
|
|
1475
|
+
return json(errorBody(error), statusOf(error))
|
|
1216
1476
|
}
|
|
1217
1477
|
return this.#pull(request, claims, namespace)
|
|
1218
1478
|
}
|
|
@@ -1234,7 +1494,10 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
|
1234
1494
|
})
|
|
1235
1495
|
)
|
|
1236
1496
|
} finally {
|
|
1237
|
-
|
|
1497
|
+
const retryAfterMs = this.#ingestBreaker.status().retryAfterMs
|
|
1498
|
+
await this.ctx.storage.setAlarm(
|
|
1499
|
+
Date.now() + Math.max(upstreamIntervalMs, retryAfterMs)
|
|
1500
|
+
)
|
|
1238
1501
|
}
|
|
1239
1502
|
}
|
|
1240
1503
|
|
|
@@ -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}`,
|
|
@@ -60,6 +70,8 @@ async function upstreamFetch(request: Request, env: Env): Promise<Response> {
|
|
|
60
70
|
const [, namespace, ...rest] = url.pathname.split('/')
|
|
61
71
|
if (!namespace) return new Response('namespace required', { status: 400 })
|
|
62
72
|
const stub = env.UPSTREAM_DO.get(env.UPSTREAM_DO.idFromName(namespace))
|
|
73
|
+
url.pathname = `/${rest.join('/')}`
|
|
74
|
+
if (url.pathname === '/_orez/write-budget') return stub.fetch(new Request(url, request))
|
|
63
75
|
const exec = async (sql: string, params: unknown[] = []) => {
|
|
64
76
|
const response = await stub.fetch('https://upstream.invalid/exec', {
|
|
65
77
|
method: 'POST',
|
|
@@ -78,19 +90,46 @@ async function upstreamFetch(request: Request, env: Env): Promise<Response> {
|
|
|
78
90
|
'INSERT OR IGNORE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)',
|
|
79
91
|
['item', JSON.stringify(schema.tables.item)]
|
|
80
92
|
)
|
|
81
|
-
url.pathname = `/${rest.join('/')}`
|
|
82
93
|
return stub.fetch(new Request(url, request))
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
/** Self service-binding target backed by the real ZeroSqlDO. */
|
|
86
97
|
export class DataService extends WorkerEntrypoint<Env> {
|
|
87
98
|
fetch(request: Request): Promise<Response> {
|
|
88
|
-
const
|
|
89
|
-
|
|
99
|
+
const url = new URL(request.url)
|
|
100
|
+
const pathname = url.pathname
|
|
101
|
+
if (
|
|
102
|
+
!pathname.endsWith('/changes') &&
|
|
103
|
+
!pathname.endsWith('/snapshot') &&
|
|
104
|
+
!pathname.endsWith('/_orez/write-budget')
|
|
105
|
+
) {
|
|
90
106
|
return Promise.resolve(
|
|
91
107
|
new Response('DATA route rejected non-feed request', { status: 418 })
|
|
92
108
|
)
|
|
93
109
|
}
|
|
110
|
+
const namespace = pathname.split('/')[1] ?? ''
|
|
111
|
+
if (pathname.endsWith('/changes') && runawayNamespaces.has(namespace)) {
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
Response.json({
|
|
114
|
+
watermark: 100,
|
|
115
|
+
changes: [
|
|
116
|
+
{
|
|
117
|
+
watermark: 1,
|
|
118
|
+
tableName: 'item',
|
|
119
|
+
op: 'INSERT',
|
|
120
|
+
rowData: {
|
|
121
|
+
id: 'runaway-replay',
|
|
122
|
+
label: 'replayed without cursor progress',
|
|
123
|
+
rank: 1,
|
|
124
|
+
done: false,
|
|
125
|
+
meta: null,
|
|
126
|
+
},
|
|
127
|
+
oldData: null,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
)
|
|
132
|
+
}
|
|
94
133
|
return upstreamFetch(request, this.env)
|
|
95
134
|
}
|
|
96
135
|
}
|
|
@@ -102,6 +141,13 @@ export class AppService extends WorkerEntrypoint<Env> {
|
|
|
102
141
|
new Response('APP route rejected non-push request', { status: 418 })
|
|
103
142
|
)
|
|
104
143
|
}
|
|
144
|
+
delegatedAttempts++
|
|
145
|
+
if (delegatedFailuresRemaining > 0) {
|
|
146
|
+
delegatedFailuresRemaining--
|
|
147
|
+
return Promise.resolve(
|
|
148
|
+
Response.json({ error: 'synthetic delegated push failure' }, { status: 503 })
|
|
149
|
+
)
|
|
150
|
+
}
|
|
105
151
|
return upstreamFetch(request, this.env)
|
|
106
152
|
}
|
|
107
153
|
}
|
|
@@ -110,6 +156,40 @@ const syncWorker = createSyncWorker(config)
|
|
|
110
156
|
export default {
|
|
111
157
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
112
158
|
const url = new URL(request.url)
|
|
159
|
+
if (url.pathname.startsWith('/runaway-control/')) {
|
|
160
|
+
const namespace = url.pathname.slice('/runaway-control/'.length)
|
|
161
|
+
return request
|
|
162
|
+
.json()
|
|
163
|
+
.catch(() => ({}))
|
|
164
|
+
.then((body) => {
|
|
165
|
+
if ((body as { enabled?: unknown }).enabled === true)
|
|
166
|
+
runawayNamespaces.add(namespace)
|
|
167
|
+
else runawayNamespaces.delete(namespace)
|
|
168
|
+
return Response.json({
|
|
169
|
+
ok: true,
|
|
170
|
+
namespace,
|
|
171
|
+
enabled: runawayNamespaces.has(namespace),
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
if (url.pathname === '/delegation-control') {
|
|
176
|
+
if (request.method === 'GET') {
|
|
177
|
+
return Promise.resolve(
|
|
178
|
+
Response.json({ delegatedFailuresRemaining, delegatedAttempts })
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
return request
|
|
182
|
+
.json()
|
|
183
|
+
.catch(() => ({}))
|
|
184
|
+
.then((body) => {
|
|
185
|
+
delegatedFailuresRemaining = Math.max(
|
|
186
|
+
0,
|
|
187
|
+
Number((body as { failures?: unknown }).failures) || 0
|
|
188
|
+
)
|
|
189
|
+
delegatedAttempts = 0
|
|
190
|
+
return Response.json({ delegatedFailuresRemaining, delegatedAttempts })
|
|
191
|
+
})
|
|
192
|
+
}
|
|
113
193
|
if (url.pathname.startsWith('/upstream/')) {
|
|
114
194
|
url.pathname = url.pathname.slice('/upstream'.length)
|
|
115
195
|
return upstreamFetch(new Request(url, request), env)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { trackBillableCursorRows } from './write-safeguards.js'
|
|
2
|
+
|
|
1
3
|
import type { MutatorSql, SyncSql } from './types.js'
|
|
2
4
|
|
|
3
5
|
type WireValue =
|
|
@@ -86,7 +88,10 @@ function encodeResult(value: unknown): WireValue {
|
|
|
86
88
|
export class SqlStorageSyncDb implements JsSyncDb {
|
|
87
89
|
readonly stats: AdapterStats = { execCalls: 0, queryCalls: 0, sqlMs: 0 }
|
|
88
90
|
|
|
89
|
-
constructor(
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly sql: SqlStorage,
|
|
93
|
+
private readonly recordRowsWritten: (rows: number) => void = () => {}
|
|
94
|
+
) {}
|
|
90
95
|
|
|
91
96
|
resetStats(): void {
|
|
92
97
|
this.stats.execCalls = 0
|
|
@@ -97,7 +102,10 @@ export class SqlStorageSyncDb implements JsSyncDb {
|
|
|
97
102
|
exec(sql: string, params: WireValue[]): void {
|
|
98
103
|
assertHostSql(sql)
|
|
99
104
|
const start = performance.now()
|
|
100
|
-
|
|
105
|
+
trackBillableCursorRows(
|
|
106
|
+
this.sql.exec(sql, ...params.map(decodeBinding)),
|
|
107
|
+
this.recordRowsWritten
|
|
108
|
+
)
|
|
101
109
|
this.stats.execCalls++
|
|
102
110
|
this.stats.sqlMs += performance.now() - start
|
|
103
111
|
}
|
|
@@ -105,7 +113,10 @@ export class SqlStorageSyncDb implements JsSyncDb {
|
|
|
105
113
|
query(sql: string, params: WireValue[]): WireRow[] {
|
|
106
114
|
assertHostSql(sql)
|
|
107
115
|
const start = performance.now()
|
|
108
|
-
const cursor =
|
|
116
|
+
const cursor = trackBillableCursorRows(
|
|
117
|
+
this.sql.exec(sql, ...params.map(decodeBinding)),
|
|
118
|
+
this.recordRowsWritten
|
|
119
|
+
)
|
|
109
120
|
const columns = [...cursor.columnNames]
|
|
110
121
|
// A SqlStorage cursor must be fully consumed before any await. Returning a
|
|
111
122
|
// materialized array here makes that property structural.
|
|
@@ -121,11 +132,14 @@ export class SqlStorageSyncDb implements JsSyncDb {
|
|
|
121
132
|
|
|
122
133
|
/** Direct application SQL surface used for initialization and admin reads. */
|
|
123
134
|
export class SqlStorageDirect implements SyncSql {
|
|
124
|
-
constructor(
|
|
135
|
+
constructor(
|
|
136
|
+
private readonly sql: SqlStorage,
|
|
137
|
+
private readonly recordRowsWritten: (rows: number) => void = () => {}
|
|
138
|
+
) {}
|
|
125
139
|
|
|
126
140
|
exec(sql: string, params: readonly unknown[] = []): void {
|
|
127
141
|
assertHostSql(sql)
|
|
128
|
-
this.sql.exec(sql, ...params)
|
|
142
|
+
trackBillableCursorRows(this.sql.exec(sql, ...params), this.recordRowsWritten)
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
query<Row extends Record<string, unknown> = Record<string, unknown>>(
|
|
@@ -133,7 +147,10 @@ export class SqlStorageDirect implements SyncSql {
|
|
|
133
147
|
params: readonly unknown[] = []
|
|
134
148
|
): Row[] {
|
|
135
149
|
assertHostSql(sql)
|
|
136
|
-
return
|
|
150
|
+
return trackBillableCursorRows(
|
|
151
|
+
this.sql.exec(sql, ...params),
|
|
152
|
+
this.recordRowsWritten
|
|
153
|
+
).toArray() as Row[]
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
156
|
|
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
|
+
/** Billable SQLite rows written by ingest per rolling window. Defaults to 150,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,249 @@
|
|
|
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
|
+
billableRows: number
|
|
14
|
+
logicalRows: number
|
|
15
|
+
budget: number
|
|
16
|
+
windowMs: number
|
|
17
|
+
tripped: boolean
|
|
18
|
+
reason: IngestBreakerReason | null
|
|
19
|
+
retryAt: number | null
|
|
20
|
+
retryAfterMs: number
|
|
21
|
+
consecutiveTrips: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Sample = { at: number; billableRows: number; logicalRows: number }
|
|
25
|
+
|
|
26
|
+
export class IngestBreakerError extends Error {
|
|
27
|
+
readonly status = 429
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
readonly error: IngestBreakerReason,
|
|
31
|
+
readonly windowRows: number,
|
|
32
|
+
readonly budget: number,
|
|
33
|
+
readonly retryAfterMs: number
|
|
34
|
+
) {
|
|
35
|
+
super(`${error}: ${windowRows}/${budget} rows; retry after ${retryAfterMs}ms`)
|
|
36
|
+
this.name = 'IngestBreakerError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class IngestCircuitBreaker {
|
|
41
|
+
readonly #options: IngestBreakerOptions
|
|
42
|
+
#samples: Sample[] = []
|
|
43
|
+
#billableRows = 0
|
|
44
|
+
#logicalRows = 0
|
|
45
|
+
#reason: IngestBreakerReason | null = null
|
|
46
|
+
#retryAt: number | null = null
|
|
47
|
+
#consecutiveTrips = 0
|
|
48
|
+
|
|
49
|
+
constructor(options: IngestBreakerOptions) {
|
|
50
|
+
for (const [name, value] of Object.entries(options)) {
|
|
51
|
+
if (name === 'now') continue
|
|
52
|
+
if (!Number.isSafeInteger(value) || Number(value) < 1)
|
|
53
|
+
throw new TypeError(`${name} must be a positive safe integer`)
|
|
54
|
+
}
|
|
55
|
+
this.#options = options
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#prune(now: number): void {
|
|
59
|
+
const cutoff = now - this.#options.windowMs
|
|
60
|
+
let remove = 0
|
|
61
|
+
while (remove < this.#samples.length && this.#samples[remove]!.at <= cutoff) {
|
|
62
|
+
this.#billableRows -= this.#samples[remove]!.billableRows
|
|
63
|
+
this.#logicalRows -= this.#samples[remove]!.logicalRows
|
|
64
|
+
remove++
|
|
65
|
+
}
|
|
66
|
+
if (remove > 0) this.#samples.splice(0, remove)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
status(): IngestBreakerStatus {
|
|
70
|
+
const now = this.#options.now()
|
|
71
|
+
this.#prune(now)
|
|
72
|
+
const retryAfterMs = Math.max(0, (this.#retryAt ?? now) - now)
|
|
73
|
+
return {
|
|
74
|
+
windowRows: this.#billableRows,
|
|
75
|
+
billableRows: this.#billableRows,
|
|
76
|
+
logicalRows: this.#logicalRows,
|
|
77
|
+
budget: this.#options.budgetRows,
|
|
78
|
+
windowMs: this.#options.windowMs,
|
|
79
|
+
tripped: this.#reason !== null && retryAfterMs > 0,
|
|
80
|
+
reason: this.#reason,
|
|
81
|
+
retryAt: this.#retryAt,
|
|
82
|
+
retryAfterMs,
|
|
83
|
+
consecutiveTrips: this.#consecutiveTrips,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
assertReady(): void {
|
|
88
|
+
const status = this.status()
|
|
89
|
+
if (status.tripped && status.reason) {
|
|
90
|
+
throw new IngestBreakerError(
|
|
91
|
+
status.reason,
|
|
92
|
+
status.windowRows,
|
|
93
|
+
status.budget,
|
|
94
|
+
status.retryAfterMs
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
record(rows: number): void {
|
|
100
|
+
this.recordLogical(rows)
|
|
101
|
+
this.recordBillable(rows)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#sample(now: number): Sample {
|
|
105
|
+
this.#prune(now)
|
|
106
|
+
const last = this.#samples[this.#samples.length - 1]
|
|
107
|
+
if (last?.at === now) return last
|
|
108
|
+
const sample = { at: now, billableRows: 0, logicalRows: 0 }
|
|
109
|
+
this.#samples.push(sample)
|
|
110
|
+
return sample
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
recordLogical(rows: number): void {
|
|
114
|
+
if (!Number.isSafeInteger(rows) || rows <= 0) return
|
|
115
|
+
const sample = this.#sample(this.#options.now())
|
|
116
|
+
sample.logicalRows += rows
|
|
117
|
+
this.#logicalRows += rows
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
recordBillable(rows: number): void {
|
|
121
|
+
this.assertReady()
|
|
122
|
+
if (!Number.isSafeInteger(rows) || rows <= 0) return
|
|
123
|
+
const now = this.#options.now()
|
|
124
|
+
const sample = this.#sample(now)
|
|
125
|
+
sample.billableRows += rows
|
|
126
|
+
this.#billableRows += rows
|
|
127
|
+
if (this.#billableRows > this.#options.budgetRows) this.trip('ingestBudgetExceeded')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
trip(reason: IngestBreakerReason): never {
|
|
131
|
+
const now = this.#options.now()
|
|
132
|
+
this.#consecutiveTrips++
|
|
133
|
+
const delay = Math.min(
|
|
134
|
+
this.#options.maxBackoffMs,
|
|
135
|
+
this.#options.initialBackoffMs * 2 ** (this.#consecutiveTrips - 1)
|
|
136
|
+
)
|
|
137
|
+
this.#reason = reason
|
|
138
|
+
this.#retryAt = now + delay
|
|
139
|
+
throw new IngestBreakerError(
|
|
140
|
+
reason,
|
|
141
|
+
this.#billableRows,
|
|
142
|
+
this.#options.budgetRows,
|
|
143
|
+
delay
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
recovered(): void {
|
|
148
|
+
this.#reason = null
|
|
149
|
+
this.#retryAt = null
|
|
150
|
+
this.#consecutiveTrips = 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
restore(reason: IngestBreakerReason, retryAt: number, consecutiveTrips: number): void {
|
|
154
|
+
if (!Number.isFinite(retryAt) || retryAt <= 0) return
|
|
155
|
+
this.#reason = reason
|
|
156
|
+
this.#retryAt = retryAt
|
|
157
|
+
this.#consecutiveTrips = Math.max(1, Math.floor(consecutiveTrips) || 1)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
reopen(): void {
|
|
161
|
+
this.#samples = []
|
|
162
|
+
this.#billableRows = 0
|
|
163
|
+
this.#logicalRows = 0
|
|
164
|
+
this.recovered()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type BillableCursor = {
|
|
169
|
+
rowsWritten?: number
|
|
170
|
+
next?: (...args: unknown[]) => unknown
|
|
171
|
+
one?: (...args: unknown[]) => unknown
|
|
172
|
+
toArray?: (...args: unknown[]) => unknown
|
|
173
|
+
raw?: (...args: unknown[]) => unknown
|
|
174
|
+
[Symbol.iterator]?: () => Iterator<unknown>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function trackBillableCursorRows<Cursor extends BillableCursor>(
|
|
178
|
+
cursor: Cursor,
|
|
179
|
+
record: (rows: number) => void
|
|
180
|
+
): Cursor {
|
|
181
|
+
if (!cursor || typeof cursor !== 'object') return cursor
|
|
182
|
+
let accountedRows = 0
|
|
183
|
+
const account = () => {
|
|
184
|
+
const current = Number(cursor.rowsWritten ?? 0)
|
|
185
|
+
if (!Number.isSafeInteger(current) || current <= accountedRows) return
|
|
186
|
+
const delta = current - accountedRows
|
|
187
|
+
accountedRows = current
|
|
188
|
+
record(delta)
|
|
189
|
+
}
|
|
190
|
+
const wrapIterator = (iterator: object): object =>
|
|
191
|
+
new Proxy(iterator, {
|
|
192
|
+
get(target, property) {
|
|
193
|
+
const value = Reflect.get(target, property, target)
|
|
194
|
+
if (property === 'next' && typeof value === 'function') {
|
|
195
|
+
return (...args: unknown[]) => {
|
|
196
|
+
try {
|
|
197
|
+
return Reflect.apply(value, target, args)
|
|
198
|
+
} finally {
|
|
199
|
+
account()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
let proxy: Cursor
|
|
207
|
+
proxy = new Proxy(cursor, {
|
|
208
|
+
get(target, property) {
|
|
209
|
+
const value = Reflect.get(target, property, target)
|
|
210
|
+
if (property === Symbol.iterator) return () => proxy as unknown as Iterator<unknown>
|
|
211
|
+
if (
|
|
212
|
+
(property === 'next' || property === 'one' || property === 'toArray') &&
|
|
213
|
+
typeof value === 'function'
|
|
214
|
+
) {
|
|
215
|
+
return (...args: unknown[]) => {
|
|
216
|
+
try {
|
|
217
|
+
return Reflect.apply(value, target, args)
|
|
218
|
+
} finally {
|
|
219
|
+
account()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (property === 'raw' && typeof value === 'function') {
|
|
224
|
+
return (...args: unknown[]) =>
|
|
225
|
+
wrapIterator(Reflect.apply(value, target, args) as object)
|
|
226
|
+
}
|
|
227
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
account()
|
|
231
|
+
return proxy
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function retryDelayMs(
|
|
235
|
+
attempt: number,
|
|
236
|
+
initialBackoffMs: number,
|
|
237
|
+
maxBackoffMs: number
|
|
238
|
+
): number {
|
|
239
|
+
return Math.min(maxBackoffMs, initialBackoffMs * 2 ** Math.max(0, attempt - 1))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function shouldRetryDelegatedPush(
|
|
243
|
+
responseStatus: number | null,
|
|
244
|
+
attempt: number,
|
|
245
|
+
maxAttempts: number
|
|
246
|
+
): boolean {
|
|
247
|
+
if (attempt >= maxAttempts) return false
|
|
248
|
+
return responseStatus === null || responseStatus === 429 || responseStatus >= 500
|
|
249
|
+
}
|