@ixo/oracle-runtime-workers 0.11.0 → 0.11.1

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": "@ixo/oracle-runtime-workers",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -439,6 +439,8 @@ export interface StorageStatus {
439
439
  pages: number;
440
440
  ownerEtag?: string;
441
441
  lastFlushAt?: number;
442
+ /** When the dirty copy is due to be uploaded (absent when clean). */
443
+ nextFlushAt?: number;
442
444
  dirty: boolean;
443
445
  flushInFlight: boolean;
444
446
  /** VFS write generation now vs. at the last successful upload. */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether a housekeeping wake should upload the working copy to the owner
3
+ * store. The object's single alarm serves several clients (realtime
4
+ * heartbeats, the durable-run keep-alive, task runs, compaction, the R2
5
+ * tier, idle eviction), so a wake says nothing about WHY it happened. The
6
+ * flush has its own persisted deadline (`meta:flushAt`, armed by the first
7
+ * write after an upload and replaced by the retry delay after a failure)
8
+ * and runs only when that deadline is due — every other wake leaves a
9
+ * dirty copy alone. Two exceptions upload right away: a copy about to be
10
+ * evicted (the wipe must never drop unsaved turns) and a copy marked dirty
11
+ * before the deadline existed (older objects; one upload, then the
12
+ * debounce applies).
13
+ */
14
+ export interface FlushScheduleInput {
15
+ /** The object holds writes the owner store has not seen (and a database is open). */
16
+ dirty: boolean;
17
+ /** The persisted flush deadline (undefined = none recorded). */
18
+ flushAt: unknown;
19
+ now: number;
20
+ /** The idle-eviction path is about to wipe the working copy. */
21
+ idle: boolean;
22
+ }
23
+
24
+ export type FlushScheduleDecision =
25
+ | { action: 'skip'; reason: 'clean' }
26
+ | { action: 'flush'; reason: 'due' | 'idle' | 'no-deadline' }
27
+ | { action: 'wait'; at: number };
28
+
29
+ /** Alarms fire a hair early; a deadline this close counts as due. */
30
+ export const FLUSH_DUE_SLACK_MS = 1000;
31
+
32
+ export function decideFlush(input: FlushScheduleInput): FlushScheduleDecision {
33
+ if (!input.dirty) return { action: 'skip', reason: 'clean' };
34
+ if (input.idle) return { action: 'flush', reason: 'idle' };
35
+ if (typeof input.flushAt !== 'number' || !Number.isFinite(input.flushAt))
36
+ return { action: 'flush', reason: 'no-deadline' };
37
+ if (input.flushAt <= input.now + FLUSH_DUE_SLACK_MS)
38
+ return { action: 'flush', reason: 'due' };
39
+ return { action: 'wait', at: input.flushAt };
40
+ }
@@ -156,6 +156,7 @@ import {
156
156
  import { turnRecursionLimit } from './turn-config';
157
157
  import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
158
158
  import { decideBootDirty } from './boot-dirty';
159
+ import { decideFlush } from './flush-schedule';
159
160
  import { mirrorTxnId, RoomMirror } from './room-mirror';
160
161
  import { prefixSpeaker } from './speaker-prefix';
161
162
  import {
@@ -320,6 +321,15 @@ const TIER_PASS_INTERVAL_MS = 6 * 60 * 60_000;
320
321
  * silently stale.
321
322
  */
322
323
  const META_DIRTY = 'meta:dirty';
324
+ /**
325
+ * When the dirty working copy is due to be uploaded. Armed by the first
326
+ * write after an upload (`FLUSH_DEBOUNCE_MS` later), replaced by the retry
327
+ * delay after a failed upload, cleared with the dirty mark. The alarm is
328
+ * shared with the realtime heartbeat, the durable-run keep-alive, task runs
329
+ * and compaction, so a wake alone never means "upload now" — the tick
330
+ * consults this deadline (`do/flush-schedule.ts`).
331
+ */
332
+ const META_FLUSH_AT = 'meta:flushAt';
323
333
  const META_OWNER_ETAG = 'meta:ownerEtag';
324
334
  const META_LAST_FLUSH = 'meta:lastFlushAt';
325
335
  const META_LAST_CHECKSUM = 'meta:lastChecksum';
@@ -1083,9 +1093,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1083
1093
  console.warn(
1084
1094
  `[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
1085
1095
  );
1086
- // Sooner than the 24 h debounce `markDirty` armed: the system of
1087
- // record is still empty for this user.
1088
- this.requestAlarm(Date.now() + FLUSH_RETRY_DELAY_MS);
1096
+ // The failed upload armed the 10-minute retry (`flushOnce`), well
1097
+ // before the 24 h debounce `markDirty` set: the system of record
1098
+ // is still empty for this user.
1089
1099
  }
1090
1100
  }
1091
1101
  await this.reconcileDirtyOnBoot(liveDb, userDid);
@@ -1278,6 +1288,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1278
1288
  META_OWNER_ETAG,
1279
1289
  META_LAST_CHECKSUM,
1280
1290
  META_LAST_FLUSH,
1291
+ META_FLUSH_AT,
1281
1292
  META_UPLOADED_GEN,
1282
1293
  META_FLUSH_FAILURES,
1283
1294
  META_LAST_VACUUM,
@@ -1383,7 +1394,30 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1383
1394
  private markDirty(): void {
1384
1395
  this.dirty = true;
1385
1396
  void this.ctx.storage.put(META_DIRTY, true);
1386
- this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
1397
+ void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
1398
+ }
1399
+
1400
+ /**
1401
+ * Record the flush deadline and arm the alarm for it. An earlier
1402
+ * deadline already on disk wins, so a burst of writes uploads once,
1403
+ * `FLUSH_DEBOUNCE_MS` after the first of them, never later.
1404
+ */
1405
+ private async armFlush(at: number): Promise<void> {
1406
+ const existing = await this.ctx.storage.get<number>(META_FLUSH_AT);
1407
+ const deadline =
1408
+ typeof existing === 'number' && existing <= at ? existing : at;
1409
+ if (deadline !== existing)
1410
+ await this.ctx.storage.put(META_FLUSH_AT, deadline);
1411
+ this.requestAlarm(deadline);
1412
+ }
1413
+
1414
+ /**
1415
+ * After a failed upload: the next attempt is `at`, replacing the deadline
1416
+ * that just came due (which would otherwise make every wake retry).
1417
+ */
1418
+ private async scheduleFlushRetry(at: number): Promise<void> {
1419
+ await this.ctx.storage.put(META_FLUSH_AT, at);
1420
+ this.requestAlarm(at);
1387
1421
  }
1388
1422
 
1389
1423
  /** Arm the object's single alarm no later than `at` (multiplexed). */
@@ -1481,15 +1515,35 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1481
1515
  deadlines.push(Math.max(nextRecovery, now + 1000));
1482
1516
  }
1483
1517
 
1484
- if (this.dirty && this.db) {
1518
+ const lastAccess =
1519
+ (await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
1520
+ const idle = now - lastAccess > IDLE_EVICT_MS;
1521
+
1522
+ // Owner-store flush: only when its own deadline is due, or the copy
1523
+ // is about to be evicted below. A wake for anything else (a run's
1524
+ // keep-alive, a heartbeat round that fell through, a compaction or
1525
+ // tier re-arm) leaves a dirty copy alone — the upload is a full-file
1526
+ // transfer and is meant to happen once a day, not once a turn.
1527
+ const flush = decideFlush({
1528
+ dirty: this.dirty && this.db !== null,
1529
+ flushAt: await this.ctx.storage.get<number>(META_FLUSH_AT),
1530
+ now,
1531
+ idle,
1532
+ });
1533
+ if (flush.action === 'flush') {
1485
1534
  try {
1486
1535
  await this.flushToOwnerStore();
1487
1536
  } catch (err) {
1488
1537
  console.error(
1489
1538
  `[user-do] flush failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1490
1539
  );
1540
+ // `flushOnce` arms the retry on an upload failure; anything that
1541
+ // failed before the upload gets the same retry window here.
1542
+ await this.scheduleFlushRetry(now + FLUSH_RETRY_DELAY_MS);
1491
1543
  deadlines.push(now + FLUSH_RETRY_DELAY_MS);
1492
1544
  }
1545
+ } else if (flush.action === 'wait') {
1546
+ deadlines.push(Math.max(flush.at, now + 1000));
1493
1547
  }
1494
1548
 
1495
1549
  if (this.taskScheduler) {
@@ -1522,9 +1576,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1522
1576
  }
1523
1577
  }
1524
1578
 
1525
- const lastAccess =
1526
- (await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
1527
-
1528
1579
  // Free-page reclaim on a quiet object (policy in sqlite/vacuum-policy.ts):
1529
1580
  // runs after the flush above so the upload holds the pre-rebuild file,
1530
1581
  // and marks dirty so the smaller file goes up on the next tick.
@@ -1551,7 +1602,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1551
1602
  // evict while tasks are scheduled: their runs ARE activity. The flush
1552
1603
  // above ran first; the wipe additionally verifies the upstream copy
1553
1604
  // matches the working copy, else the copy stays until it does.
1554
- const idle = now - lastAccess > IDLE_EVICT_MS;
1555
1605
  if (
1556
1606
  idle &&
1557
1607
  !this.dirty &&
@@ -2052,9 +2102,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2052
2102
  writeGeneration: db.writeGeneration,
2053
2103
  });
2054
2104
  if (decision === 'flagged') {
2055
- // The mark is on disk already (its flush alarm too); just adopt it.
2105
+ // The mark is on disk already (its deadline too); adopt it and make
2106
+ // sure the alarm is armed for that deadline.
2056
2107
  this.dirty = true;
2057
- this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
2108
+ void this.armFlush(Date.now() + FLUSH_DEBOUNCE_MS);
2058
2109
  } else if (decision === 'behind-upload') {
2059
2110
  console.log(
2060
2111
  `[user-do] working copy of ${userDid} is at generation ${db.writeGeneration}, last upload at ${String(got.get(META_UPLOADED_GEN))} — a turn ended without marking it; flushing`,
@@ -2431,6 +2482,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2431
2482
  const line = `[user-do] owner-store flush failed (${failures} in a row) for ${this.userDid} — working copy kept dirty, retrying in ${FLUSH_RETRY_DELAY_MS / 60_000} min: ${detail}`;
2432
2483
  if (failures >= FLUSH_FAILURES_ERROR_THRESHOLD) console.error(line);
2433
2484
  else console.warn(line);
2485
+ await this.scheduleFlushRetry(Date.now() + FLUSH_RETRY_DELAY_MS);
2434
2486
  throw err;
2435
2487
  }
2436
2488
  await this.ctx.storage.put(META_OWNER_ETAG, saved.etag);
@@ -2510,18 +2562,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2510
2562
  }
2511
2563
 
2512
2564
  private async clearDirty(): Promise<void> {
2513
- // A write that landed while the snapshot was being uploaded already
2514
- // re-armed the alarm through `markDirty`; only clear when the file's
2515
- // generation still matches what was just recorded.
2565
+ // A write that landed while the snapshot was being uploaded keeps the
2566
+ // copy dirty; its upload is a full debounce away (the deadline the
2567
+ // write found on disk was the one that just ran).
2516
2568
  const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
2517
2569
  if (
2518
2570
  this.db &&
2519
2571
  uploadedGen !== undefined &&
2520
2572
  uploadedGen !== this.db.writeGeneration
2521
- )
2573
+ ) {
2574
+ const at = Date.now() + FLUSH_DEBOUNCE_MS;
2575
+ await this.ctx.storage.put(META_FLUSH_AT, at);
2576
+ this.requestAlarm(at);
2522
2577
  return;
2578
+ }
2523
2579
  this.dirty = false;
2524
- await this.ctx.storage.delete(META_DIRTY);
2580
+ await this.ctx.storage.delete([META_DIRTY, META_FLUSH_AT]);
2525
2581
  }
2526
2582
 
2527
2583
  async resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }> {
@@ -2650,6 +2706,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2650
2706
  pages: Math.ceil(fileBytes / 4096),
2651
2707
  ownerEtag: await this.ctx.storage.get<string>(META_OWNER_ETAG),
2652
2708
  lastFlushAt: await this.ctx.storage.get<number>(META_LAST_FLUSH),
2709
+ nextFlushAt: await this.ctx.storage.get<number>(META_FLUSH_AT),
2653
2710
  dirty: this.dirty,
2654
2711
  flushInFlight: this.flushInFlight !== null,
2655
2712
  writeGeneration: db?.writeGeneration,