@ixo/oracle-runtime-workers 0.9.0 → 0.11.0

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.
Files changed (59) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +38 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +249 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +1019 -169
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/owner-store/ixo-vfs-store.ts +12 -5
  39. package/src/owner-store/matrix-media-store.ts +12 -7
  40. package/src/owner-store/measure.ts +39 -0
  41. package/src/owner-store/migrating-store.ts +9 -3
  42. package/src/owner-store/types.ts +14 -4
  43. package/src/plugin-api/tool-helper.ts +9 -1
  44. package/src/plugin-api/types.ts +86 -0
  45. package/src/plugins/index.ts +3 -0
  46. package/src/plugins/matrix-group-chats/README.md +70 -0
  47. package/src/plugins/matrix-group-chats/index.ts +132 -0
  48. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  49. package/src/plugins/tasks/tasks-tools.ts +4 -0
  50. package/src/shell/app.ts +78 -0
  51. package/src/sqlite/cache-config.ts +27 -6
  52. package/src/sqlite/chunk-layout.ts +14 -0
  53. package/src/sqlite/database.ts +188 -19
  54. package/src/sqlite/do-vfs.ts +963 -99
  55. package/src/sqlite/index.ts +13 -0
  56. package/src/sqlite/page-tier.ts +668 -0
  57. package/src/sqlite/sqlite-saver.ts +81 -0
  58. package/src/tasks/scheduler.ts +134 -3
  59. package/src/tasks/store.ts +22 -0
@@ -12,8 +12,21 @@ export {
12
12
  DEFAULT_CACHE_PAGES,
13
13
  type DoVfsOptions,
14
14
  type DoVfsStats,
15
+ type TierFlushResult,
16
+ type TierStatus,
15
17
  type VfsSnapshot,
16
18
  } from './do-vfs';
19
+ export {
20
+ PageTier,
21
+ PageTierError,
22
+ DEFAULT_TIER_HOT_BUDGET_BYTES,
23
+ DEFAULT_TIER_PERIOD_MS,
24
+ DEFAULT_TIER_EVICT_AFTER_PERIODS,
25
+ SEGMENT_CHUNKS,
26
+ SEGMENT_BYTES,
27
+ type PageTierOptions,
28
+ type PageTierStats,
29
+ } from './page-tier';
17
30
  export {
18
31
  parseChunkCacheBytes,
19
32
  cachePagesForBytes,
@@ -0,0 +1,668 @@
1
+ /* eslint-disable no-console -- console is the logger on workerd; tier maintenance failures are logged, never thrown into SQLite */
2
+ /**
3
+ * The R2 page tier — cold chunks of a user's working copy live in R2, hot
4
+ * chunks stay in Durable Object SQLite (`vfs2_chunks`), and the file SQLite
5
+ * sees is the union of both.
6
+ *
7
+ * Why: DO SQLite storage is $0.20 per GB-month and capped at 10 GB per
8
+ * object; R2 is $0.015 per GB-month with no per-object cap. A daily user
9
+ * touches a few MB of a working copy that may be hundreds of MB, so most of
10
+ * the file is cold most of the time. The tier keeps the working set where
11
+ * SQLite can read it synchronously and moves the rest where it is cheap.
12
+ *
13
+ * Layout in R2 (one prefix per Durable Object):
14
+ * <prefix>/<file>/<segno>.<gen> — a SEGMENT: 16 consecutive 64 KiB
15
+ * chunks (1 MiB), chunk-aligned, sparse
16
+ * slots zero-filled. Immutable: a rewrite
17
+ * is a new key (gen + 1); the old key is
18
+ * deleted after the map points away.
19
+ *
20
+ * Layout in DO storage (the map — small, read once per file open):
21
+ * vfs2_tier_segments(file, segno, gen, mask) mask: 16 bits, one per
22
+ * chunk slot that is valid
23
+ * in the object
24
+ * vfs2_tier_access(file, period, bits) bitmap of chunks touched
25
+ * per period (a day)
26
+ * vfs2_chunks gains `mask`: a hot row may be PARTIAL (pages written over
27
+ * a cold base); pages outside its mask are read from the R2 slot.
28
+ *
29
+ * Rules the VFS enforces (see `do-vfs.ts`):
30
+ * - A hot row is the truth for the pages in its mask; the R2 slot is the
31
+ * truth for the rest; neither → zeros (sparse).
32
+ * - Reads of cold pages cannot block (the VFS is synchronous): a miss is
33
+ * recorded and the statement fails with `SQLITE_IOERR`; the database
34
+ * wrapper fetches the segment(s), fills the clean cache, and retries the
35
+ * statement (or the whole transaction). Writes never miss: a page written
36
+ * over a cold base becomes a partial hot row.
37
+ * - Eviction (`DoVfs.tierFlush`) only moves chunks that no turn touched for
38
+ * `evictAfterPeriods` periods; a segment is rewritten from the hot rows
39
+ * plus its previous object, then the evicted rows are deleted in ONE
40
+ * storage transaction with the map update, so a crash leaves at worst an
41
+ * orphan object (swept later) and never a hole.
42
+ *
43
+ * Costs the design is built around (R2 list prices): Class A (put/list/
44
+ * delete) $4.50 per million, Class B (get) $0.36 per million. Hence whole
45
+ * 1 MiB segments (one op per 16 chunks), a daily eviction pass, no per-chunk
46
+ * objects, and no R2 call on the hot path of a turn.
47
+ */
48
+ import { CHUNK_SIZE, PAGES_PER_CHUNK } from './chunk-layout';
49
+
50
+ /** Chunks per R2 segment object (1 MiB). Also the snapshot window, so a cold window is one GET. */
51
+ export const SEGMENT_CHUNKS = 16;
52
+ export const SEGMENT_BYTES = SEGMENT_CHUNKS * CHUNK_SIZE;
53
+
54
+ /** All 16 pages of a chunk present. */
55
+ export const FULL_MASK = (1 << PAGES_PER_CHUNK) - 1;
56
+ /** All 16 slots of a segment valid. */
57
+ const FULL_SEGMENT_MASK = (1 << SEGMENT_CHUNKS) - 1;
58
+
59
+ /** Soft target for hot bytes per file; eviction logs when it cannot get under it. */
60
+ export const DEFAULT_TIER_HOT_BUDGET_BYTES = 16 * 1024 * 1024;
61
+ /** Access-tracking period: a chunk untouched for `evictAfterPeriods` of these is cold. */
62
+ export const DEFAULT_TIER_PERIOD_MS = 24 * 60 * 60_000;
63
+ export const DEFAULT_TIER_EVICT_AFTER_PERIODS = 2;
64
+ /** Access bitmaps kept per file (older periods are dropped on each eviction pass). */
65
+ export const TIER_ACCESS_HISTORY_PERIODS = 8;
66
+ /** Segments rewritten per eviction pass — bounds time, memory (2 MiB in flight) and R2 subrequests. */
67
+ export const DEFAULT_TIER_MAX_SEGMENTS_PER_PASS = 64;
68
+ /**
69
+ * Cold bytes one statement/transaction may pin in memory across its retries
70
+ * (see `DoVfs.resolveMisses`): a scan over more cold data than this fails
71
+ * with a clear error rather than thrashing the clean cache.
72
+ */
73
+ export const DEFAULT_TIER_MISS_PIN_BYTES = 32 * 1024 * 1024;
74
+ /** Orphan sweep (list + delete unreferenced keys) every this many maintenance runs. */
75
+ const ORPHAN_SWEEP_EVERY = 16;
76
+ /** R2 `delete()` accepts up to 1000 keys per call. */
77
+ const R2_DELETE_BATCH = 1000;
78
+
79
+ export interface PageTierOptions {
80
+ bucket: R2Bucket;
81
+ /** Key prefix owned by this object (the DO id string); everything under it is ours to delete. */
82
+ prefix: string;
83
+ hotBudgetBytes?: number;
84
+ periodMs?: number;
85
+ evictAfterPeriods?: number;
86
+ maxSegmentsPerPass?: number;
87
+ /** Cold bytes one statement/transaction may pin while it is retried (default 32 MiB). */
88
+ missPinBytes?: number;
89
+ /** Clock, injectable for tests. */
90
+ now?: () => number;
91
+ }
92
+
93
+ export interface ResolvedPageTierOptions {
94
+ bucket: R2Bucket;
95
+ prefix: string;
96
+ hotBudgetBytes: number;
97
+ periodMs: number;
98
+ evictAfterPeriods: number;
99
+ maxSegmentsPerPass: number;
100
+ missPinBytes: number;
101
+ now: () => number;
102
+ }
103
+
104
+ export function resolvePageTierOptions(
105
+ options: PageTierOptions,
106
+ ): ResolvedPageTierOptions {
107
+ return {
108
+ bucket: options.bucket,
109
+ prefix: options.prefix.replace(/\/+$/, ''),
110
+ hotBudgetBytes: options.hotBudgetBytes ?? DEFAULT_TIER_HOT_BUDGET_BYTES,
111
+ periodMs: options.periodMs ?? DEFAULT_TIER_PERIOD_MS,
112
+ evictAfterPeriods:
113
+ options.evictAfterPeriods ?? DEFAULT_TIER_EVICT_AFTER_PERIODS,
114
+ maxSegmentsPerPass:
115
+ options.maxSegmentsPerPass ?? DEFAULT_TIER_MAX_SEGMENTS_PER_PASS,
116
+ missPinBytes: options.missPinBytes ?? DEFAULT_TIER_MISS_PIN_BYTES,
117
+ now: options.now ?? (() => Date.now()),
118
+ };
119
+ }
120
+
121
+ export interface SegmentEntry {
122
+ gen: number;
123
+ /** Bit `s` set = slot `s` (chunk `segno * 16 + s`) is valid in the object. */
124
+ mask: number;
125
+ }
126
+
127
+ export interface PageTierStats {
128
+ /** Segment objects referenced by the map, across files. */
129
+ coldSegments: number;
130
+ /** Chunk slots valid in R2 (≈ cold bytes / 64 KiB). */
131
+ coldChunks: number;
132
+ r2Gets: number;
133
+ r2Puts: number;
134
+ r2Deletes: number;
135
+ r2Lists: number;
136
+ /** Cold reads that failed a statement and were resolved by a fetch. */
137
+ coldMisses: number;
138
+ missResolutions: number;
139
+ evictedChunks: number;
140
+ evictionPasses: number;
141
+ pendingDeletes: number;
142
+ }
143
+
144
+ interface SegmentRow extends Record<string, SqlStorageValue> {
145
+ segno: number;
146
+ gen: number;
147
+ mask: number;
148
+ }
149
+
150
+ interface AccessRow extends Record<string, SqlStorageValue> {
151
+ period: number;
152
+ bits: ArrayBuffer;
153
+ }
154
+
155
+ export function segmentOf(chunkno: number): number {
156
+ return Math.floor(chunkno / SEGMENT_CHUNKS);
157
+ }
158
+
159
+ export function slotOf(chunkno: number): number {
160
+ return chunkno - segmentOf(chunkno) * SEGMENT_CHUNKS;
161
+ }
162
+
163
+ /** Bitmap helpers (little-endian bit order within bytes). */
164
+ export function bitmapHas(bits: Uint8Array, index: number): boolean {
165
+ const byte = bits[index >> 3];
166
+ return byte !== undefined && (byte & (1 << (index & 7))) !== 0;
167
+ }
168
+
169
+ function bitmapSet(bits: Uint8Array, index: number): void {
170
+ const i = index >> 3;
171
+ if (i < bits.byteLength) bits[i] = (bits[i] ?? 0) | (1 << (index & 7));
172
+ }
173
+
174
+ /** OR `b` into a copy of `a` grown to fit both. */
175
+ function bitmapMerge(a: Uint8Array, b: Uint8Array): Uint8Array {
176
+ const out = new Uint8Array(Math.max(a.byteLength, b.byteLength));
177
+ out.set(a);
178
+ for (let i = 0; i < b.byteLength; i++) out[i] = (out[i] ?? 0) | (b[i] ?? 0);
179
+ return out;
180
+ }
181
+
182
+ export function bitmapFor(
183
+ chunks: Iterable<number>,
184
+ totalChunks: number,
185
+ ): Uint8Array {
186
+ const bits = new Uint8Array(Math.max(1, Math.ceil(totalChunks / 8)));
187
+ for (const chunkno of chunks) bitmapSet(bits, chunkno);
188
+ return bits;
189
+ }
190
+
191
+ /**
192
+ * The tier's persistent map plus its R2 client. One instance per `DoVfs`;
193
+ * all synchronous methods run against DO storage (inside the caller's
194
+ * `transactionSync` when they mutate), all `async` methods talk to R2.
195
+ */
196
+ export class PageTier {
197
+ private readonly segments = new Map<string, Map<number, SegmentEntry>>();
198
+ private schemaReady = false;
199
+ private readonly pendingDeletes = new Set<string>();
200
+ private maintenanceRuns = 0;
201
+ /** Tiny LRU of raw segment bytes for sequential readers (snapshot windows). */
202
+ private readonly segmentCache = new Map<string, Uint8Array>();
203
+ private static readonly SEGMENT_CACHE_ENTRIES = 2;
204
+ readonly stats: PageTierStats = {
205
+ coldSegments: 0,
206
+ coldChunks: 0,
207
+ r2Gets: 0,
208
+ r2Puts: 0,
209
+ r2Deletes: 0,
210
+ r2Lists: 0,
211
+ coldMisses: 0,
212
+ missResolutions: 0,
213
+ evictedChunks: 0,
214
+ evictionPasses: 0,
215
+ pendingDeletes: 0,
216
+ };
217
+
218
+ constructor(
219
+ private storage: DurableObjectStorage,
220
+ readonly options: ResolvedPageTierOptions,
221
+ ) {}
222
+
223
+ /** Re-bind after the object was re-instantiated (see `DoVfs.attach`). */
224
+ attach(storage: DurableObjectStorage): void {
225
+ this.storage = storage;
226
+ this.schemaReady = false;
227
+ this.segments.clear();
228
+ this.segmentCache.clear();
229
+ this.maxGen.clear();
230
+ }
231
+
232
+ ensureSchema(): void {
233
+ if (this.schemaReady) return;
234
+ this.storage.sql.exec(
235
+ 'CREATE TABLE IF NOT EXISTS vfs2_tier_segments (file TEXT NOT NULL, segno INTEGER NOT NULL, gen INTEGER NOT NULL, mask INTEGER NOT NULL, PRIMARY KEY (file, segno))',
236
+ );
237
+ this.storage.sql.exec(
238
+ 'CREATE TABLE IF NOT EXISTS vfs2_tier_access (file TEXT NOT NULL, period INTEGER NOT NULL, bits BLOB NOT NULL, PRIMARY KEY (file, period))',
239
+ );
240
+ // Generation counter per file: keys are never reused, even after a
241
+ // segment row was dropped (truncate/import) — a delete queued for an old
242
+ // key must never hit a fresh object.
243
+ this.storage.sql.exec(
244
+ 'CREATE TABLE IF NOT EXISTS vfs2_tier_meta (file TEXT PRIMARY KEY, max_gen INTEGER NOT NULL)',
245
+ );
246
+ this.schemaReady = true;
247
+ }
248
+
249
+ /** In-memory high-water mark of generations handed out per file (persisted by `setSegment`). */
250
+ private readonly maxGen = new Map<string, number>();
251
+
252
+ /** A generation number no object of `file` has ever carried. */
253
+ allocateGen(file: string): number {
254
+ this.ensureSchema();
255
+ let max = this.maxGen.get(file);
256
+ if (max === undefined) {
257
+ const meta = this.storage.sql
258
+ .exec<{
259
+ max_gen: number;
260
+ }>('SELECT max_gen FROM vfs2_tier_meta WHERE file = ?', file)
261
+ .toArray()[0];
262
+ max = meta?.max_gen ?? 0;
263
+ for (const entry of this.segmentsOf(file).values())
264
+ max = Math.max(max, entry.gen);
265
+ }
266
+ max += 1;
267
+ this.maxGen.set(file, max);
268
+ return max;
269
+ }
270
+
271
+ currentPeriod(now = this.options.now()): number {
272
+ return Math.floor(now / this.options.periodMs);
273
+ }
274
+
275
+ key(file: string, segno: number, gen: number): string {
276
+ return `${this.options.prefix}/${file}/${segno}.${gen}`;
277
+ }
278
+
279
+ private filePrefix(file: string): string {
280
+ return `${this.options.prefix}/${file}/`;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // The map (DO storage, synchronous)
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /** Segment entries of `file`, loaded from storage on first use. */
288
+ segmentsOf(file: string): Map<number, SegmentEntry> {
289
+ let map = this.segments.get(file);
290
+ if (map !== undefined) return map;
291
+ this.ensureSchema();
292
+ map = new Map();
293
+ const rows = this.storage.sql
294
+ .exec<SegmentRow>(
295
+ 'SELECT segno, gen, mask FROM vfs2_tier_segments WHERE file = ?',
296
+ file,
297
+ )
298
+ .toArray();
299
+ for (const row of rows)
300
+ map.set(row.segno, { gen: row.gen, mask: row.mask });
301
+ this.segments.set(file, map);
302
+ this.recount();
303
+ return map;
304
+ }
305
+
306
+ /** Whether chunk `chunkno` of `file` has a valid slot in R2. */
307
+ slotInR2(file: string, chunkno: number): boolean {
308
+ const entry = this.segmentsOf(file).get(segmentOf(chunkno));
309
+ return entry !== undefined && (entry.mask & (1 << slotOf(chunkno))) !== 0;
310
+ }
311
+
312
+ /** Upsert one segment entry — call inside the caller's storage transaction. */
313
+ setSegment(file: string, segno: number, entry: SegmentEntry): void {
314
+ this.ensureSchema();
315
+ this.storage.sql.exec(
316
+ 'INSERT INTO vfs2_tier_segments (file, segno, gen, mask) VALUES (?, ?, ?, ?) ON CONFLICT(file, segno) DO UPDATE SET gen = excluded.gen, mask = excluded.mask',
317
+ file,
318
+ segno,
319
+ entry.gen,
320
+ entry.mask,
321
+ );
322
+ const max = Math.max(this.maxGen.get(file) ?? 0, entry.gen);
323
+ this.maxGen.set(file, max);
324
+ this.storage.sql.exec(
325
+ 'INSERT INTO vfs2_tier_meta (file, max_gen) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET max_gen = max(vfs2_tier_meta.max_gen, excluded.max_gen)',
326
+ file,
327
+ max,
328
+ );
329
+ this.segmentsOf(file).set(segno, { ...entry });
330
+ this.recount();
331
+ }
332
+
333
+ /**
334
+ * Forget every segment of `file` (its content is being replaced or the
335
+ * file deleted) — inside the caller's storage transaction. The objects are
336
+ * queued for deletion; run `maintenance()` afterwards.
337
+ */
338
+ dropFile(file: string): void {
339
+ this.ensureSchema();
340
+ const map = this.segmentsOf(file);
341
+ for (const [segno, entry] of map)
342
+ this.pendingDeletes.add(this.key(file, segno, entry.gen));
343
+ this.storage.sql.exec(
344
+ 'DELETE FROM vfs2_tier_segments WHERE file = ?',
345
+ file,
346
+ );
347
+ this.storage.sql.exec('DELETE FROM vfs2_tier_access WHERE file = ?', file);
348
+ map.clear();
349
+ this.dropSegmentCache(file);
350
+ this.recount();
351
+ this.stats.pendingDeletes = this.pendingDeletes.size;
352
+ }
353
+
354
+ /**
355
+ * The file was truncated to `keepChunks` chunks: slots at or past it are
356
+ * no longer valid (a later grow must read zeros, never stale bytes). Whole
357
+ * segments past the boundary are dropped and their objects queued for
358
+ * deletion; the boundary segment keeps its object with a narrowed mask.
359
+ * Inside the caller's storage transaction.
360
+ */
361
+ truncate(file: string, keepChunks: number): void {
362
+ const map = this.segmentsOf(file);
363
+ if (map.size === 0) return;
364
+ this.ensureSchema();
365
+ const boundary = segmentOf(Math.max(0, keepChunks));
366
+ for (const [segno, entry] of Array.from(map.entries())) {
367
+ if (
368
+ segno > boundary ||
369
+ (segno === boundary && keepChunks % SEGMENT_CHUNKS === 0)
370
+ ) {
371
+ this.pendingDeletes.add(this.key(file, segno, entry.gen));
372
+ this.storage.sql.exec(
373
+ 'DELETE FROM vfs2_tier_segments WHERE file = ? AND segno = ?',
374
+ file,
375
+ segno,
376
+ );
377
+ map.delete(segno);
378
+ } else if (segno === boundary) {
379
+ const keepSlots = keepChunks - boundary * SEGMENT_CHUNKS; // 1..15
380
+ const mask = entry.mask & ((1 << keepSlots) - 1);
381
+ if (mask !== entry.mask) {
382
+ this.storage.sql.exec(
383
+ 'UPDATE vfs2_tier_segments SET mask = ? WHERE file = ? AND segno = ?',
384
+ mask,
385
+ file,
386
+ segno,
387
+ );
388
+ entry.mask = mask;
389
+ }
390
+ }
391
+ }
392
+ this.dropSegmentCache(file);
393
+ this.recount();
394
+ this.stats.pendingDeletes = this.pendingDeletes.size;
395
+ }
396
+
397
+ private recount(): void {
398
+ let segments = 0;
399
+ let chunks = 0;
400
+ for (const map of this.segments.values()) {
401
+ for (const entry of map.values()) {
402
+ if (entry.mask === 0) continue;
403
+ segments++;
404
+ chunks += popcount16(entry.mask);
405
+ }
406
+ }
407
+ this.stats.coldSegments = segments;
408
+ this.stats.coldChunks = chunks;
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Access tracking (which chunks a turn touched, per period)
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /** OR `touched` into the bitmap of `period` for `file` (one row write). */
416
+ recordAccess(
417
+ file: string,
418
+ period: number,
419
+ touched: Iterable<number>,
420
+ totalChunks: number,
421
+ ): void {
422
+ this.ensureSchema();
423
+ const fresh = bitmapFor(touched, totalChunks);
424
+ const row = this.storage.sql
425
+ .exec<AccessRow>(
426
+ 'SELECT period, bits FROM vfs2_tier_access WHERE file = ? AND period = ?',
427
+ file,
428
+ period,
429
+ )
430
+ .toArray()[0];
431
+ const merged =
432
+ row === undefined ? fresh : bitmapMerge(new Uint8Array(row.bits), fresh);
433
+ this.storage.sql.exec(
434
+ 'INSERT INTO vfs2_tier_access (file, period, bits) VALUES (?, ?, ?) ON CONFLICT(file, period) DO UPDATE SET bits = excluded.bits',
435
+ file,
436
+ period,
437
+ toArrayBuffer(merged),
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Bitmap of chunks touched in the last `periods` periods (including the
443
+ * current one), plus the in-memory `touched` set of the current one.
444
+ * Rows older than `TIER_ACCESS_HISTORY_PERIODS` are dropped.
445
+ */
446
+ recentBitmap(
447
+ file: string,
448
+ periods: number,
449
+ touched: Iterable<number>,
450
+ totalChunks: number,
451
+ now = this.options.now(),
452
+ ): Uint8Array {
453
+ this.ensureSchema();
454
+ const current = this.currentPeriod(now);
455
+ const oldest = current - (periods - 1);
456
+ const rows = this.storage.sql
457
+ .exec<AccessRow>(
458
+ 'SELECT period, bits FROM vfs2_tier_access WHERE file = ? AND period >= ?',
459
+ file,
460
+ oldest,
461
+ )
462
+ .toArray();
463
+ let bits = bitmapFor(touched, totalChunks);
464
+ for (const row of rows) bits = bitmapMerge(bits, new Uint8Array(row.bits));
465
+ this.storage.sql.exec(
466
+ 'DELETE FROM vfs2_tier_access WHERE file = ? AND period < ?',
467
+ file,
468
+ current - (TIER_ACCESS_HISTORY_PERIODS - 1),
469
+ );
470
+ return bits;
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // R2 (async)
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /** The segment object bytes (exactly SEGMENT_BYTES, zero-padded), or null when the map has no entry. */
478
+ async getSegment(file: string, segno: number): Promise<Uint8Array | null> {
479
+ const entry = this.segmentsOf(file).get(segno);
480
+ if (entry === undefined) return null;
481
+ return this.getSegmentAt(file, segno, entry);
482
+ }
483
+
484
+ /**
485
+ * The bytes of a specific generation of a segment — what a snapshot took
486
+ * at open time even if the map has moved on since (the old object is
487
+ * only deleted once no snapshot is open).
488
+ */
489
+ async getSegmentAt(
490
+ file: string,
491
+ segno: number,
492
+ entry: SegmentEntry,
493
+ ): Promise<Uint8Array | null> {
494
+ if (entry.mask === 0) return null;
495
+ const key = this.key(file, segno, entry.gen);
496
+ const cached = this.segmentCache.get(key);
497
+ if (cached !== undefined) {
498
+ this.segmentCache.delete(key);
499
+ this.segmentCache.set(key, cached);
500
+ return cached;
501
+ }
502
+ const object = await this.options.bucket.get(key);
503
+ this.stats.r2Gets++;
504
+ if (object === null) {
505
+ throw new PageTierError(
506
+ `tier segment ${key} is referenced by the map but missing in R2`,
507
+ );
508
+ }
509
+ const raw = new Uint8Array(await object.arrayBuffer());
510
+ const bytes =
511
+ raw.byteLength === SEGMENT_BYTES ? raw : padTo(raw, SEGMENT_BYTES);
512
+ this.segmentCache.set(key, bytes);
513
+ while (this.segmentCache.size > PageTier.SEGMENT_CACHE_ENTRIES) {
514
+ const oldest = this.segmentCache.keys().next();
515
+ if (oldest.done) break;
516
+ this.segmentCache.delete(oldest.value);
517
+ }
518
+ return bytes;
519
+ }
520
+
521
+ /** Write a new generation of `segno`; returns the key written. Does NOT touch the map. */
522
+ async putSegment(
523
+ file: string,
524
+ segno: number,
525
+ gen: number,
526
+ bytes: Uint8Array,
527
+ ): Promise<string> {
528
+ const key = this.key(file, segno, gen);
529
+ await this.options.bucket.put(key, toArrayBuffer(bytes));
530
+ this.stats.r2Puts++;
531
+ return key;
532
+ }
533
+
534
+ queueDelete(key: string): void {
535
+ this.pendingDeletes.add(key);
536
+ this.stats.pendingDeletes = this.pendingDeletes.size;
537
+ }
538
+
539
+ dropSegmentCache(file: string): void {
540
+ const prefix = this.filePrefix(file);
541
+ for (const key of Array.from(this.segmentCache.keys()))
542
+ if (key.startsWith(prefix)) this.segmentCache.delete(key);
543
+ }
544
+
545
+ /**
546
+ * Delete queued keys (objects the map no longer references) and, every
547
+ * `ORPHAN_SWEEP_EVERY` runs or when `sweep` is set, list the prefix and
548
+ * delete anything the map does not reference — the leftovers of a crash
549
+ * between a put and its map update. Never throws.
550
+ */
551
+ async maintenance(
552
+ opts: { sweep?: boolean } = {},
553
+ ): Promise<{ deleted: number }> {
554
+ let deleted = 0;
555
+ const keys = Array.from(this.pendingDeletes);
556
+ this.pendingDeletes.clear();
557
+ try {
558
+ deleted += await this.deleteKeys(keys);
559
+ } catch (err) {
560
+ for (const key of keys) this.pendingDeletes.add(key);
561
+ console.warn(
562
+ `[page-tier] delete of ${keys.length} object(s) failed: ${errorMessage(err)}`,
563
+ );
564
+ }
565
+ this.maintenanceRuns++;
566
+ if (
567
+ opts.sweep === true ||
568
+ this.maintenanceRuns % ORPHAN_SWEEP_EVERY === 0
569
+ ) {
570
+ try {
571
+ deleted += await this.sweepOrphans();
572
+ } catch (err) {
573
+ console.warn(`[page-tier] orphan sweep failed: ${errorMessage(err)}`);
574
+ }
575
+ }
576
+ this.stats.pendingDeletes = this.pendingDeletes.size;
577
+ return { deleted };
578
+ }
579
+
580
+ /** Every object under the prefix (keys). */
581
+ async listKeys(): Promise<string[]> {
582
+ const keys: string[] = [];
583
+ let cursor: string | undefined;
584
+ do {
585
+ const page = await this.options.bucket.list({
586
+ prefix: `${this.options.prefix}/`,
587
+ ...(cursor !== undefined && { cursor }),
588
+ });
589
+ this.stats.r2Lists++;
590
+ for (const object of page.objects) keys.push(object.key);
591
+ cursor = page.truncated ? page.cursor : undefined;
592
+ } while (cursor !== undefined);
593
+ return keys;
594
+ }
595
+
596
+ private async sweepOrphans(): Promise<number> {
597
+ const referenced = new Set<string>();
598
+ this.ensureSchema();
599
+ const rows = this.storage.sql
600
+ .exec<
601
+ SegmentRow & { file: string }
602
+ >('SELECT file, segno, gen, mask FROM vfs2_tier_segments')
603
+ .toArray();
604
+ for (const row of rows)
605
+ referenced.add(this.key(row.file, row.segno, row.gen));
606
+ const orphans = (await this.listKeys()).filter(
607
+ (key) => !referenced.has(key),
608
+ );
609
+ return this.deleteKeys(orphans);
610
+ }
611
+
612
+ /** Delete everything under the prefix (the object's working copy is being wiped). */
613
+ async deleteAll(): Promise<number> {
614
+ this.pendingDeletes.clear();
615
+ this.segmentCache.clear();
616
+ this.stats.pendingDeletes = 0;
617
+ return this.deleteKeys(await this.listKeys());
618
+ }
619
+
620
+ private async deleteKeys(keys: string[]): Promise<number> {
621
+ for (let i = 0; i < keys.length; i += R2_DELETE_BATCH) {
622
+ const batch = keys.slice(i, i + R2_DELETE_BATCH);
623
+ await this.options.bucket.delete(batch);
624
+ this.stats.r2Deletes++;
625
+ }
626
+ return keys.length;
627
+ }
628
+ }
629
+
630
+ export class PageTierError extends Error {
631
+ constructor(message: string) {
632
+ super(message);
633
+ this.name = 'PageTierError';
634
+ }
635
+ }
636
+
637
+ function popcount16(mask: number): number {
638
+ let n = mask & FULL_SEGMENT_MASK;
639
+ let count = 0;
640
+ while (n !== 0) {
641
+ n &= n - 1;
642
+ count++;
643
+ }
644
+ return count;
645
+ }
646
+
647
+ function padTo(bytes: Uint8Array, length: number): Uint8Array {
648
+ const out = new Uint8Array(length);
649
+ out.set(bytes.subarray(0, Math.min(bytes.byteLength, length)));
650
+ return out;
651
+ }
652
+
653
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
654
+ const { buffer } = bytes;
655
+ if (
656
+ bytes.byteOffset === 0 &&
657
+ bytes.byteLength === buffer.byteLength &&
658
+ buffer instanceof ArrayBuffer
659
+ )
660
+ return buffer;
661
+ const copy = new Uint8Array(bytes.byteLength);
662
+ copy.set(bytes);
663
+ return copy.buffer;
664
+ }
665
+
666
+ function errorMessage(err: unknown): string {
667
+ return err instanceof Error ? err.message : String(err);
668
+ }