@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 +1 -1
- package/src/do/contracts.ts +2 -0
- package/src/do/flush-schedule.ts +40 -0
- package/src/do/user-oracle-do.ts +73 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.11.
|
|
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": {
|
package/src/do/contracts.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -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
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
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,
|