@ixo/oracle-runtime-workers 0.9.0 → 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.
- package/package.json +1 -1
- package/src/core/env.ts +9 -0
- package/src/do/contracts.ts +22 -0
- package/src/do/user-oracle-do.ts +145 -24
- package/src/owner-store/ixo-vfs-store.ts +12 -5
- package/src/owner-store/matrix-media-store.ts +12 -7
- package/src/owner-store/measure.ts +39 -0
- package/src/owner-store/migrating-store.ts +9 -3
- package/src/owner-store/types.ts +14 -4
- package/src/shell/app.ts +12 -0
- package/src/sqlite/cache-config.ts +27 -6
- package/src/sqlite/chunk-layout.ts +14 -0
- package/src/sqlite/database.ts +188 -19
- package/src/sqlite/do-vfs.ts +963 -99
- package/src/sqlite/index.ts +13 -0
- package/src/sqlite/page-tier.ts +668 -0
package/src/sqlite/do-vfs.ts
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
/**
|
|
177
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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(
|
|
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(
|
|
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`.
|
|
892
|
-
*
|
|
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,
|
|
900
|
-
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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 (
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
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
|
|
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
|
-
):
|
|
1151
|
-
const
|
|
1152
|
-
if (
|
|
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.
|
|
1182
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1190
|
-
|
|
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)
|
|
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.
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1689
|
+
keepChunks,
|
|
1315
1690
|
);
|
|
1691
|
+
this.tier?.truncate(file.name, keepChunks);
|
|
1316
1692
|
}
|
|
1317
|
-
for (let i = 0; i <
|
|
1318
|
-
this.upsertChunks(file.name,
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
* the
|
|
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
|
|
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)
|
|
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,
|
|
1369
|
-
|
|
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(
|
|
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 {
|