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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.4.52",
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
- throw new Error('upstream change cursor made no progress')
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.#serviceBinding(
804
- config.mutateBinding ?? config.upstream?.binding
805
- ).fetch(endpoint.toString(), {
806
- method: 'POST',
1003
+ const upstreamResponse = await this.#fetchDelegatedPush(
1004
+ endpoint,
807
1005
  headers,
808
- body: bytes,
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({ error: errorMessage(error) }, status)
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({ error: errorMessage(error) }, statusOf(error))
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
- return json({ error: errorMessage(error) }, statusOf(error))
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
- await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
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 pathname = new URL(request.url).pathname
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
+ }