orez-sync-cf-host 0.4.53 → 0.4.55

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.53",
3
+ "version": "0.4.55",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/src/config.ts CHANGED
@@ -46,7 +46,7 @@ export function validateSyncHostConfig<Env extends SyncHostEnv>(
46
46
  throw new TypeError('upstream.intervalMs must be a safe integer >= 1000')
47
47
  }
48
48
  for (const [name, value] of Object.entries({
49
- ingestBudgetRows: config.upstream.ingestBudgetRows ?? 250_000,
49
+ ingestBudgetRows: config.upstream.ingestBudgetRows ?? 150_000,
50
50
  ingestBudgetWindowMs: config.upstream.ingestBudgetWindowMs ?? 300_000,
51
51
  ingestBackoffMs: config.upstream.ingestBackoffMs ?? 1_000,
52
52
  ingestMaxBackoffMs: config.upstream.ingestMaxBackoffMs ?? 60_000,
package/src/host.ts CHANGED
@@ -286,7 +286,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
286
286
  const wakeCoalesceMs = config.wakeCoalesceMs ?? 25
287
287
  const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000
288
288
  const upstreamLimit = config.upstream?.changeLimit ?? 1_000
289
- const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 250_000
289
+ const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000
290
290
  const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000
291
291
  const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000
292
292
  const ingestMaxBackoffMs = config.upstream?.ingestMaxBackoffMs ?? 60_000
@@ -309,6 +309,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
309
309
  #wakeRecipients = new Set<WebSocket>()
310
310
  #wakePromise: Promise<void> | null = null
311
311
  #ingestPromise: Promise<number> | null = null
312
+ #recordingIngestBillable = false
312
313
  #ingestBreaker = new IngestCircuitBreaker({
313
314
  budgetRows: ingestBudgetRows,
314
315
  windowMs: ingestBudgetWindowMs,
@@ -319,8 +320,11 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
319
320
 
320
321
  constructor(ctx: DurableObjectState, env: Env) {
321
322
  super(ctx, env)
322
- this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql)
323
- 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)
324
328
  this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast) =>
325
329
  this.#wasm(() => engine_compile_query(config.schema, ast))
326
330
  )
@@ -499,6 +503,26 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
499
503
  }
500
504
  }
501
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
+
502
526
  #rememberUpstreamPath(request: Request): string | null {
503
527
  if (!config.upstream) return null
504
528
  const path = request.headers.get(UPSTREAM_PATH_HEADER)
@@ -530,21 +554,31 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
530
554
  }
531
555
  }
532
556
 
533
- #recordIngestRows(rows: number, fields: Record<string, unknown>): void {
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
534
563
  try {
535
- this.#ingestBreaker.record(rows)
564
+ return apply()
536
565
  } 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
- )
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
+ }
547
579
  throw error
580
+ } finally {
581
+ this.#recordingIngestBillable = false
548
582
  }
549
583
  }
550
584
 
@@ -574,17 +608,21 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
574
608
  throw new Error(`upstream snapshot returned ${snapshotResponse.status}`)
575
609
  }
576
610
  const snapshot = await snapshotResponse.json()
577
- const rebuilt = this.ctx.storage.transactionSync(() =>
578
- this.#wasm(() =>
579
- engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
580
- )
581
- ) 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
+ )
582
624
  total += rebuilt.applied
583
- this.#recordIngestRows(rebuilt.applied, {
584
- phase: 'snapshot',
585
- cursor,
586
- resultWatermark: rebuilt.watermark,
587
- })
625
+ this.#recordIngestLogicalRows(rebuilt.applied)
588
626
  const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
589
627
  if (String(nextCursor) === String(cursor)) {
590
628
  this.#tripIngest('ingestCursorStalled', {
@@ -603,15 +641,22 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
603
641
  if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
604
642
  throw new Error('invalid upstream changes response')
605
643
  }
606
- const result = this.ctx.storage.transactionSync(() =>
607
- 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
+ )
608
657
  ) as ApplyUpstreamResult
609
658
  total += result.applied
610
- this.#recordIngestRows(result.applied, {
611
- phase: 'changes',
612
- cursor,
613
- batchWatermark: batch.watermark,
614
- })
659
+ this.#recordIngestLogicalRows(result.applied)
615
660
  const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
616
661
  if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
617
662
  this.#tripIngest('ingestCursorStalled', {
@@ -1258,6 +1303,8 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1258
1303
 
1259
1304
  #admin(route: string, request: Request): Promise<Response> | Response {
1260
1305
  if (route === '/admin/health') return json({ ok: true })
1306
+ if (route === '/admin/upstream-write-budget' && request.method === 'GET')
1307
+ return this.#upstreamWriteBudgetStatus()
1261
1308
  if (route === '/admin/status') {
1262
1309
  const heap = (
1263
1310
  performance as Performance & {
@@ -70,6 +70,8 @@ async function upstreamFetch(request: Request, env: Env): Promise<Response> {
70
70
  const [, namespace, ...rest] = url.pathname.split('/')
71
71
  if (!namespace) return new Response('namespace required', { status: 400 })
72
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))
73
75
  const exec = async (sql: string, params: unknown[] = []) => {
74
76
  const response = await stub.fetch('https://upstream.invalid/exec', {
75
77
  method: 'POST',
@@ -88,7 +90,6 @@ async function upstreamFetch(request: Request, env: Env): Promise<Response> {
88
90
  'INSERT OR IGNORE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)',
89
91
  ['item', JSON.stringify(schema.tables.item)]
90
92
  )
91
- url.pathname = `/${rest.join('/')}`
92
93
  return stub.fetch(new Request(url, request))
93
94
  }
94
95
 
@@ -97,7 +98,11 @@ export class DataService extends WorkerEntrypoint<Env> {
97
98
  fetch(request: Request): Promise<Response> {
98
99
  const url = new URL(request.url)
99
100
  const pathname = url.pathname
100
- if (!pathname.endsWith('/changes') && !pathname.endsWith('/snapshot')) {
101
+ if (
102
+ !pathname.endsWith('/changes') &&
103
+ !pathname.endsWith('/snapshot') &&
104
+ !pathname.endsWith('/_orez/write-budget')
105
+ ) {
101
106
  return Promise.resolve(
102
107
  new Response('DATA route rejected non-feed request', { status: 418 })
103
108
  )
@@ -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,7 +116,7 @@ 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. */
119
+ /** Billable SQLite rows written by ingest per rolling window. Defaults to 150,000. */
120
120
  ingestBudgetRows?: number
121
121
  /** Rolling ingest budget window. Defaults to five minutes. */
122
122
  ingestBudgetWindowMs?: number
@@ -10,6 +10,8 @@ export type IngestBreakerOptions = {
10
10
 
11
11
  export type IngestBreakerStatus = {
12
12
  windowRows: number
13
+ billableRows: number
14
+ logicalRows: number
13
15
  budget: number
14
16
  windowMs: number
15
17
  tripped: boolean
@@ -19,7 +21,7 @@ export type IngestBreakerStatus = {
19
21
  consecutiveTrips: number
20
22
  }
21
23
 
22
- type Sample = { at: number; rows: number }
24
+ type Sample = { at: number; billableRows: number; logicalRows: number }
23
25
 
24
26
  export class IngestBreakerError extends Error {
25
27
  readonly status = 429
@@ -38,7 +40,8 @@ export class IngestBreakerError extends Error {
38
40
  export class IngestCircuitBreaker {
39
41
  readonly #options: IngestBreakerOptions
40
42
  #samples: Sample[] = []
41
- #windowRows = 0
43
+ #billableRows = 0
44
+ #logicalRows = 0
42
45
  #reason: IngestBreakerReason | null = null
43
46
  #retryAt: number | null = null
44
47
  #consecutiveTrips = 0
@@ -56,7 +59,8 @@ export class IngestCircuitBreaker {
56
59
  const cutoff = now - this.#options.windowMs
57
60
  let remove = 0
58
61
  while (remove < this.#samples.length && this.#samples[remove]!.at <= cutoff) {
59
- this.#windowRows -= this.#samples[remove]!.rows
62
+ this.#billableRows -= this.#samples[remove]!.billableRows
63
+ this.#logicalRows -= this.#samples[remove]!.logicalRows
60
64
  remove++
61
65
  }
62
66
  if (remove > 0) this.#samples.splice(0, remove)
@@ -67,7 +71,9 @@ export class IngestCircuitBreaker {
67
71
  this.#prune(now)
68
72
  const retryAfterMs = Math.max(0, (this.#retryAt ?? now) - now)
69
73
  return {
70
- windowRows: this.#windowRows,
74
+ windowRows: this.#billableRows,
75
+ billableRows: this.#billableRows,
76
+ logicalRows: this.#logicalRows,
71
77
  budget: this.#options.budgetRows,
72
78
  windowMs: this.#options.windowMs,
73
79
  tripped: this.#reason !== null && retryAfterMs > 0,
@@ -91,15 +97,34 @@ export class IngestCircuitBreaker {
91
97
  }
92
98
 
93
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 {
94
121
  this.assertReady()
95
122
  if (!Number.isSafeInteger(rows) || rows <= 0) return
96
123
  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')
124
+ const sample = this.#sample(now)
125
+ sample.billableRows += rows
126
+ this.#billableRows += rows
127
+ if (this.#billableRows > this.#options.budgetRows) this.trip('ingestBudgetExceeded')
103
128
  }
104
129
 
105
130
  trip(reason: IngestBreakerReason): never {
@@ -113,7 +138,7 @@ export class IngestCircuitBreaker {
113
138
  this.#retryAt = now + delay
114
139
  throw new IngestBreakerError(
115
140
  reason,
116
- this.#windowRows,
141
+ this.#billableRows,
117
142
  this.#options.budgetRows,
118
143
  delay
119
144
  )
@@ -134,11 +159,78 @@ export class IngestCircuitBreaker {
134
159
 
135
160
  reopen(): void {
136
161
  this.#samples = []
137
- this.#windowRows = 0
162
+ this.#billableRows = 0
163
+ this.#logicalRows = 0
138
164
  this.recovered()
139
165
  }
140
166
  }
141
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
+
142
234
  export function retryDelayMs(
143
235
  attempt: number,
144
236
  initialBackoffMs: number,