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