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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.4.52",
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
- this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql)
290
- this.#directSql = new SqlStorageDirect(ctx.storage.sql)
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.ctx.storage.transactionSync(() =>
466
- this.#wasm(() =>
467
- engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
468
- )
469
- ) as ApplyUpstreamResult
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.ctx.storage.transactionSync(() =>
481
- this.#wasm(() => engine_apply_upstream(this.#engineDb, config.schema, batch))
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
- throw new Error('upstream change cursor made no progress')
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.#serviceBinding(
804
- config.mutateBinding ?? config.upstream?.binding
805
- ).fetch(endpoint.toString(), {
806
- method: 'POST',
1048
+ const upstreamResponse = await this.#fetchDelegatedPush(
1049
+ endpoint,
807
1050
  headers,
808
- body: bytes,
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({ error: errorMessage(error) }, status)
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({ error: errorMessage(error) }, statusOf(error))
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
- return json({ error: errorMessage(error) }, statusOf(error))
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
- await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
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 pathname = new URL(request.url).pathname
89
- if (!pathname.endsWith('/changes') && !pathname.endsWith('/snapshot')) {
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(private readonly sql: SqlStorage) {}
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
- this.sql.exec(sql, ...params.map(decodeBinding))
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 = this.sql.exec(sql, ...params.map(decodeBinding))
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(private readonly sql: SqlStorage) {}
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 this.sql.exec(sql, ...params).toArray() as Row[]
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
+ }