@ixo/oracle-runtime-workers 0.4.1 → 0.10.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 (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +2 -2
  3. package/src/attachments/download.ts +43 -22
  4. package/src/attachments/index.ts +1 -0
  5. package/src/core/env.ts +28 -0
  6. package/src/core/main-agent.ts +56 -4
  7. package/src/core/middlewares/dangling-tool-calls.ts +99 -0
  8. package/src/core/middlewares/index.ts +5 -0
  9. package/src/core/runtime-context.ts +14 -5
  10. package/src/core/subagent-as-tool.ts +12 -1
  11. package/src/do/ambient.ts +13 -4
  12. package/src/do/boot-dirty.ts +38 -0
  13. package/src/do/contracts.ts +113 -18
  14. package/src/do/matrix-turn-ledger.ts +126 -0
  15. package/src/do/reauth-prompt.ts +76 -0
  16. package/src/do/room-mirror.ts +111 -0
  17. package/src/do/sse-stream.ts +104 -2
  18. package/src/do/transcript.ts +15 -2
  19. package/src/do/turn-config.ts +24 -0
  20. package/src/do/user-oracle-do.ts +484 -91
  21. package/src/matrix/gateway-do.ts +233 -42
  22. package/src/matrix/inbox-store.ts +188 -0
  23. package/src/matrix/txn-id.ts +35 -0
  24. package/src/matrix/work-status.ts +15 -3
  25. package/src/owner-store/ixo-vfs-store.ts +12 -5
  26. package/src/owner-store/matrix-media-store.ts +65 -40
  27. package/src/owner-store/measure.ts +39 -0
  28. package/src/owner-store/migrating-store.ts +29 -34
  29. package/src/owner-store/types.ts +0 -0
  30. package/src/plugin-api/types.ts +25 -1
  31. package/src/plugins/portal/action-log.ts +36 -9
  32. package/src/plugins/tasks/tasks-tools.ts +7 -7
  33. package/src/shell/app.ts +92 -7
  34. package/src/shell/turn-body-cap.ts +19 -0
  35. package/src/sqlite/cache-config.ts +27 -6
  36. package/src/sqlite/chunk-layout.ts +14 -0
  37. package/src/sqlite/database.ts +188 -19
  38. package/src/sqlite/do-vfs.ts +963 -99
  39. package/src/sqlite/index.ts +13 -0
  40. package/src/sqlite/page-tier.ts +668 -0
  41. package/src/tasks/schedule-input.ts +102 -0
  42. package/src/tasks/scheduler.ts +351 -54
  43. package/src/tasks/store.ts +198 -3
@@ -63,6 +63,15 @@
63
63
  * INTO`) and need no crash atomicity, so their dirty chunks are flushed to
64
64
  * storage every `SPILL_CHUNKS` instead of being held until commit. That is
65
65
  * what keeps a full VACUUM of a multi-hundred-MB file at constant memory.
66
+ *
67
+ * R2 page tier (optional, `DoVfsOptions.tier` — see `page-tier.ts`): chunk
68
+ * rows are the HOT set; chunks nobody touched for a couple of periods are
69
+ * moved into 1 MiB R2 segment objects and their rows deleted. Rows may then
70
+ * be PARTIAL (`vfs2_chunks.mask` names the pages a write landed over a cold
71
+ * base). A read of a cold page records a miss and fails the statement with
72
+ * `SQLITE_IOERR_READ`; `DoSqliteDatabase` fetches the segment(s) into the
73
+ * clean cache and retries. Writes never miss. Without a tier every row is
74
+ * full and nothing below changes.
66
75
  */
67
76
  import { createHash } from 'node:crypto';
68
77
  import {
@@ -87,15 +96,22 @@ import {
87
96
  SQLITE_OPEN_DELETEONCLOSE,
88
97
  SQLITE_OPEN_MAIN_DB,
89
98
  } from 'wa-sqlite';
90
-
91
- /** Bytes per SQLite page. Matches `PRAGMA page_size=4096`. */
92
- export const VFS_PAGE_SIZE = 4096;
93
-
94
- /** SQLite pages packed into one stored row (billing grain). */
95
- export const PAGES_PER_CHUNK = 16;
96
-
97
- /** Bytes per stored chunk row (64 KiB — well under the 2 MB DO value cap). */
98
- export const CHUNK_SIZE = VFS_PAGE_SIZE * PAGES_PER_CHUNK;
99
+ import { CHUNK_SIZE, PAGES_PER_CHUNK, VFS_PAGE_SIZE } from './chunk-layout';
100
+ import {
101
+ bitmapHas,
102
+ FULL_MASK,
103
+ PageTier,
104
+ resolvePageTierOptions,
105
+ SEGMENT_BYTES,
106
+ SEGMENT_CHUNKS,
107
+ segmentOf,
108
+ slotOf,
109
+ type PageTierOptions,
110
+ type PageTierStats,
111
+ type SegmentEntry,
112
+ } from './page-tier';
113
+
114
+ export { CHUNK_SIZE, PAGES_PER_CHUNK, VFS_PAGE_SIZE } from './chunk-layout';
99
115
 
100
116
  /**
101
117
  * Default number of clean pages worth of chunk cache kept in memory (4 MiB).
@@ -133,6 +149,34 @@ export interface DoVfsOptions {
133
149
  * VFS is resized to the new budget (excess clean chunks are dropped).
134
150
  */
135
151
  cachePages?: number;
152
+ /**
153
+ * R2 page tier for the main database (see `page-tier.ts`). Fixed for the
154
+ * life of the VFS registration: the first `open()` of an object decides.
155
+ */
156
+ tier?: PageTierOptions;
157
+ }
158
+
159
+ /** What `tierFlush` did in one pass. */
160
+ export interface TierFlushResult {
161
+ /** Chunk rows moved to R2 and deleted from storage. */
162
+ evictedChunks: number;
163
+ segmentsRewritten: number;
164
+ /** Evictable chunks left for the next pass (the per-pass cap was hit). */
165
+ remaining: number;
166
+ /** Hot rows / bytes after the pass. */
167
+ hotRows: number;
168
+ hotBytes: number;
169
+ skipped?: 'no-tier' | 'snapshot-open' | 'write-transaction' | 'in-progress';
170
+ }
171
+
172
+ export interface TierStatus extends PageTierStats {
173
+ enabled: boolean;
174
+ hotRows: number;
175
+ hotBytes: number;
176
+ /** Cold chunks (R2 slots) of the file as a byte count. */
177
+ coldBytes: number;
178
+ /** Statement/transaction retries the database wrapper ran for cold misses. */
179
+ retries: number;
136
180
  }
137
181
 
138
182
  export interface DoVfsStats {
@@ -169,12 +213,30 @@ export interface VfsSnapshot {
169
213
  close(): void;
170
214
  }
171
215
 
216
+ /**
217
+ * A chunk's bytes plus the pages of it that are actually known: `mask` bit
218
+ * `p` set = page `p` of `data` is valid. `FULL_MASK` = a whole chunk. The
219
+ * pages outside the mask belong to the R2 slot (or are zeros when there is
220
+ * none) — see `page-tier.ts`.
221
+ */
222
+ interface MaskedChunk {
223
+ data: Uint8Array;
224
+ mask: number;
225
+ }
226
+
172
227
  interface SnapshotState {
173
228
  name: string;
174
229
  size: number;
175
230
  totalChunks: number;
176
- /** Chunk pre-images captured because a later flush overwrote/deleted them. */
177
- shadow: Map<number, Uint8Array>;
231
+ /**
232
+ * Pre-images of chunks a later flush overwrote/deleted. A partial image's
233
+ * remaining pages come from the R2 slot as mapped at snapshot time
234
+ * (`segments`), which stays readable: tier passes and object deletes wait
235
+ * while a snapshot is open.
236
+ */
237
+ shadow: Map<number, MaskedChunk>;
238
+ /** The tier map of the file as of snapshot time (empty without a tier). */
239
+ segments: Map<number, SegmentEntry>;
178
240
  open: boolean;
179
241
  }
180
242
 
@@ -188,6 +250,25 @@ interface PersistentFile {
188
250
  lock: number;
189
251
  /** Chunks written since the last flush, keyed by chunk number (full 64 KiB buffers). */
190
252
  dirty: Map<number, Uint8Array>;
253
+ /**
254
+ * Pages of each dirty chunk that hold real bytes (`FULL_MASK` when the
255
+ * chunk's base was known). Only ever partial with a tier, when a write
256
+ * landed on a cold chunk: the unknown pages are still in R2.
257
+ */
258
+ dirtyMask: Map<number, number>;
259
+ /**
260
+ * The clean state each dirty chunk was created from — the snapshot
261
+ * pre-image, captured without a storage read: the clean buffer (full or a
262
+ * partial row), `'sparse'` (never existed: zeros) or `'cold'` (only in
263
+ * R2). Cleared by `flush()`.
264
+ */
265
+ base: Map<number, MaskedChunk | 'sparse' | 'cold'>;
266
+ /** Chunks read or written since the last `recordAccess` (tier only). */
267
+ touched: Set<number>;
268
+ /** The access period `touched` belongs to; a touch in a later period persists the set first. */
269
+ touchedPeriod: number;
270
+ /** While a tier pass is in flight: chunks a flush wrote meanwhile (never evicted by that pass). */
271
+ tierWrittenSince: Set<number> | null;
191
272
  /** Lowest page count the file was truncated to since the last flush (chunks past it are deleted on flush). */
192
273
  truncateToPages: number | null;
193
274
  sizeDirty: boolean;
@@ -195,6 +276,21 @@ interface PersistentFile {
195
276
  spill: boolean;
196
277
  }
197
278
 
279
+ /**
280
+ * Thrown inside the synchronous read path when a page lives only in R2: the
281
+ * VFS records the chunk and answers SQLite with `SQLITE_IOERR_READ`; the
282
+ * database wrapper resolves the misses and retries.
283
+ */
284
+ class ColdChunkMiss extends Error {
285
+ constructor(
286
+ readonly file: string,
287
+ readonly chunkno: number,
288
+ ) {
289
+ super(`cold chunk ${chunkno} of '${file}'`);
290
+ this.name = 'ColdChunkMiss';
291
+ }
292
+ }
293
+
198
294
  interface MemoryFile {
199
295
  kind: 'memory';
200
296
  name: string;
@@ -208,6 +304,12 @@ type OpenFile = PersistentFile | MemoryFile;
208
304
  interface ChunkRow extends Record<string, SqlStorageValue> {
209
305
  chunkno: number;
210
306
  data: ArrayBuffer;
307
+ mask: number;
308
+ }
309
+
310
+ interface ChunkMaskRow extends Record<string, SqlStorageValue> {
311
+ chunkno: number;
312
+ mask: number;
211
313
  }
212
314
 
213
315
  interface FileMetaRow extends Record<string, SqlStorageValue> {
@@ -274,6 +376,10 @@ class ChunkCache {
274
376
  this.dropFrom(file, 0);
275
377
  }
276
378
 
379
+ dropChunk(file: string, chunkno: number): void {
380
+ this.map.delete(ChunkCache.key(file, chunkno));
381
+ }
382
+
277
383
  clear(): void {
278
384
  this.map.clear();
279
385
  }
@@ -325,6 +431,23 @@ export class DoVfs implements SQLiteVFS {
325
431
  /** Last error thrown by a flush that SQLite could not be told about (see file header). */
326
432
  lastFlushError: unknown = undefined;
327
433
 
434
+ /** The R2 page tier, when configured (see `page-tier.ts`). */
435
+ private tier: PageTier | null = null;
436
+ /** Cold chunks the synchronous read path could not serve, per file (see `resolveMisses`). */
437
+ private readonly misses = new Map<string, Set<number>>();
438
+ /**
439
+ * Chunks fetched for the statement/transaction being retried, per file.
440
+ * Kept OUTSIDE the LRU until `releasePins()`: a statement whose working
441
+ * set exceeds the clean cache would otherwise evict what its last resolve
442
+ * fetched and miss again on every retry. Bounded by `missPinBytes`.
443
+ */
444
+ private readonly pinned = new Map<string, Map<number, Uint8Array>>();
445
+ private pinnedBytes = 0;
446
+ /** Statement/transaction retries the wrapper ran for cold misses (reported in `tierStatus`). */
447
+ missRetries = 0;
448
+ /** A tier pass in flight (one at a time per VFS). */
449
+ private tierPassInFlight = false;
450
+
328
451
  constructor(
329
452
  readonly name: string,
330
453
  storage: DurableObjectStorage,
@@ -332,6 +455,23 @@ export class DoVfs implements SQLiteVFS {
332
455
  ) {
333
456
  this.storage = storage;
334
457
  this.cache = new ChunkCache(DoVfs.chunksForPages(options.cachePages));
458
+ if (options.tier !== undefined) {
459
+ this.tier = new PageTier(storage, resolvePageTierOptions(options.tier));
460
+ }
461
+ }
462
+
463
+ get tierEnabled(): boolean {
464
+ return this.tier !== null;
465
+ }
466
+
467
+ /**
468
+ * Attach a tier to a VFS created without one (an object whose first open
469
+ * predates the binding). A tier, once set, is never swapped or removed —
470
+ * its map lives in this object's storage.
471
+ */
472
+ configureTier(options: PageTierOptions | undefined): void {
473
+ if (options === undefined || this.tier !== null) return;
474
+ this.tier = new PageTier(this.storage, resolvePageTierOptions(options));
335
475
  }
336
476
 
337
477
  private static chunksForPages(cachePages: number | undefined): number {
@@ -366,7 +506,12 @@ export class DoVfs implements SQLiteVFS {
366
506
  this.persistentByName.clear();
367
507
  this.openFiles.clear();
368
508
  this.snapshots.clear();
509
+ this.misses.clear();
510
+ this.pinned.clear();
511
+ this.pinnedBytes = 0;
512
+ this.tierPassInFlight = false;
369
513
  this.lastFlushError = undefined;
514
+ this.tier?.attach(storage);
370
515
  }
371
516
 
372
517
  /** SQLite connection handles opened through this VFS (tracked by `DoSqliteDatabase`). */
@@ -390,6 +535,10 @@ export class DoVfs implements SQLiteVFS {
390
535
  this.connections.clear();
391
536
  for (const file of this.persistentByName.values()) {
392
537
  file.dirty.clear();
538
+ file.dirtyMask.clear();
539
+ file.base.clear();
540
+ file.touched.clear();
541
+ file.tierWrittenSince = null;
393
542
  file.truncateToPages = null;
394
543
  file.sizeDirty = false;
395
544
  file.flags &= ~SQLITE_OPEN_DELETEONCLOSE;
@@ -421,6 +570,22 @@ export class DoVfs implements SQLiteVFS {
421
570
  return code;
422
571
  }
423
572
 
573
+ /**
574
+ * A cold page: remember the chunk so `resolveMisses` can fetch it and
575
+ * answer SQLite with `code` — quietly (no log, no `lastFlushError`): the
576
+ * wrapper retries, it is not a failure.
577
+ */
578
+ private miss(miss: ColdChunkMiss, code: number): number {
579
+ let set = this.misses.get(miss.file);
580
+ if (set === undefined) {
581
+ set = new Set();
582
+ this.misses.set(miss.file, set);
583
+ }
584
+ set.add(miss.chunkno);
585
+ if (this.tier) this.tier.stats.coldMisses++;
586
+ return code;
587
+ }
588
+
424
589
  // ---------------------------------------------------------------------------
425
590
  // sqlite3_vfs methods
426
591
  // ---------------------------------------------------------------------------
@@ -455,6 +620,11 @@ export class DoVfs implements SQLiteVFS {
455
620
  gen: meta?.gen ?? 0,
456
621
  lock: SQLITE_LOCK_NONE,
457
622
  dirty: new Map(),
623
+ dirtyMask: new Map(),
624
+ base: new Map(),
625
+ touched: new Set(),
626
+ touchedPeriod: -1,
627
+ tierWrittenSince: null,
458
628
  truncateToPages: null,
459
629
  sizeDirty: false,
460
630
  spill: name.endsWith(VACUUM_FILE_SUFFIX),
@@ -519,6 +689,8 @@ export class DoVfs implements SQLiteVFS {
519
689
  if (file.kind === 'memory') return readMemory(file, pData, iOffset);
520
690
  return this.readPersistent(file, pData, iOffset);
521
691
  } catch (error) {
692
+ if (error instanceof ColdChunkMiss)
693
+ return this.miss(error, SQLITE_IOERR_READ);
522
694
  return this.fail('SQLITE_IOERR_READ', error, SQLITE_IOERR_READ);
523
695
  }
524
696
  }
@@ -531,6 +703,8 @@ export class DoVfs implements SQLiteVFS {
531
703
  this.writePersistent(file, pData, iOffset);
532
704
  return SQLITE_OK;
533
705
  } catch (error) {
706
+ if (error instanceof ColdChunkMiss)
707
+ return this.miss(error, SQLITE_IOERR_WRITE);
534
708
  return this.fail('SQLITE_IOERR_WRITE', error, SQLITE_IOERR_WRITE);
535
709
  }
536
710
  }
@@ -546,6 +720,8 @@ export class DoVfs implements SQLiteVFS {
546
720
  this.truncatePersistent(file, iSize);
547
721
  return SQLITE_OK;
548
722
  } catch (error) {
723
+ if (error instanceof ColdChunkMiss)
724
+ return this.miss(error, SQLITE_IOERR_TRUNCATE);
549
725
  return this.fail('SQLITE_IOERR_TRUNCATE', error, SQLITE_IOERR_TRUNCATE);
550
726
  }
551
727
  }
@@ -680,9 +856,10 @@ export class DoVfs implements SQLiteVFS {
680
856
  * Concatenate the stored pages of `name` in order: the result is the SQLite
681
857
  * file exactly as a filesystem VFS would hold it. Pending (unflushed) pages
682
858
  * are flushed first, so this must not be called from inside a SQLite
683
- * transaction on that file. Materializes the whole file in memory.
859
+ * transaction on that file. Materializes the whole file in memory; cold
860
+ * windows come from R2.
684
861
  */
685
- exportFile(name: string): Uint8Array {
862
+ async exportFile(name: string): Promise<Uint8Array> {
686
863
  const open = this.persistentByName.get(name);
687
864
  if (open !== undefined) {
688
865
  if (open.lock >= SQLITE_LOCK_EXCLUSIVE) {
@@ -698,26 +875,13 @@ export class DoVfs implements SQLiteVFS {
698
875
  throw new DoVfsError(`file '${name}' does not exist`);
699
876
  const out = new Uint8Array(size);
700
877
  const totalChunks = Math.ceil(size / CHUNK_SIZE);
701
- // Read in windows so a large file doesn't need a single giant result set
702
- // (64 chunks = 4 MiB per query).
703
- const window = 64;
704
- for (let start = 0; start < totalChunks; start += window) {
705
- const rows = this.storage.sql
706
- .exec<ChunkRow>(
707
- 'SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno < ? ORDER BY chunkno',
708
- name,
709
- start,
710
- start + window,
711
- )
712
- .toArray();
713
- this.stats.storageReads++;
714
- this.stats.rowsRead += rows.length;
715
- for (const row of rows) {
716
- const offset = row.chunkno * CHUNK_SIZE;
717
- const bytes = new Uint8Array(row.data);
718
- const n = Math.min(bytes.byteLength, size - offset);
719
- if (n > 0) out.set(bytes.subarray(0, n), offset);
720
- }
878
+ const segments = this.tier ? this.tier.segmentsOf(name) : new Map();
879
+ for (let start = 0; start < totalChunks; start += SNAPSHOT_WINDOW_CHUNKS) {
880
+ const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
881
+ out.set(
882
+ await this.readWindow(name, size, start, end, undefined, segments),
883
+ start * CHUNK_SIZE,
884
+ );
721
885
  }
722
886
  return out;
723
887
  }
@@ -742,8 +906,10 @@ export class DoVfs implements SQLiteVFS {
742
906
  const normalized = normalizeJournalModeHeader(bytes);
743
907
  this.ensureSchema();
744
908
  this.cache.dropFile(name);
909
+ this.dropPins(name);
745
910
  this.storage.transactionSync(() => {
746
911
  this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', name);
912
+ this.tier?.dropFile(name);
747
913
  this.storage.sql.exec(
748
914
  'INSERT INTO vfs2_files (file, size, gen) VALUES (?, ?, 1) ON CONFLICT(file) DO UPDATE SET size = excluded.size, gen = vfs2_files.gen + 1',
749
915
  name,
@@ -803,9 +969,12 @@ export class DoVfs implements SQLiteVFS {
803
969
  throw new DoVfsError(`file '${name}' does not exist`);
804
970
  const hash = createHash('sha256');
805
971
  const totalChunks = Math.ceil(size / CHUNK_SIZE);
972
+ const segments = this.tier ? this.tier.segmentsOf(name) : new Map();
806
973
  for (let start = 0; start < totalChunks; start += SNAPSHOT_WINDOW_CHUNKS) {
807
974
  const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
808
- hash.update(this.readWindow(name, size, start, end, undefined));
975
+ hash.update(
976
+ await this.readWindow(name, size, start, end, undefined, segments),
977
+ );
809
978
  }
810
979
  return hash.digest('hex');
811
980
  }
@@ -838,6 +1007,14 @@ export class DoVfs implements SQLiteVFS {
838
1007
  size: meta.size,
839
1008
  totalChunks: Math.ceil(meta.size / CHUNK_SIZE),
840
1009
  shadow: new Map(),
1010
+ segments: this.tier
1011
+ ? new Map(
1012
+ Array.from(this.tier.segmentsOf(name), ([segno, entry]) => [
1013
+ segno,
1014
+ { ...entry },
1015
+ ]),
1016
+ )
1017
+ : new Map(),
841
1018
  open: true,
842
1019
  };
843
1020
  this.snapshots.set(name, state);
@@ -859,7 +1036,7 @@ export class DoVfs implements SQLiteVFS {
859
1036
  let cursor = 0;
860
1037
  return new ReadableStream<Uint8Array>(
861
1038
  {
862
- pull: (controller) => {
1039
+ pull: async (controller) => {
863
1040
  if (!state.open) {
864
1041
  controller.error(
865
1042
  new DoVfsError(`snapshot of '${state.name}' was closed`),
@@ -875,7 +1052,14 @@ export class DoVfs implements SQLiteVFS {
875
1052
  cursor + SNAPSHOT_WINDOW_CHUNKS,
876
1053
  );
877
1054
  controller.enqueue(
878
- this.readWindow(state.name, state.size, cursor, end, state.shadow),
1055
+ await this.readWindow(
1056
+ state.name,
1057
+ state.size,
1058
+ cursor,
1059
+ end,
1060
+ state.shadow,
1061
+ state.segments,
1062
+ ),
879
1063
  );
880
1064
  cursor = end;
881
1065
  },
@@ -888,22 +1072,25 @@ export class DoVfs implements SQLiteVFS {
888
1072
 
889
1073
  /**
890
1074
  * Bytes of chunks `[start, end)` of `name` exactly as stored, capped at
891
- * `size`. Pre-images in `shadow` win over storage. Windows bypass the clean
892
- * cache so a whole-file read never evicts the working set.
1075
+ * `size`. Per chunk, page by page: a pre-image in `shadow` wins, then the
1076
+ * hot row, then the R2 slot as mapped in `segments`, else zeros. Windows
1077
+ * bypass the clean cache so a whole-file read never evicts the working
1078
+ * set; a window is one segment, so a cold window is one R2 GET.
893
1079
  */
894
- private readWindow(
1080
+ private async readWindow(
895
1081
  name: string,
896
1082
  size: number,
897
1083
  start: number,
898
1084
  end: number,
899
- shadow: Map<number, Uint8Array> | undefined,
900
- ): Uint8Array {
1085
+ shadow: Map<number, MaskedChunk> | undefined,
1086
+ segments: Map<number, SegmentEntry>,
1087
+ ): Promise<Uint8Array> {
901
1088
  const from = start * CHUNK_SIZE;
902
1089
  const to = Math.min(size, end * CHUNK_SIZE);
903
1090
  const out = new Uint8Array(Math.max(0, to - from));
904
1091
  const rows = this.storage.sql
905
1092
  .exec<ChunkRow>(
906
- 'SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno < ? ORDER BY chunkno',
1093
+ 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno < ? ORDER BY chunkno',
907
1094
  name,
908
1095
  start,
909
1096
  end,
@@ -911,20 +1098,48 @@ export class DoVfs implements SQLiteVFS {
911
1098
  .toArray();
912
1099
  this.stats.storageReads++;
913
1100
  this.stats.rowsRead += rows.length;
914
- const place = (chunkno: number, bytes: Uint8Array): void => {
915
- const offset = chunkno * CHUNK_SIZE - from;
916
- const n = Math.min(bytes.byteLength, out.byteLength - offset);
917
- if (n > 0) out.set(bytes.subarray(0, n), offset);
1101
+ const byChunk = new Map<number, MaskedChunk>();
1102
+ for (const row of rows)
1103
+ byChunk.set(row.chunkno, {
1104
+ data: new Uint8Array(row.data),
1105
+ mask: row.mask,
1106
+ });
1107
+ // Which slots still need R2 after shadow + rows? Fetch each segment once.
1108
+ const segmentBytes = new Map<number, Uint8Array | null>();
1109
+ const slotBytes = async (chunkno: number): Promise<Uint8Array | null> => {
1110
+ const segno = segmentOf(chunkno);
1111
+ const entry = segments.get(segno);
1112
+ if (entry === undefined || (entry.mask & (1 << slotOf(chunkno))) === 0)
1113
+ return null;
1114
+ if (!segmentBytes.has(segno)) {
1115
+ segmentBytes.set(
1116
+ segno,
1117
+ this.tier ? await this.tier.getSegmentAt(name, segno, entry) : null,
1118
+ );
1119
+ }
1120
+ const seg = segmentBytes.get(segno) ?? null;
1121
+ if (seg === null) return null;
1122
+ const at = slotOf(chunkno) * CHUNK_SIZE;
1123
+ return seg.subarray(at, at + CHUNK_SIZE);
918
1124
  };
919
- for (const row of rows) {
920
- if (shadow?.has(row.chunkno)) continue;
921
- place(row.chunkno, new Uint8Array(row.data));
922
- }
923
- if (shadow) {
924
- for (let chunkno = start; chunkno < end; chunkno++) {
925
- const pre = shadow.get(chunkno);
926
- if (pre !== undefined) place(chunkno, pre);
1125
+ for (let chunkno = start; chunkno < end; chunkno++) {
1126
+ const offset = chunkno * CHUNK_SIZE - from;
1127
+ const n = Math.min(CHUNK_SIZE, out.byteLength - offset);
1128
+ if (n <= 0) break;
1129
+ const pre = shadow?.get(chunkno);
1130
+ const image = pre ?? byChunk.get(chunkno);
1131
+ let mask = image?.mask ?? 0;
1132
+ if (image !== undefined) {
1133
+ placeMasked(out, offset, n, image.data, image.mask);
1134
+ }
1135
+ if (mask !== FULL_MASK) {
1136
+ const cold = await slotBytes(chunkno);
1137
+ if (cold !== null) {
1138
+ placeMasked(out, offset, n, cold, FULL_MASK & ~mask);
1139
+ mask = FULL_MASK;
1140
+ }
927
1141
  }
1142
+ // Remaining pages: sparse → already zero.
928
1143
  }
929
1144
  return out;
930
1145
  }
@@ -1001,8 +1216,10 @@ export class DoVfs implements SQLiteVFS {
1001
1216
  }
1002
1217
  flushRows();
1003
1218
  this.cache.dropFile(name);
1219
+ this.dropPins(name);
1004
1220
  this.storage.transactionSync(() => {
1005
1221
  this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', name);
1222
+ this.tier?.dropFile(name);
1006
1223
  this.storage.sql.exec(
1007
1224
  'UPDATE vfs2_chunks SET file = ? WHERE file = ?',
1008
1225
  name,
@@ -1045,8 +1262,11 @@ export class DoVfs implements SQLiteVFS {
1045
1262
  throw new DoVfsError(`file '${from}' does not exist`);
1046
1263
  this.cache.dropFile(from);
1047
1264
  this.cache.dropFile(to);
1265
+ this.dropPins(from);
1266
+ this.dropPins(to);
1048
1267
  this.storage.transactionSync(() => {
1049
1268
  this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', to);
1269
+ this.tier?.dropFile(to);
1050
1270
  this.storage.sql.exec(
1051
1271
  'UPDATE vfs2_chunks SET file = ? WHERE file = ?',
1052
1272
  to,
@@ -1104,6 +1324,17 @@ export class DoVfs implements SQLiteVFS {
1104
1324
  'ALTER TABLE vfs2_files ADD COLUMN gen INTEGER NOT NULL DEFAULT 0',
1105
1325
  );
1106
1326
  }
1327
+ // Page mask (added with the R2 tier): rows written before it are full.
1328
+ const chunkColumns = this.storage.sql
1329
+ .exec<{ name: string }>('PRAGMA table_info(vfs2_chunks)')
1330
+ .toArray()
1331
+ .map((row) => row.name);
1332
+ if (!chunkColumns.includes('mask')) {
1333
+ this.storage.sql.exec(
1334
+ `ALTER TABLE vfs2_chunks ADD COLUMN mask INTEGER NOT NULL DEFAULT ${FULL_MASK}`,
1335
+ );
1336
+ }
1337
+ this.tier?.ensureSchema();
1107
1338
  // v1 (one page per row) never shipped; drop dev-environment leftovers so
1108
1339
  // a stale un-chunked working copy can't shadow the re-import path.
1109
1340
  this.storage.sql.exec('DROP TABLE IF EXISTS vfs_pages');
@@ -1140,20 +1371,53 @@ export class DoVfs implements SQLiteVFS {
1140
1371
  }
1141
1372
 
1142
1373
  /**
1143
- * The chunk holding `chunkno`, from (in order) the dirty set, the clean
1144
- * cache, then storage. Returns a buffer that must be treated as read-only
1145
- * unless it came from `dirty` — writers copy before mutating.
1374
+ * Note a read or write of `chunkno` for the tier's eviction policy. The
1375
+ * set belongs to the period the touches happened in: crossing a period
1376
+ * boundary while loaded persists the old set under its own period first
1377
+ * (one row write), so a chunk last used yesterday ages correctly.
1146
1378
  */
1147
- private loadChunk(
1379
+ private touch(file: PersistentFile, chunkno: number): void {
1380
+ const tier = this.tier;
1381
+ if (tier === null) return;
1382
+ const period = tier.currentPeriod();
1383
+ if (file.touchedPeriod !== period) {
1384
+ if (file.touched.size > 0) this.persistTouches(file, tier);
1385
+ file.touchedPeriod = period;
1386
+ }
1387
+ file.touched.add(chunkno);
1388
+ }
1389
+
1390
+ private persistTouches(file: PersistentFile, tier: PageTier): void {
1391
+ if (file.touched.size === 0) return;
1392
+ tier.recordAccess(
1393
+ file.name,
1394
+ file.touchedPeriod,
1395
+ file.touched,
1396
+ Math.ceil(file.size / CHUNK_SIZE),
1397
+ );
1398
+ file.touched.clear();
1399
+ this.stats.storageWrites++;
1400
+ this.stats.rowsWritten++;
1401
+ }
1402
+
1403
+ /**
1404
+ * What storage holds for `chunkno` without touching R2: the clean cache
1405
+ * (always a full, effective chunk) or the hot row (full or partial).
1406
+ * `undefined` = no local bytes at all.
1407
+ */
1408
+ private lookupLocal(
1148
1409
  file: PersistentFile,
1149
1410
  chunkno: number,
1150
- ): Uint8Array | undefined {
1151
- const dirty = file.dirty.get(chunkno);
1152
- if (dirty !== undefined) return dirty;
1411
+ ): MaskedChunk | undefined {
1412
+ const pin = this.pinned.get(file.name)?.get(chunkno);
1413
+ if (pin !== undefined) {
1414
+ this.stats.cacheHits++;
1415
+ return { data: pin, mask: FULL_MASK };
1416
+ }
1153
1417
  const cached = this.cache.get(file.name, chunkno);
1154
1418
  if (cached !== undefined) {
1155
1419
  this.stats.cacheHits++;
1156
- return cached;
1420
+ return { data: cached, mask: FULL_MASK };
1157
1421
  }
1158
1422
  if (
1159
1423
  file.truncateToPages !== null &&
@@ -1163,7 +1427,7 @@ export class DoVfs implements SQLiteVFS {
1163
1427
  this.stats.cacheMisses++;
1164
1428
  const rows = this.storage.sql
1165
1429
  .exec<ChunkRow>(
1166
- 'SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno = ?',
1430
+ 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno = ?',
1167
1431
  file.name,
1168
1432
  chunkno,
1169
1433
  )
@@ -1178,17 +1442,78 @@ export class DoVfs implements SQLiteVFS {
1178
1442
  full.set(chunk.subarray(0, Math.min(chunk.byteLength, CHUNK_SIZE)));
1179
1443
  chunk = full;
1180
1444
  }
1181
- this.cache.set(file.name, chunkno, chunk);
1182
- return chunk;
1445
+ const mask = this.tier === null ? FULL_MASK : row.mask;
1446
+ if (mask === FULL_MASK) this.cache.set(file.name, chunkno, chunk);
1447
+ return { data: chunk, mask };
1183
1448
  }
1184
1449
 
1185
- /** A dirty (mutable) buffer for `chunkno`, copied from clean state if needed. */
1450
+ /**
1451
+ * The chunk holding `chunkno`, from (in order) the dirty set, the clean
1452
+ * cache, then storage. Returns a buffer that must be treated as read-only
1453
+ * unless it came from `dirty` — writers copy before mutating. Throws
1454
+ * `ColdChunkMiss` when pages of it live only in R2 (see the file header).
1455
+ */
1456
+ private loadChunk(
1457
+ file: PersistentFile,
1458
+ chunkno: number,
1459
+ ): Uint8Array | undefined {
1460
+ this.touch(file, chunkno);
1461
+ const dirty = file.dirty.get(chunkno);
1462
+ if (dirty !== undefined) {
1463
+ const mask = file.dirtyMask.get(chunkno) ?? FULL_MASK;
1464
+ if (mask === FULL_MASK) return dirty;
1465
+ // A partial dirty chunk: complete it from the fetched slot if a
1466
+ // resolve has pinned or cached it since.
1467
+ const cold =
1468
+ this.pinned.get(file.name)?.get(chunkno) ??
1469
+ this.cache.get(file.name, chunkno);
1470
+ if (cold !== undefined) {
1471
+ placeMasked(dirty, 0, CHUNK_SIZE, cold, FULL_MASK & ~mask);
1472
+ file.dirtyMask.set(chunkno, FULL_MASK);
1473
+ return dirty;
1474
+ }
1475
+ if (this.tier === null || !this.tier.slotInR2(file.name, chunkno)) {
1476
+ // No cold base: the unknown pages are zeros.
1477
+ file.dirtyMask.set(chunkno, FULL_MASK);
1478
+ return dirty;
1479
+ }
1480
+ throw new ColdChunkMiss(file.name, chunkno);
1481
+ }
1482
+ const local = this.lookupLocal(file, chunkno);
1483
+ if (local !== undefined && local.mask === FULL_MASK) return local.data;
1484
+ if (this.tier !== null && this.tier.slotInR2(file.name, chunkno))
1485
+ throw new ColdChunkMiss(file.name, chunkno);
1486
+ if (local === undefined) return undefined; // sparse
1487
+ // A partial row whose R2 slot is gone (truncated away): the rest is zeros.
1488
+ this.cache.set(file.name, chunkno, local.data);
1489
+ return local.data;
1490
+ }
1491
+
1492
+ /**
1493
+ * A dirty (mutable) buffer for `chunkno`, copied from clean state if
1494
+ * needed. Never misses: over a cold base the buffer starts as zeros with
1495
+ * an empty mask, and `writePersistent` marks the pages it fills — a
1496
+ * partial row when flushed. The clean state is remembered in `file.base`
1497
+ * as the snapshot pre-image.
1498
+ */
1186
1499
  private dirtyChunk(file: PersistentFile, chunkno: number): Uint8Array {
1187
1500
  let chunk = file.dirty.get(chunkno);
1188
1501
  if (chunk !== undefined) return chunk;
1189
- const clean = this.loadChunk(file, chunkno);
1190
- chunk = clean !== undefined ? clean.slice() : new Uint8Array(CHUNK_SIZE);
1502
+ this.touch(file, chunkno);
1503
+ const local = this.lookupLocal(file, chunkno);
1504
+ let mask: number;
1505
+ if (local !== undefined) {
1506
+ chunk = local.data.slice();
1507
+ mask = local.mask;
1508
+ file.base.set(chunkno, local);
1509
+ } else {
1510
+ chunk = new Uint8Array(CHUNK_SIZE);
1511
+ const cold = this.tier !== null && this.tier.slotInR2(file.name, chunkno);
1512
+ mask = cold ? 0 : FULL_MASK;
1513
+ file.base.set(chunkno, cold ? 'cold' : 'sparse');
1514
+ }
1191
1515
  file.dirty.set(chunkno, chunk);
1516
+ file.dirtyMask.set(chunkno, mask);
1192
1517
  return chunk;
1193
1518
  }
1194
1519
 
@@ -1236,6 +1561,24 @@ export class DoVfs implements SQLiteVFS {
1236
1561
  // copied implicitly by `set` (it aliases the wasm heap).
1237
1562
  const chunk = this.dirtyChunk(file, chunkno);
1238
1563
  chunk.set(pData.subarray(done, done + n), inChunk);
1564
+ const mask = file.dirtyMask.get(chunkno) ?? FULL_MASK;
1565
+ if (mask !== FULL_MASK) {
1566
+ // Over a cold base only whole pages can be claimed: SQLite writes
1567
+ // its main database in whole pages, so anything else is a bug.
1568
+ if (inChunk % VFS_PAGE_SIZE !== 0 || n % VFS_PAGE_SIZE !== 0) {
1569
+ throw new DoVfsError(
1570
+ `unaligned write (${n} bytes at ${pos}) over a cold chunk of '${file.name}'`,
1571
+ );
1572
+ }
1573
+ let updated = mask;
1574
+ for (
1575
+ let page = inChunk / VFS_PAGE_SIZE;
1576
+ page < (inChunk + n) / VFS_PAGE_SIZE;
1577
+ page++
1578
+ )
1579
+ updated |= 1 << page;
1580
+ file.dirtyMask.set(chunkno, updated);
1581
+ }
1239
1582
  done += n;
1240
1583
  }
1241
1584
  const end = iOffset + pData.byteLength;
@@ -1251,9 +1594,22 @@ export class DoVfs implements SQLiteVFS {
1251
1594
  const keepPages = Math.ceil(iSize / VFS_PAGE_SIZE);
1252
1595
  const keepChunks = Math.ceil(keepPages / PAGES_PER_CHUNK);
1253
1596
  for (const chunkno of Array.from(file.dirty.keys())) {
1254
- if (chunkno >= keepChunks) file.dirty.delete(chunkno);
1597
+ if (chunkno >= keepChunks) {
1598
+ file.dirty.delete(chunkno);
1599
+ file.dirtyMask.delete(chunkno);
1600
+ file.base.delete(chunkno);
1601
+ }
1255
1602
  }
1256
1603
  this.cache.dropFrom(file.name, keepChunks);
1604
+ const pins = this.pinned.get(file.name);
1605
+ if (pins !== undefined) {
1606
+ for (const chunkno of Array.from(pins.keys())) {
1607
+ if (chunkno >= keepChunks) {
1608
+ pins.delete(chunkno);
1609
+ this.pinnedBytes -= CHUNK_SIZE;
1610
+ }
1611
+ }
1612
+ }
1257
1613
  file.truncateToPages =
1258
1614
  file.truncateToPages === null
1259
1615
  ? keepPages
@@ -1261,16 +1617,25 @@ export class DoVfs implements SQLiteVFS {
1261
1617
  file.size = iSize;
1262
1618
  file.sizeDirty = true;
1263
1619
  // Zero the bytes past the new end inside the last surviving chunk, so a
1264
- // later grow-then-read never resurrects stale bytes.
1620
+ // later grow-then-read never resurrects stale bytes. Over a cold base
1621
+ // the zeroed pages are claimed in the mask (SQLite truncates on page
1622
+ // boundaries, so no page is half-known).
1265
1623
  if (keepChunks > 0) {
1266
1624
  const tail = iSize - (keepChunks - 1) * CHUNK_SIZE;
1267
1625
  if (tail < CHUNK_SIZE) {
1268
- const last = this.loadChunk(file, keepChunks - 1);
1269
- if (last !== undefined) {
1270
- const copy =
1271
- file.dirty.get(keepChunks - 1) === last ? last : last.slice();
1272
- copy.fill(0, tail);
1273
- file.dirty.set(keepChunks - 1, copy);
1626
+ const last = this.dirtyChunk(file, keepChunks - 1);
1627
+ last.fill(0, tail);
1628
+ const mask = file.dirtyMask.get(keepChunks - 1) ?? FULL_MASK;
1629
+ if (mask !== FULL_MASK) {
1630
+ if (tail % VFS_PAGE_SIZE !== 0) {
1631
+ throw new DoVfsError(
1632
+ `truncate of '${file.name}' to ${iSize} is not page-aligned over a cold chunk`,
1633
+ );
1634
+ }
1635
+ let updated = mask;
1636
+ for (let page = tail / VFS_PAGE_SIZE; page < PAGES_PER_CHUNK; page++)
1637
+ updated |= 1 << page;
1638
+ file.dirtyMask.set(keepChunks - 1, updated);
1274
1639
  }
1275
1640
  }
1276
1641
  }
@@ -1295,27 +1660,38 @@ export class DoVfs implements SQLiteVFS {
1295
1660
  const snapshot = this.snapshots.get(file.name);
1296
1661
  if (snapshot !== undefined) {
1297
1662
  // Pin the pre-image of everything this flush overwrites or deletes.
1298
- const touched = dirty.map(([chunkno]) => chunkno);
1663
+ this.shadowDirtyPreImages(
1664
+ file,
1665
+ snapshot,
1666
+ dirty.map(([chunkno]) => chunkno),
1667
+ );
1299
1668
  if (truncateTo !== null) {
1669
+ const deleted: number[] = [];
1300
1670
  for (
1301
1671
  let chunkno = DoVfs.chunkFloor(truncateTo);
1302
1672
  chunkno < snapshot.totalChunks;
1303
1673
  chunkno++
1304
1674
  )
1305
- touched.push(chunkno);
1675
+ deleted.push(chunkno);
1676
+ this.shadowStoredPreImages(file, snapshot, deleted);
1306
1677
  }
1307
- this.shadowPreImages(file, snapshot, touched);
1308
1678
  }
1679
+ const rows = dirty.map(
1680
+ ([chunkno, chunk]) =>
1681
+ [chunkno, chunk, file.dirtyMask.get(chunkno) ?? FULL_MASK] as const,
1682
+ );
1309
1683
  this.storage.transactionSync(() => {
1310
1684
  if (truncateTo !== null) {
1685
+ const keepChunks = DoVfs.chunkFloor(truncateTo);
1311
1686
  this.storage.sql.exec(
1312
1687
  'DELETE FROM vfs2_chunks WHERE file = ? AND chunkno >= ?',
1313
1688
  file.name,
1314
- DoVfs.chunkFloor(truncateTo),
1689
+ keepChunks,
1315
1690
  );
1691
+ this.tier?.truncate(file.name, keepChunks);
1316
1692
  }
1317
- for (let i = 0; i < dirty.length; i += FLUSH_BATCH_ROWS) {
1318
- this.upsertChunks(file.name, dirty.slice(i, i + FLUSH_BATCH_ROWS));
1693
+ for (let i = 0; i < rows.length; i += FLUSH_BATCH_ROWS) {
1694
+ this.upsertChunks(file.name, rows.slice(i, i + FLUSH_BATCH_ROWS));
1319
1695
  }
1320
1696
  this.storage.sql.exec(
1321
1697
  'UPDATE vfs2_files SET size = ?, gen = gen + 1 WHERE file = ?',
@@ -1327,20 +1703,63 @@ export class DoVfs implements SQLiteVFS {
1327
1703
  file.gen += 1;
1328
1704
  this.stats.storageWrites++;
1329
1705
  this.stats.flushes++;
1330
- for (const [chunkno, chunk] of dirty)
1331
- this.cache.set(file.name, chunkno, chunk);
1706
+ const pins = this.pinned.get(file.name);
1707
+ for (const [chunkno, chunk, mask] of rows) {
1708
+ if (mask === FULL_MASK) this.cache.set(file.name, chunkno, chunk);
1709
+ else this.cache.dropChunk(file.name, chunkno);
1710
+ // A chunk pinned for the running retry now has newer bytes in storage:
1711
+ // refresh the pin (or drop a partial one) so neither the retry nor
1712
+ // the release into the LRU can resurrect the pre-write content.
1713
+ if (pins?.has(chunkno)) {
1714
+ if (mask === FULL_MASK) pins.set(chunkno, chunk);
1715
+ else {
1716
+ pins.delete(chunkno);
1717
+ this.pinnedBytes -= CHUNK_SIZE;
1718
+ }
1719
+ }
1720
+ file.tierWrittenSince?.add(chunkno);
1721
+ }
1332
1722
  file.dirty.clear();
1723
+ file.dirtyMask.clear();
1724
+ file.base.clear();
1333
1725
  file.truncateToPages = null;
1334
1726
  file.sizeDirty = false;
1335
1727
  }
1336
1728
 
1337
1729
  /**
1338
- * Capture, for an open snapshot of `file`, the stored content of every
1339
- * chunk in `chunknos` that lies inside the snapshot and is not shadowed
1340
- * yet. The clean cache usually still holds the pre-image (a write copies
1341
- * the clean chunk through it); otherwise one storage read per chunk.
1730
+ * Pin, for an open snapshot of `file`, the pre-image of every dirty chunk
1731
+ * about to be flushed: the clean state `dirtyChunk` started from
1732
+ * (`file.base`) — no storage read. A cold base pins an empty image whose
1733
+ * pages the snapshot reader takes from the R2 slot it mapped at open time.
1734
+ */
1735
+ private shadowDirtyPreImages(
1736
+ file: PersistentFile,
1737
+ snapshot: SnapshotState,
1738
+ chunknos: number[],
1739
+ ): void {
1740
+ for (const chunkno of chunknos) {
1741
+ if (chunkno >= snapshot.totalChunks || snapshot.shadow.has(chunkno))
1742
+ continue;
1743
+ const base = file.base.get(chunkno);
1744
+ if (base === undefined || base === 'sparse') {
1745
+ snapshot.shadow.set(chunkno, {
1746
+ data: new Uint8Array(CHUNK_SIZE),
1747
+ mask: FULL_MASK,
1748
+ });
1749
+ } else if (base === 'cold') {
1750
+ snapshot.shadow.set(chunkno, { data: new Uint8Array(0), mask: 0 });
1751
+ } else {
1752
+ snapshot.shadow.set(chunkno, base);
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ /**
1758
+ * Pin the STORED content of chunks a truncation is about to delete (they
1759
+ * were never dirtied, so `file.base` knows nothing about them): the clean
1760
+ * cache when it still holds them, else one batched storage read.
1342
1761
  */
1343
- private shadowPreImages(
1762
+ private shadowStoredPreImages(
1344
1763
  file: PersistentFile,
1345
1764
  snapshot: SnapshotState,
1346
1765
  chunknos: number[],
@@ -1350,14 +1769,15 @@ export class DoVfs implements SQLiteVFS {
1350
1769
  if (chunkno >= snapshot.totalChunks || snapshot.shadow.has(chunkno))
1351
1770
  continue;
1352
1771
  const cached = this.cache.get(file.name, chunkno);
1353
- if (cached !== undefined) snapshot.shadow.set(chunkno, cached);
1772
+ if (cached !== undefined)
1773
+ snapshot.shadow.set(chunkno, { data: cached, mask: FULL_MASK });
1354
1774
  else missing.push(chunkno);
1355
1775
  }
1356
1776
  for (let i = 0; i < missing.length; i += SNAPSHOT_WINDOW_CHUNKS) {
1357
1777
  const batch = missing.slice(i, i + SNAPSHOT_WINDOW_CHUNKS);
1358
1778
  const rows = this.storage.sql
1359
1779
  .exec<ChunkRow>(
1360
- `SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno IN (${batch.map(() => '?').join(', ')})`,
1780
+ `SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno IN (${batch.map(() => '?').join(', ')})`,
1361
1781
  file.name,
1362
1782
  ...batch,
1363
1783
  )
@@ -1365,27 +1785,30 @@ export class DoVfs implements SQLiteVFS {
1365
1785
  this.stats.storageReads++;
1366
1786
  this.stats.rowsRead += rows.length;
1367
1787
  for (const row of rows)
1368
- snapshot.shadow.set(row.chunkno, new Uint8Array(row.data));
1369
- // A chunk with no stored row was sparse at snapshot time: zeros.
1788
+ snapshot.shadow.set(row.chunkno, {
1789
+ data: new Uint8Array(row.data),
1790
+ mask: this.tier === null ? FULL_MASK : row.mask,
1791
+ });
1792
+ // No stored row: cold (the reader fills from R2) or sparse (zeros).
1370
1793
  for (const chunkno of batch) {
1371
1794
  if (!snapshot.shadow.has(chunkno))
1372
- snapshot.shadow.set(chunkno, new Uint8Array(CHUNK_SIZE));
1795
+ snapshot.shadow.set(chunkno, { data: new Uint8Array(0), mask: 0 });
1373
1796
  }
1374
1797
  }
1375
1798
  }
1376
1799
 
1377
1800
  private upsertChunks(
1378
1801
  name: string,
1379
- rows: ReadonlyArray<readonly [number, Uint8Array]>,
1802
+ rows: ReadonlyArray<readonly [number, Uint8Array, number?]>,
1380
1803
  ): void {
1381
1804
  if (rows.length === 0) return;
1382
- const placeholders = rows.map(() => '(?, ?, ?)').join(', ');
1805
+ const placeholders = rows.map(() => '(?, ?, ?, ?)').join(', ');
1383
1806
  const bindings: Array<string | number | ArrayBuffer> = [];
1384
- for (const [chunkno, chunk] of rows) {
1385
- bindings.push(name, chunkno, toArrayBuffer(chunk));
1807
+ for (const [chunkno, chunk, mask] of rows) {
1808
+ bindings.push(name, chunkno, toArrayBuffer(chunk), mask ?? FULL_MASK);
1386
1809
  }
1387
1810
  this.storage.sql.exec(
1388
- `INSERT OR REPLACE INTO vfs2_chunks (file, chunkno, data) VALUES ${placeholders}`,
1811
+ `INSERT OR REPLACE INTO vfs2_chunks (file, chunkno, data, mask) VALUES ${placeholders}`,
1389
1812
  ...bindings,
1390
1813
  );
1391
1814
  this.stats.rowsWritten += rows.length;
@@ -1394,12 +1817,453 @@ export class DoVfs implements SQLiteVFS {
1394
1817
  private deletePersistent(name: string): void {
1395
1818
  this.assertNoSnapshot(name, 'delete');
1396
1819
  this.cache.dropFile(name);
1820
+ this.misses.delete(name);
1821
+ this.dropPins(name);
1397
1822
  this.storage.transactionSync(() => {
1398
1823
  this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', name);
1399
1824
  this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', name);
1825
+ this.tier?.dropFile(name);
1400
1826
  });
1401
1827
  this.stats.storageWrites++;
1402
1828
  }
1829
+
1830
+ // ---------------------------------------------------------------------------
1831
+ // R2 page tier (see page-tier.ts)
1832
+ // ---------------------------------------------------------------------------
1833
+
1834
+ /** Whether the synchronous read path recorded cold misses since the last resolve. */
1835
+ hasMisses(): boolean {
1836
+ for (const set of this.misses.values()) if (set.size > 0) return true;
1837
+ return false;
1838
+ }
1839
+
1840
+ /** Forget recorded misses (a transaction committed past a statement that swallowed one). */
1841
+ clearMisses(): void {
1842
+ this.misses.clear();
1843
+ }
1844
+
1845
+ /**
1846
+ * Fetch every segment the recorded misses fall in (plus one segment of
1847
+ * read-ahead for sequential scans), pin the effective content of each of
1848
+ * their chunks (hot row pages over the R2 slot) for the statement or
1849
+ * transaction being retried, and clear the misses. The caller re-runs
1850
+ * the failed statement/transaction and calls `releasePins()` once it is
1851
+ * done. Returns the number of segments fetched.
1852
+ */
1853
+ async resolveMisses(): Promise<number> {
1854
+ const tier = this.tier;
1855
+ if (tier === null) {
1856
+ this.misses.clear();
1857
+ return 0;
1858
+ }
1859
+ let fetched = 0;
1860
+ for (const [name, chunks] of Array.from(this.misses.entries())) {
1861
+ this.misses.delete(name);
1862
+ const file = this.persistentByName.get(name);
1863
+ const size = file?.size ?? this.readStoredMeta(name)?.size ?? 0;
1864
+ const totalChunks = Math.ceil(size / CHUNK_SIZE);
1865
+ const lastSegment = segmentOf(Math.max(0, totalChunks - 1));
1866
+ const segnos = new Set<number>();
1867
+ for (const chunkno of chunks) {
1868
+ const segno = segmentOf(chunkno);
1869
+ segnos.add(segno);
1870
+ if (segno + 1 <= lastSegment) segnos.add(segno + 1);
1871
+ }
1872
+ let pins = this.pinned.get(name);
1873
+ if (pins === undefined) {
1874
+ pins = new Map();
1875
+ this.pinned.set(name, pins);
1876
+ }
1877
+ for (const segno of segnos) {
1878
+ const first = segno * SEGMENT_CHUNKS;
1879
+ const last = Math.min(totalChunks, first + SEGMENT_CHUNKS) - 1;
1880
+ if (last < first) continue;
1881
+ // Read-ahead only when the segment is cold and not pinned already.
1882
+ let wanted = false;
1883
+ for (let chunkno = first; chunkno <= last && !wanted; chunkno++)
1884
+ wanted = !pins.has(chunkno) && tier.slotInR2(name, chunkno);
1885
+ if (!wanted) continue;
1886
+ const seg = await tier.getSegment(name, segno);
1887
+ fetched++;
1888
+ const rows = this.storage.sql
1889
+ .exec<ChunkRow>(
1890
+ 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno <= ?',
1891
+ name,
1892
+ first,
1893
+ last,
1894
+ )
1895
+ .toArray();
1896
+ this.stats.storageReads++;
1897
+ this.stats.rowsRead += rows.length;
1898
+ const byChunk = new Map<number, ChunkRow>();
1899
+ for (const row of rows) byChunk.set(row.chunkno, row);
1900
+ for (let chunkno = first; chunkno <= last; chunkno++) {
1901
+ if (pins.has(chunkno)) continue;
1902
+ const row = byChunk.get(chunkno);
1903
+ if (row !== undefined && row.mask === FULL_MASK) {
1904
+ // Hot and complete: the miss was for a neighbour; nothing to add.
1905
+ continue;
1906
+ }
1907
+ const inR2 = tier.slotInR2(name, chunkno);
1908
+ if (row === undefined && !inR2) continue; // sparse: reads return zeros
1909
+ const full = new Uint8Array(CHUNK_SIZE);
1910
+ if (inR2 && seg !== null) {
1911
+ const at = slotOf(chunkno) * CHUNK_SIZE;
1912
+ full.set(seg.subarray(at, at + CHUNK_SIZE));
1913
+ }
1914
+ if (row !== undefined)
1915
+ placeMasked(
1916
+ full,
1917
+ 0,
1918
+ CHUNK_SIZE,
1919
+ new Uint8Array(row.data),
1920
+ row.mask,
1921
+ );
1922
+ if (this.pinnedBytes + CHUNK_SIZE > tier.options.missPinBytes) {
1923
+ throw new DoVfsError(
1924
+ `cold-read working set of '${name}' exceeds the ${Math.round(tier.options.missPinBytes / 1024 / 1024)} MiB pin budget — the statement touches more cold data than one pass may hold in memory`,
1925
+ );
1926
+ }
1927
+ pins.set(chunkno, full);
1928
+ this.pinnedBytes += CHUNK_SIZE;
1929
+ }
1930
+ }
1931
+ }
1932
+ tier.stats.missResolutions++;
1933
+ return fetched;
1934
+ }
1935
+
1936
+ /**
1937
+ * The retried statement/transaction is done: hand the pinned chunks to
1938
+ * the LRU (the most recently used survive) and free the pin budget.
1939
+ */
1940
+ releasePins(): void {
1941
+ for (const [name, pins] of this.pinned) {
1942
+ for (const [chunkno, chunk] of pins) this.cache.set(name, chunkno, chunk);
1943
+ pins.clear();
1944
+ }
1945
+ this.pinned.clear();
1946
+ this.pinnedBytes = 0;
1947
+ }
1948
+
1949
+ private dropPins(name: string): void {
1950
+ const pins = this.pinned.get(name);
1951
+ if (pins === undefined) return;
1952
+ this.pinnedBytes -= pins.size * CHUNK_SIZE;
1953
+ this.pinned.delete(name);
1954
+ }
1955
+
1956
+ /** Persist which chunks were touched since the last call (one row write). No-op without a tier. */
1957
+ recordAccess(name: string): void {
1958
+ const tier = this.tier;
1959
+ const file = this.persistentByName.get(name);
1960
+ if (tier === null || file === undefined) return;
1961
+ this.persistTouches(file, tier);
1962
+ }
1963
+
1964
+ /** Hot/cold accounting of `name` plus the tier's counters. */
1965
+ tierStatus(name: string): TierStatus {
1966
+ const tier = this.tier;
1967
+ const base: TierStatus = {
1968
+ enabled: tier !== null,
1969
+ hotRows: 0,
1970
+ hotBytes: 0,
1971
+ coldBytes: 0,
1972
+ retries: this.missRetries,
1973
+ coldSegments: 0,
1974
+ coldChunks: 0,
1975
+ r2Gets: 0,
1976
+ r2Puts: 0,
1977
+ r2Deletes: 0,
1978
+ r2Lists: 0,
1979
+ coldMisses: 0,
1980
+ missResolutions: 0,
1981
+ evictedChunks: 0,
1982
+ evictionPasses: 0,
1983
+ pendingDeletes: 0,
1984
+ };
1985
+ if (tier === null) return base;
1986
+ this.ensureSchema();
1987
+ const row = this.storage.sql
1988
+ .exec<{
1989
+ n: number;
1990
+ }>('SELECT count(*) AS n FROM vfs2_chunks WHERE file = ?', name)
1991
+ .toArray()[0];
1992
+ const hotRows = row?.n ?? 0;
1993
+ // Make sure the file's map is loaded so the counters are current.
1994
+ tier.segmentsOf(name);
1995
+ return {
1996
+ ...base,
1997
+ ...tier.stats,
1998
+ hotRows,
1999
+ hotBytes: hotRows * CHUNK_SIZE,
2000
+ coldBytes: tier.stats.coldChunks * CHUNK_SIZE,
2001
+ };
2002
+ }
2003
+
2004
+ /**
2005
+ * One eviction pass over `name` (see page-tier.ts): rows nobody touched
2006
+ * for `evictAfterPeriods` periods are written into their R2 segments and
2007
+ * deleted. Must run outside a write transaction; skips while a snapshot is
2008
+ * open. `force` evicts every clean row regardless of recency (tests, ops).
2009
+ */
2010
+ async tierFlush(
2011
+ name: string,
2012
+ opts: { force?: boolean; maxSegments?: number } = {},
2013
+ ): Promise<TierFlushResult> {
2014
+ const tier = this.tier;
2015
+ const none = (skipped: TierFlushResult['skipped']): TierFlushResult => ({
2016
+ evictedChunks: 0,
2017
+ segmentsRewritten: 0,
2018
+ remaining: 0,
2019
+ hotRows: 0,
2020
+ hotBytes: 0,
2021
+ skipped,
2022
+ });
2023
+ if (tier === null) return none('no-tier');
2024
+ if (this.tierPassInFlight) return none('in-progress');
2025
+ if (this.snapshots.has(name)) return none('snapshot-open');
2026
+ const file = this.persistentByName.get(name);
2027
+ if (file !== undefined && file.lock >= SQLITE_LOCK_EXCLUSIVE)
2028
+ return none('write-transaction');
2029
+ this.ensureSchema();
2030
+ if (file !== undefined) this.flush(file);
2031
+ const size = file?.size ?? this.readStoredMeta(name)?.size;
2032
+ if (size === undefined) return none('no-tier');
2033
+ const totalChunks = Math.ceil(size / CHUNK_SIZE);
2034
+ if (file !== undefined) this.persistTouches(file, tier);
2035
+ const recent = tier.recentBitmap(
2036
+ name,
2037
+ tier.options.evictAfterPeriods,
2038
+ [],
2039
+ totalChunks,
2040
+ );
2041
+ const hot = this.storage.sql
2042
+ .exec<ChunkMaskRow>(
2043
+ 'SELECT chunkno, mask FROM vfs2_chunks WHERE file = ? ORDER BY chunkno',
2044
+ name,
2045
+ )
2046
+ .toArray();
2047
+ this.stats.storageReads++;
2048
+ this.stats.rowsRead += hot.length;
2049
+ const bySegment = new Map<number, number[]>();
2050
+ for (const row of hot) {
2051
+ if (row.chunkno >= totalChunks) continue;
2052
+ // Chunk 0 holds the file header: `sqlite3_open_v2` reads it before any
2053
+ // statement could retry a miss, so it stays hot forever (64 KiB).
2054
+ if (row.chunkno === 0) continue;
2055
+ if (!opts.force && bitmapHas(recent, row.chunkno)) continue;
2056
+ const segno = segmentOf(row.chunkno);
2057
+ const list = bySegment.get(segno);
2058
+ if (list) list.push(row.chunkno);
2059
+ else bySegment.set(segno, [row.chunkno]);
2060
+ }
2061
+ const plan = Array.from(bySegment.keys()).sort((a, b) => a - b);
2062
+ const cap = opts.maxSegments ?? tier.options.maxSegmentsPerPass;
2063
+ const todo = plan.slice(0, cap);
2064
+ let evicted = 0;
2065
+ this.tierPassInFlight = true;
2066
+ if (file !== undefined) file.tierWrittenSince = new Set();
2067
+ try {
2068
+ for (const segno of todo) {
2069
+ const candidates = bySegment.get(segno) ?? [];
2070
+ const entry = tier.segmentsOf(name).get(segno);
2071
+ const first = segno * SEGMENT_CHUNKS;
2072
+ const last = Math.min(totalChunks, first + SEGMENT_CHUNKS) - 1;
2073
+ const rows = this.storage.sql
2074
+ .exec<ChunkRow>(
2075
+ 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno <= ?',
2076
+ name,
2077
+ first,
2078
+ last,
2079
+ )
2080
+ .toArray();
2081
+ this.stats.storageReads++;
2082
+ this.stats.rowsRead += rows.length;
2083
+ const byChunk = new Map<number, ChunkRow>();
2084
+ for (const row of rows) byChunk.set(row.chunkno, row);
2085
+ // The old object is needed for every slot a full hot row does not cover.
2086
+ let needOld = false;
2087
+ if (entry !== undefined && entry.mask !== 0) {
2088
+ for (let chunkno = first; chunkno <= last; chunkno++) {
2089
+ const row = byChunk.get(chunkno);
2090
+ const inR2 = (entry.mask & (1 << slotOf(chunkno))) !== 0;
2091
+ if (inR2 && (row === undefined || row.mask !== FULL_MASK)) {
2092
+ needOld = true;
2093
+ break;
2094
+ }
2095
+ }
2096
+ }
2097
+ const old =
2098
+ needOld && entry !== undefined
2099
+ ? await tier.getSegmentAt(name, segno, entry)
2100
+ : null;
2101
+ const seg = new Uint8Array(SEGMENT_BYTES);
2102
+ let mask = 0;
2103
+ for (let chunkno = first; chunkno <= last; chunkno++) {
2104
+ const slot = slotOf(chunkno);
2105
+ const at = slot * CHUNK_SIZE;
2106
+ const row = byChunk.get(chunkno);
2107
+ const inR2 = entry !== undefined && (entry.mask & (1 << slot)) !== 0;
2108
+ if (inR2 && old !== null)
2109
+ seg.set(old.subarray(at, at + CHUNK_SIZE), at);
2110
+ if (row !== undefined) {
2111
+ placeMasked(
2112
+ seg,
2113
+ at,
2114
+ CHUNK_SIZE,
2115
+ new Uint8Array(row.data),
2116
+ row.mask,
2117
+ );
2118
+ mask |= 1 << slot;
2119
+ } else if (inR2) {
2120
+ mask |= 1 << slot;
2121
+ }
2122
+ }
2123
+ const gen = tier.allocateGen(name);
2124
+ const newKey = await tier.putSegment(name, segno, gen, seg);
2125
+ // Rows a flush wrote while the object was in flight are newer than
2126
+ // what we uploaded: keep them hot.
2127
+ const written = file?.tierWrittenSince;
2128
+ const evict = candidates.filter(
2129
+ (chunkno) =>
2130
+ written === null || written === undefined || !written.has(chunkno),
2131
+ );
2132
+ this.storage.transactionSync(() => {
2133
+ tier.setSegment(name, segno, { gen, mask });
2134
+ if (evict.length > 0) {
2135
+ this.storage.sql.exec(
2136
+ `DELETE FROM vfs2_chunks WHERE file = ? AND chunkno IN (${evict.map(() => '?').join(', ')})`,
2137
+ name,
2138
+ ...evict,
2139
+ );
2140
+ this.stats.rowsWritten += evict.length;
2141
+ }
2142
+ });
2143
+ this.stats.storageWrites++;
2144
+ if (entry !== undefined) {
2145
+ const oldKey = tier.key(name, segno, entry.gen);
2146
+ if (oldKey !== newKey) tier.queueDelete(oldKey);
2147
+ }
2148
+ evicted += evict.length;
2149
+ }
2150
+ } finally {
2151
+ this.tierPassInFlight = false;
2152
+ if (file !== undefined) file.tierWrittenSince = null;
2153
+ }
2154
+ tier.stats.evictedChunks += evicted;
2155
+ tier.stats.evictionPasses++;
2156
+ if (this.snapshots.size === 0) await tier.maintenance();
2157
+ const hotRows = Math.max(0, hot.length - evicted);
2158
+ return {
2159
+ evictedChunks: evicted,
2160
+ segmentsRewritten: todo.length,
2161
+ remaining: plan.length - todo.length,
2162
+ hotRows,
2163
+ hotBytes: hotRows * CHUNK_SIZE,
2164
+ };
2165
+ }
2166
+
2167
+ /**
2168
+ * Bring every cold chunk of `name` back into storage as full rows and
2169
+ * forget the segments (their objects are deleted by maintenance). Used
2170
+ * before `VACUUM INTO`, which reads the whole file through the synchronous
2171
+ * path.
2172
+ */
2173
+ async materialize(name: string): Promise<{ chunks: number }> {
2174
+ const tier = this.tier;
2175
+ if (tier === null) return { chunks: 0 };
2176
+ if (this.snapshots.has(name))
2177
+ throw new DoVfsError(
2178
+ `cannot materialize '${name}' while a snapshot is open`,
2179
+ );
2180
+ const file = this.persistentByName.get(name);
2181
+ if (file !== undefined) this.flush(file);
2182
+ const size = file?.size ?? this.readStoredMeta(name)?.size ?? 0;
2183
+ const totalChunks = Math.ceil(size / CHUNK_SIZE);
2184
+ let chunks = 0;
2185
+ for (const [segno, entry] of Array.from(tier.segmentsOf(name).entries())) {
2186
+ if (entry.mask === 0) continue;
2187
+ const seg = await tier.getSegmentAt(name, segno, entry);
2188
+ const first = segno * SEGMENT_CHUNKS;
2189
+ const last = Math.min(totalChunks, first + SEGMENT_CHUNKS) - 1;
2190
+ if (last < first) continue;
2191
+ const rows = this.storage.sql
2192
+ .exec<ChunkRow>(
2193
+ 'SELECT chunkno, data, mask FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno <= ?',
2194
+ name,
2195
+ first,
2196
+ last,
2197
+ )
2198
+ .toArray();
2199
+ this.stats.storageReads++;
2200
+ this.stats.rowsRead += rows.length;
2201
+ const byChunk = new Map<number, ChunkRow>();
2202
+ for (const row of rows) byChunk.set(row.chunkno, row);
2203
+ const upserts: Array<[number, Uint8Array]> = [];
2204
+ for (let chunkno = first; chunkno <= last; chunkno++) {
2205
+ const row = byChunk.get(chunkno);
2206
+ if (row !== undefined && row.mask === FULL_MASK) continue;
2207
+ const inR2 = (entry.mask & (1 << slotOf(chunkno))) !== 0;
2208
+ if (!inR2 && row === undefined) continue;
2209
+ const full = new Uint8Array(CHUNK_SIZE);
2210
+ if (inR2 && seg !== null) {
2211
+ const at = slotOf(chunkno) * CHUNK_SIZE;
2212
+ full.set(seg.subarray(at, at + CHUNK_SIZE));
2213
+ }
2214
+ if (row !== undefined)
2215
+ placeMasked(full, 0, CHUNK_SIZE, new Uint8Array(row.data), row.mask);
2216
+ upserts.push([chunkno, full]);
2217
+ }
2218
+ this.storage.transactionSync(() => {
2219
+ for (let i = 0; i < upserts.length; i += FLUSH_BATCH_ROWS)
2220
+ this.upsertChunks(name, upserts.slice(i, i + FLUSH_BATCH_ROWS));
2221
+ });
2222
+ this.stats.storageWrites++;
2223
+ chunks += upserts.length;
2224
+ }
2225
+ this.storage.transactionSync(() => tier.dropFile(name));
2226
+ this.cache.dropFile(name);
2227
+ if (this.snapshots.size === 0) await tier.maintenance();
2228
+ return { chunks };
2229
+ }
2230
+
2231
+ /** Delete queued/orphaned R2 objects (never while a snapshot reads them). */
2232
+ async tierMaintenance(
2233
+ opts: { sweep?: boolean } = {},
2234
+ ): Promise<{ deleted: number }> {
2235
+ if (this.tier === null || this.snapshots.size > 0) return { deleted: 0 };
2236
+ return this.tier.maintenance(opts);
2237
+ }
2238
+
2239
+ /** Delete every R2 object of this VFS (the working copy is being wiped). */
2240
+ async deleteTierObjects(): Promise<number> {
2241
+ if (this.tier === null) return 0;
2242
+ for (const map of [this.misses]) map.clear();
2243
+ return this.tier.deleteAll();
2244
+ }
2245
+ }
2246
+
2247
+ /** Copy the pages of `mask` from `src` into `dst[offset..offset+n)` (page-aligned buffers). */
2248
+ function placeMasked(
2249
+ dst: Uint8Array,
2250
+ offset: number,
2251
+ n: number,
2252
+ src: Uint8Array,
2253
+ mask: number,
2254
+ ): void {
2255
+ if (mask === FULL_MASK) {
2256
+ const len = Math.min(n, src.byteLength);
2257
+ if (len > 0) dst.set(src.subarray(0, len), offset);
2258
+ return;
2259
+ }
2260
+ for (let page = 0; page < PAGES_PER_CHUNK; page++) {
2261
+ if ((mask & (1 << page)) === 0) continue;
2262
+ const at = page * VFS_PAGE_SIZE;
2263
+ if (at >= n) break;
2264
+ const len = Math.min(VFS_PAGE_SIZE, n - at, src.byteLength - at);
2265
+ if (len > 0) dst.set(src.subarray(at, at + len), offset + at);
2266
+ }
1403
2267
  }
1404
2268
 
1405
2269
  export function isDoVfs(vfs: SQLiteVFS): vfs is DoVfs {