sliftutils 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +48 -38
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +13 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +12 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +17 -15
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +379 -326
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +16 -1
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +65 -20
- package/storage/BulkDatabase2/mergeLock.ts +6 -5
- package/storage/BulkDatabase2/syncClient.d.ts +2 -1
- package/storage/BulkDatabase2/syncClient.ts +19 -2
- package/storage/FileFolderAPI.tsx +10 -2
- package/storage/BulkDatabase2/manifest.d.ts +0 -17
- package/storage/BulkDatabase2/manifest.ts +0 -59
|
@@ -1,62 +1,69 @@
|
|
|
1
1
|
import { sort } from "socket-function/src/misc";
|
|
2
2
|
import { getTimeUnique } from "socket-function/src/bits";
|
|
3
|
-
import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase } from "./BulkDatabaseFormat";
|
|
3
|
+
import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, BulkHeaderInfo, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase, loadBulkHeader, TARGET_FILE_BYTES } from "./BulkDatabaseFormat";
|
|
4
4
|
import { lazy } from "socket-function/src/caching";
|
|
5
5
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
6
|
import { blue, red } from "socket-function/src/formatting/logColors";
|
|
7
7
|
import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
|
|
8
8
|
import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
|
|
9
|
-
import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
10
|
-
import { Manifest, chooseManifest, isManifestName, manifestFileName, parseManifestStartTime } from "./manifest";
|
|
9
|
+
import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
11
10
|
import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
|
|
12
11
|
import type { FileStorage } from "../FileFolderAPI";
|
|
13
12
|
|
|
13
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// KNOWN BUGS (accepted, documented):
|
|
15
|
+
//
|
|
16
|
+
// • Inconsistent directory listing under concurrent merges. A read lists the directory, then loads
|
|
17
|
+
// each listed file. If a file is missing when we go to read it (a merge deleted it), we re-list and
|
|
18
|
+
// retry — the deleting merge wrote the replacement first, so the data is never gone, just moved.
|
|
19
|
+
// But a directory listing is not guaranteed atomic on every filesystem: a listing taken while
|
|
20
|
+
// another writer is mid-swap can in principle return a set of files that never simultaneously
|
|
21
|
+
// existed (e.g. the replacement but not a sibling it depends on). That yields a momentarily
|
|
22
|
+
// INCONSISTENT view — some keys may read stale or missing. It does NOT lose data on disk: a reload
|
|
23
|
+
// (refresh the page) re-lists and resolves correctly. We accept this rather than reintroduce a
|
|
24
|
+
// manifest; it's rare and OS/filesystem-dependent.
|
|
25
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
14
27
|
// BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
|
|
15
28
|
// folder rather than sharing bulkDatabases/.
|
|
16
29
|
const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
17
30
|
const FILE_EXTENSION = ".bulk";
|
|
18
|
-
// Once this many bulk files pile up we run a merge pass to consolidate small ones (bounded by the
|
|
19
|
-
// byte caps below), so reads don't fan out across an unbounded number of files.
|
|
20
|
-
const MERGE_FILE_COUNT = 8;
|
|
21
|
-
|
|
22
|
-
// Memory ceiling for a single merge. Files are merged in contiguous (newest-first) runs whose combined
|
|
23
|
-
// LOGICAL (uncompressed) size stays under MERGE_MAX_BYTES, so a merge never reads more than this into
|
|
24
|
-
// memory at once. A file at or above MERGE_MIN_BYTES is "sealed": big enough already, never read back
|
|
25
|
-
// in to be merged again. Sizes are measured uncompressed (from each file's index) because that — not
|
|
26
|
-
// the smaller on-disk compressed size — is what actually lands in memory during a merge.
|
|
27
|
-
const MERGE_MIN_BYTES = 400 * 1024 * 1024;
|
|
28
|
-
const MERGE_MAX_BYTES = 800 * 1024 * 1024;
|
|
29
|
-
|
|
30
31
|
// A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
|
|
31
32
|
// into a bulk file (streaming thousands of rows one frame at a time would be pointless).
|
|
32
33
|
const ROLLOVER_ROWS = 5000;
|
|
33
34
|
const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
34
35
|
|
|
35
|
-
// An unreadable file might be a write
|
|
36
|
-
// it on sight. Once it
|
|
37
|
-
// writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
36
|
+
// An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
|
|
37
|
+
// can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
|
|
38
|
+
// no writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
38
39
|
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
39
40
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// A read lists the directory then loads each file; if a file vanished (a merge deleted it) we re-list
|
|
42
|
+
// and retry, since the merge wrote the replacement first. Bounded so a pathological merge storm can't
|
|
43
|
+
// loop forever — after this many tries we load whatever's currently there (the documented inconsistent
|
|
44
|
+
// -view bug), which a later reload resolves.
|
|
45
|
+
const MAX_READ_ATTEMPTS = 8;
|
|
46
|
+
|
|
47
|
+
// The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
|
|
48
|
+
// file (half the target, so it has room to grow before it needs splitting). The key-stratified second
|
|
49
|
+
// merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
|
|
50
|
+
// (multi-file) keys exceeds DUP_THRESHOLD — i.e. only when deduping actually buys enough.
|
|
51
|
+
const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
|
|
52
|
+
const KEY_GROUP_BYTES = 800 * 1024 * 1024;
|
|
53
|
+
const DUP_THRESHOLD = 0.4;
|
|
54
|
+
|
|
55
|
+
// Time thresholds, mutable so tests can shrink them from hours to milliseconds.
|
|
43
56
|
export const bulkDatabase2Timing = {
|
|
44
|
-
// A writer stops appending to its current stream file once it's this old (starts a fresh one)
|
|
45
|
-
// file is
|
|
57
|
+
// A writer stops appending to its current stream file once it's this old (starts a fresh one). A
|
|
58
|
+
// stream file older than this is therefore safe for a merge to delete: its writer has provably moved
|
|
59
|
+
// on to a new file and will never append to it again.
|
|
46
60
|
streamSealAgeMs: 10 * 60 * 60 * 1000,
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// Per-instance throttle on how often we scan to see whether a fold is due.
|
|
54
|
-
foldCheckIntervalMs: 5 * 1000,
|
|
55
|
-
// How long a superseded/orphaned/old-manifest file must sit before cleanup deletes it (by its name
|
|
56
|
-
// timestamp) — long enough that any reader still on an older manifest has finished.
|
|
57
|
-
cleanupAgeMs: 60 * 1000,
|
|
58
|
-
// Per-instance throttle on cleanup scans.
|
|
59
|
-
cleanupIntervalMs: 10 * 1000,
|
|
61
|
+
// Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
|
|
62
|
+
mergeCheckIntervalMs: 30 * 60 * 1000,
|
|
63
|
+
// The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
|
|
64
|
+
firstMergeTriggerFiles: 20,
|
|
65
|
+
// ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
|
|
66
|
+
firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
|
|
60
67
|
};
|
|
61
68
|
|
|
62
69
|
// Marks a key as deleted in the in-memory overlay.
|
|
@@ -157,6 +164,12 @@ function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
|
157
164
|
return { fileName, level, timestamp };
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
// A file we listed is gone now (a concurrent merge deleted it after writing its replacement). Distinct
|
|
168
|
+
// from a corrupt/torn file: missing => the data moved, so re-list and retry; corrupt => skip the file.
|
|
169
|
+
class MissingFileError extends Error { }
|
|
170
|
+
// Thrown out of a read build when a listed file went missing mid-load, so the build re-lists and retries.
|
|
171
|
+
class FilesChangedError extends Error { }
|
|
172
|
+
|
|
160
173
|
// All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
|
|
161
174
|
// Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
|
|
162
175
|
export class BulkDatabaseBase<T extends { key: string }> {
|
|
@@ -189,8 +202,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
189
202
|
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
190
203
|
// so concurrent writers never touch the same file.
|
|
191
204
|
private streamFileName: string | undefined;
|
|
192
|
-
|
|
193
|
-
|
|
205
|
+
// Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
|
|
206
|
+
// first write — the first background merge check waits a full interval after construction.
|
|
207
|
+
private lastMergeCheck = Date.now();
|
|
194
208
|
private getStreamFileName(): string {
|
|
195
209
|
// Seal (stop appending to) our current file once it's old enough, so no file is ever appended
|
|
196
210
|
// to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
|
|
@@ -225,23 +239,43 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
225
239
|
}
|
|
226
240
|
|
|
227
241
|
private reader = lazy(async (): Promise<ResolvedReader> => {
|
|
242
|
+
// A merge can delete a file between our directory listing and our read of it. The merge wrote the
|
|
243
|
+
// replacement first, so the data isn't gone — it just moved to a file our stale listing didn't
|
|
244
|
+
// include. So on a missing file we re-list and rebuild. Bounded; the last attempt tolerates a
|
|
245
|
+
// missing file (loads whatever is there — the documented inconsistent-view bug, fixed by reload).
|
|
228
246
|
let start = Date.now();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
247
|
+
for (let attempt = 0; ; attempt++) {
|
|
248
|
+
try {
|
|
249
|
+
return await this.buildReader(start, attempt >= MAX_READ_ATTEMPTS);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e instanceof FilesChangedError && attempt < MAX_READ_ATTEMPTS) continue;
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// One read build over a directory listing. Loads every bulk file's columnar reader plus all streamed
|
|
258
|
+
// entries, then joins them by write-time. A corrupt/torn bulk file is skipped with a warning (its
|
|
259
|
+
// data lives in another file). A *missing* file (deleted by a concurrent merge) throws
|
|
260
|
+
// FilesChangedError so the caller re-lists — unless tolerateMissing, when we proceed without it.
|
|
261
|
+
private async buildReader(start: number, tolerateMissing: boolean): Promise<ResolvedReader> {
|
|
262
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
263
|
+
let filesChanged = false;
|
|
234
264
|
const [bulkReadersRaw, streamData] = await Promise.all([
|
|
235
265
|
Promise.all(bulkFiles.map(async f => {
|
|
236
266
|
try {
|
|
237
267
|
return await this.loadFileReader(f.fileName);
|
|
238
268
|
} catch (e) {
|
|
269
|
+
if (e instanceof MissingFileError) { filesChanged = true; return undefined; }
|
|
239
270
|
await this.handleUnreadableFile(f, (e as Error).message);
|
|
240
271
|
return undefined;
|
|
241
272
|
}
|
|
242
273
|
})),
|
|
243
274
|
this.loadStreamEntries(streamFiles),
|
|
244
275
|
]);
|
|
276
|
+
if (streamData.missing) filesChanged = true;
|
|
277
|
+
if (filesChanged && !tolerateMissing) throw new FilesChangedError();
|
|
278
|
+
|
|
245
279
|
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
246
280
|
// The join resolves purely by write-time, so reader order doesn't matter.
|
|
247
281
|
const readers: BaseBulkDatabaseReader[] = [];
|
|
@@ -260,9 +294,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
260
294
|
if (time > 50) {
|
|
261
295
|
console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
|
|
262
296
|
}
|
|
263
|
-
void this.cleanup(); // opportunistic, throttled, fire-and-forget — reads help GC orphans too
|
|
264
297
|
return joined;
|
|
265
|
-
}
|
|
298
|
+
}
|
|
266
299
|
|
|
267
300
|
// Connects to the cross-tab BroadcastChannel (browser only) so writes in other tabs of this
|
|
268
301
|
// collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
|
|
@@ -272,7 +305,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
272
305
|
private syncSetup = lazy(async () => {
|
|
273
306
|
if (!isSyncSupported()) return;
|
|
274
307
|
await this.reader();
|
|
275
|
-
|
|
308
|
+
// onSeal: a peer is about to fold recent data; drop our current stream file so we stop appending
|
|
309
|
+
// to it (our next write starts a fresh one), letting the merge fold it whole.
|
|
310
|
+
let recent = await syncConnect(this.name, write => this.applyRemote(write), () => { this.streamFileName = undefined; });
|
|
276
311
|
for (let write of recent) this.applyRemote(write);
|
|
277
312
|
});
|
|
278
313
|
|
|
@@ -340,7 +375,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
340
375
|
for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
|
|
341
376
|
});
|
|
342
377
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
343
|
-
|
|
378
|
+
void this.maybeMerge();
|
|
344
379
|
}
|
|
345
380
|
|
|
346
381
|
public async delete(key: string): Promise<void> {
|
|
@@ -357,7 +392,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
357
392
|
for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
|
|
358
393
|
});
|
|
359
394
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
360
|
-
|
|
395
|
+
void this.maybeMerge();
|
|
361
396
|
}
|
|
362
397
|
|
|
363
398
|
public async update(entry: Partial<T> & { key: string }): Promise<void> {
|
|
@@ -385,172 +420,66 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
385
420
|
if (present.length) await this.writeBatch(present);
|
|
386
421
|
}
|
|
387
422
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
private async getValidFiles(): Promise<{
|
|
393
|
-
bulkFiles: BulkFileInfo[];
|
|
394
|
-
streamFiles: StreamFileInfo[];
|
|
395
|
-
manifest: Manifest | undefined;
|
|
396
|
-
manifestName: string | undefined;
|
|
397
|
-
allBulkNames: string[];
|
|
398
|
-
allStreamNames: string[];
|
|
399
|
-
manifestNames: string[];
|
|
400
|
-
}> {
|
|
423
|
+
// Lists every bulk + stream file currently on disk (no manifest — every file is part of the
|
|
424
|
+
// database). Bulk newest-first, streams oldest-first. Duplicate data (from a crashed/raced merge)
|
|
425
|
+
// is harmless: reads resolve by write-time and a later merge with enough duplication removes it.
|
|
426
|
+
private async listFiles(): Promise<{ bulkFiles: BulkFileInfo[]; streamFiles: StreamFileInfo[] }> {
|
|
401
427
|
const storage = await this.storage();
|
|
402
428
|
const names = await storage.getKeys();
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
const allStreamNames: string[] = [];
|
|
429
|
+
const bulkFiles: BulkFileInfo[] = [];
|
|
430
|
+
const streamFiles: StreamFileInfo[] = [];
|
|
406
431
|
for (const n of names) {
|
|
407
|
-
if (
|
|
408
|
-
else if (n.endsWith(
|
|
409
|
-
else if (n.endsWith(STREAM_EXTENSION)) allStreamNames.push(n);
|
|
432
|
+
if (n.endsWith(FILE_EXTENSION)) { const p = parseFileName(n); if (p) bulkFiles.push(p); }
|
|
433
|
+
else if (n.endsWith(STREAM_EXTENSION)) { const p = parseStreamFileName(n); if (p) streamFiles.push(p); }
|
|
410
434
|
}
|
|
411
|
-
const parsed = (await Promise.all(manifestNames.map(async name => {
|
|
412
|
-
try {
|
|
413
|
-
const buf = await storage.get(name);
|
|
414
|
-
if (!buf) return undefined;
|
|
415
|
-
return { name, manifest: JSON.parse(buf.toString("utf8")) as Manifest };
|
|
416
|
-
} catch {
|
|
417
|
-
return undefined; // torn/corrupt/half-written manifest — ignore it
|
|
418
|
-
}
|
|
419
|
-
}))).filter((m): m is { name: string; manifest: Manifest } => !!m);
|
|
420
|
-
const chosen = chooseManifest(parsed);
|
|
421
|
-
|
|
422
|
-
let validBulkNames: string[];
|
|
423
|
-
if (!chosen) {
|
|
424
|
-
validBulkNames = allBulkNames;
|
|
425
|
-
} else {
|
|
426
|
-
const valid = new Set(chosen.manifest.validBulkFiles);
|
|
427
|
-
validBulkNames = allBulkNames.filter(n => valid.has(n));
|
|
428
|
-
}
|
|
429
|
-
const ignored = new Set(chosen?.manifest.ignoredStreamFiles || []);
|
|
430
|
-
const validStreamNames = allStreamNames.filter(n => !ignored.has(n));
|
|
431
|
-
|
|
432
|
-
const bulkFiles = validBulkNames.flatMap(n => { const p = parseFileName(n); return p ? [p] : []; });
|
|
433
435
|
// Newest-first by timestamp; ties broken by file name for determinism.
|
|
434
436
|
bulkFiles.sort((a, b) => {
|
|
435
437
|
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
436
438
|
return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
|
|
437
439
|
});
|
|
438
|
-
const streamFiles = validStreamNames.flatMap(n => { const p = parseStreamFileName(n); return p ? [p] : []; });
|
|
439
440
|
sort(streamFiles, f => f.timestamp);
|
|
440
|
-
|
|
441
|
-
return { bulkFiles, streamFiles, manifest: chosen?.manifest, manifestName: chosen?.name, allBulkNames, allStreamNames, manifestNames };
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Writes a brand-new manifest (never clobbering an existing one) capturing the full valid state.
|
|
445
|
-
private async commitManifest(startTime: number, readFiles: string[], validBulkFiles: string[], ignoredStreamFiles: string[]): Promise<void> {
|
|
446
|
-
const storage = await this.storage();
|
|
447
|
-
const manifest: Manifest = { startTime, validBulkFiles, ignoredStreamFiles, readFiles };
|
|
448
|
-
await storage.set(manifestFileName(startTime, writerId, nextCounter()), Buffer.from(JSON.stringify(manifest), "utf8"));
|
|
441
|
+
return { bulkFiles, streamFiles };
|
|
449
442
|
}
|
|
450
443
|
|
|
451
|
-
// Writes `rows` directly as bulk file(s), stamped with the current time as their
|
|
452
|
-
// are being written now). Used by the large-batch write path
|
|
453
|
-
//
|
|
454
|
-
//
|
|
444
|
+
// Writes `rows` directly as bulk file(s), stamped with the current time as their write-time (the rows
|
|
445
|
+
// are being written now). Used by the large-batch write path: no manifest, just new files on disk.
|
|
446
|
+
// The rows carry time=now, so the join orders them correctly against any older stream entry for the
|
|
447
|
+
// same key (newer time wins) — no clobber. A later testMerge consolidates them.
|
|
455
448
|
private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
|
|
456
449
|
const storage = await this.storage();
|
|
457
|
-
const
|
|
458
|
-
const view = await this.getValidFiles();
|
|
450
|
+
const timestamp = nextFileTime();
|
|
459
451
|
const now = Date.now();
|
|
460
452
|
const times = rows.map(() => now);
|
|
461
|
-
const newBulkNames: string[] = [];
|
|
462
453
|
for (const buffer of buildFileBuffer(rows, times)) {
|
|
463
|
-
const name = newFileName(
|
|
454
|
+
const name = newFileName(timestamp);
|
|
464
455
|
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
465
|
-
newBulkNames.push(name);
|
|
466
|
-
}
|
|
467
|
-
const validBulkFiles = view.bulkFiles.map(f => f.fileName).concat(newBulkNames);
|
|
468
|
-
const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
|
|
469
|
-
await this.commitManifest(startTime, readFiles, validBulkFiles, view.manifest?.ignoredStreamFiles || []);
|
|
470
|
-
this.resetReader();
|
|
471
|
-
if (validBulkFiles.length >= MERGE_FILE_COUNT) {
|
|
472
|
-
while (await this.mergeFiles() > 0) { /* consolidate accumulated bulk files */ }
|
|
473
|
-
}
|
|
474
|
-
await this.cleanup();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Fold. Reads every stream file CREATED before the cutoff (all sealed, since seal age < fold age),
|
|
478
|
-
// resolves them into one bulk file carrying each key's latest write-time per row, and re-persists
|
|
479
|
-
// surviving tombstones to a fresh stream file (so deletes of keys living in older bulk files keep
|
|
480
|
-
// suppressing them). Because reads resolve by write-time, the folded data needn't be older than the
|
|
481
|
-
// stream — the join sorts it out — so we just fold whole files, no cutoff split. Folded files are
|
|
482
|
-
// marked ignored (cleanup deletes them later); the new manifest is the atomic swap.
|
|
483
|
-
private async consolidate(): Promise<void> {
|
|
484
|
-
const storage = await this.storage();
|
|
485
|
-
const startTime = nextFileTime();
|
|
486
|
-
const view = await this.getValidFiles();
|
|
487
|
-
const cutoff = Date.now() - bulkDatabase2Timing.foldDataAgeMs;
|
|
488
|
-
const selected = view.streamFiles.filter(f => f.timestamp < cutoff);
|
|
489
|
-
if (!selected.length) return;
|
|
490
|
-
const { entries } = await this.loadStreamEntries(selected);
|
|
491
|
-
const ordered = this.orderStreamEntries(entries);
|
|
492
|
-
|
|
493
|
-
const byKey = new Map<string, Record<string, unknown>>();
|
|
494
|
-
const byKeyTime = new Map<string, number>();
|
|
495
|
-
const deleted = new Map<string, number>();
|
|
496
|
-
for (const e of ordered) {
|
|
497
|
-
if (e.deletedKey !== undefined) {
|
|
498
|
-
byKey.delete(e.deletedKey);
|
|
499
|
-
byKeyTime.delete(e.deletedKey);
|
|
500
|
-
deleted.set(e.deletedKey, e.time);
|
|
501
|
-
} else if (e.row) {
|
|
502
|
-
const key = e.row.key as string;
|
|
503
|
-
byKey.set(key, { ...byKey.get(key), ...e.row });
|
|
504
|
-
byKeyTime.set(key, e.time); // ordered ascending, so this ends up the latest write
|
|
505
|
-
deleted.delete(key);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const newBulkNames: string[] = [];
|
|
510
|
-
if (byKey.size) {
|
|
511
|
-
const rows = [...byKey.values()];
|
|
512
|
-
const times = rows.map(r => byKeyTime.get(r.key as string)!);
|
|
513
|
-
for (const buffer of buildFileBuffer(rows, times)) {
|
|
514
|
-
const name = newFileName(startTime);
|
|
515
|
-
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
516
|
-
newBulkNames.push(name);
|
|
517
|
-
}
|
|
518
456
|
}
|
|
519
|
-
|
|
520
|
-
// Surviving tombstones -> a fresh stream file (always valid). Written BEFORE the manifest so the
|
|
521
|
-
// folded sources are never ignored while their deletes are missing.
|
|
522
|
-
if (deleted.size) {
|
|
523
|
-
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
524
|
-
await storage.set(carryName, frameDeletes([...deleted].map(([key, time]) => ({ time, key }))));
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const validBulkFiles = view.bulkFiles.map(f => f.fileName).concat(newBulkNames);
|
|
528
|
-
const ignoredStreamFiles = [...new Set([...(view.manifest?.ignoredStreamFiles || []), ...selected.map(f => f.fileName)])];
|
|
529
|
-
const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
|
|
530
|
-
await this.commitManifest(startTime, readFiles, validBulkFiles, ignoredStreamFiles);
|
|
531
457
|
this.resetReader();
|
|
532
|
-
|
|
533
|
-
if (validBulkFiles.length >= MERGE_FILE_COUNT) {
|
|
534
|
-
while (await this.mergeFiles() > 0) { /* consolidate accumulated bulk files */ }
|
|
535
|
-
}
|
|
536
|
-
await this.cleanup();
|
|
458
|
+
void this.maybeMerge();
|
|
537
459
|
}
|
|
538
460
|
|
|
539
461
|
// Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
|
|
540
|
-
// unique timestamp + originating file) so callers can order writes globally across files
|
|
541
|
-
|
|
542
|
-
|
|
462
|
+
// unique timestamp + originating file) so callers can order writes globally across files, the
|
|
463
|
+
// prefix size we read per file (so a merge can verify nothing was appended before deleting it), and
|
|
464
|
+
// whether any listed file was missing (so a read can re-list and retry — a merge deleted it).
|
|
465
|
+
private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number; missing: boolean; sizes: Map<string, number> }> {
|
|
466
|
+
const sizes = new Map<string, number>();
|
|
467
|
+
if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
|
|
543
468
|
const storage = await this.storage();
|
|
469
|
+
let missing = false;
|
|
544
470
|
// Read a bounded prefix [0, size) rather than the whole file: a foreign writer may be appending
|
|
545
471
|
// concurrently, and storage.get() errors when the file grows past the size it stat'd. Reading a
|
|
546
472
|
// prefix is tolerant — parseStream stops at the last complete frame, the file stays valid, and a
|
|
547
|
-
// later read picks up the rest. A file removed out from under us (
|
|
473
|
+
// later read picks up the rest. A file removed out from under us (a merge) sets `missing`.
|
|
548
474
|
const buffers = await Promise.all(streamFiles.map(async f => {
|
|
549
475
|
try {
|
|
550
476
|
const info = await storage.getInfo(f.fileName);
|
|
551
|
-
if (!info
|
|
477
|
+
if (!info) { missing = true; return undefined; }
|
|
478
|
+
sizes.set(f.fileName, info.size);
|
|
479
|
+
if (info.size === 0) return undefined;
|
|
552
480
|
return await storage.getRange(f.fileName, { start: 0, end: info.size });
|
|
553
481
|
} catch {
|
|
482
|
+
missing = true;
|
|
554
483
|
return undefined;
|
|
555
484
|
}
|
|
556
485
|
}));
|
|
@@ -568,7 +497,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
568
497
|
entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
|
|
569
498
|
}
|
|
570
499
|
}
|
|
571
|
-
return { entries, totalBytes };
|
|
500
|
+
return { entries, totalBytes, missing, sizes };
|
|
572
501
|
}
|
|
573
502
|
|
|
574
503
|
// Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
|
|
@@ -580,38 +509,77 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
580
509
|
return entries.map(e => e.entry);
|
|
581
510
|
}
|
|
582
511
|
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
private async maybeRolloverStream(): Promise<void> {
|
|
512
|
+
// Throttled, fire-and-forget after writes: run a background merge check at most once per interval.
|
|
513
|
+
private async maybeMerge(): Promise<void> {
|
|
586
514
|
const now = Date.now();
|
|
587
|
-
if (now - this.
|
|
588
|
-
this.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
515
|
+
if (now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
|
|
516
|
+
this.lastMergeCheck = now;
|
|
517
|
+
try {
|
|
518
|
+
await this.tryMergeNow();
|
|
519
|
+
} catch (e) {
|
|
520
|
+
console.warn(`${this.name}: background merge failed: ${(e as Error).message}`);
|
|
592
521
|
}
|
|
593
522
|
}
|
|
594
523
|
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
524
|
+
// Runs one merge pass now (the same one maybeMerge runs on a timer). Returns whether it merged
|
|
525
|
+
// anything, and whether it bailed because another tab/process holds the merge lock — so a caller
|
|
526
|
+
// (e.g. a 30-minute scheduler) can tell "nothing to do" from "someone else is doing it".
|
|
527
|
+
public async tryMergeNow(): Promise<{ merged: boolean; lockFailed: boolean }> {
|
|
528
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return { merged: false, lockFailed: true };
|
|
529
|
+
try {
|
|
530
|
+
return { merged: await this.testMerge(), lockFailed: false };
|
|
531
|
+
} finally {
|
|
532
|
+
releaseMergeLock(this.name, writerId);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Full compaction: fold + dedup everything into key-sorted, ~256MB files. Reads the whole collection
|
|
537
|
+
// into memory (the accepted soft bound), so it's an explicit, occasional call. Deletes consumed bulk
|
|
538
|
+
// files and any stream file it's safe to (aged, or sealed-and-stable).
|
|
599
539
|
public async compact(): Promise<void> {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
540
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return; // someone else is already merging; fine
|
|
541
|
+
try {
|
|
542
|
+
syncBroadcastSeal(this.name);
|
|
543
|
+
this.streamFileName = undefined;
|
|
544
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
545
|
+
if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles);
|
|
546
|
+
} finally {
|
|
547
|
+
releaseMergeLock(this.name, writerId);
|
|
548
|
+
}
|
|
603
549
|
}
|
|
604
550
|
|
|
605
|
-
|
|
551
|
+
// The unified merge entry point: rewrite everything overlapping [timeLo, timeHi] into fresh
|
|
552
|
+
// key-sorted ~256MB bulk file(s). Selects bulk files by their header time range and stream files by
|
|
553
|
+
// their (creation .. seal-age) window. If the range reaches the present, first asks peers to seal so
|
|
554
|
+
// recent stream data is complete. Callers: testMerge (recent / key-group ranges); external callers
|
|
555
|
+
// can pass any range — older data just produces older files.
|
|
556
|
+
public async merge(timeLo: number, timeHi: number): Promise<void> {
|
|
557
|
+
if (timeHi >= Date.now()) { syncBroadcastSeal(this.name); this.streamFileName = undefined; }
|
|
558
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
559
|
+
const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
|
|
560
|
+
const selBulk = bulkFiles.filter((f, i) => {
|
|
561
|
+
const h = headers[i];
|
|
562
|
+
if (!h) return false;
|
|
563
|
+
// Old files (no recorded time range) only belong to a merge that reaches back to the start.
|
|
564
|
+
if (!h.maxTime && !h.minTime) return timeLo <= 0;
|
|
565
|
+
return h.minTime <= timeHi && h.maxTime >= timeLo;
|
|
566
|
+
});
|
|
567
|
+
const selStream = streamFiles.filter(f =>
|
|
568
|
+
f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
|
|
569
|
+
if (selBulk.length + selStream.length < 2) return;
|
|
570
|
+
await this.mergeFileSet(selBulk, selStream);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
|
|
574
|
+
// file a merge deleted (re-list and retry / skip) from a corrupt one (handle as unreadable).
|
|
575
|
+
private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number }> {
|
|
606
576
|
const storage = await this.storage();
|
|
607
577
|
const info = await storage.getInfo(fileName);
|
|
608
|
-
if (!info)
|
|
578
|
+
if (!info) throw new MissingFileError(`bulk file ${fileName} is missing`);
|
|
609
579
|
const rawGetRange: GetRange = async (start, end) => {
|
|
610
580
|
if (end <= start) return EMPTY_BUFFER;
|
|
611
581
|
const buf = await storage.getRange(fileName, { start, end });
|
|
612
|
-
if (!buf) {
|
|
613
|
-
throw new Error(`Expected range [${start}, ${end}) of ${fileName}, file was missing`);
|
|
614
|
-
}
|
|
582
|
+
if (!buf) throw new MissingFileError(`range [${start}, ${end}) of ${fileName} is missing`);
|
|
615
583
|
return buf;
|
|
616
584
|
};
|
|
617
585
|
return { rawGetRange, size: info.size };
|
|
@@ -619,9 +587,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
619
587
|
|
|
620
588
|
private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
|
|
621
589
|
const raw = await this.makeRawGetRange(fileName);
|
|
622
|
-
if (!raw) {
|
|
623
|
-
throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
|
|
624
|
-
}
|
|
625
590
|
const fileId = nullJoin(this.name, fileName);
|
|
626
591
|
// Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
|
|
627
592
|
// decompressing version (same interface) and read the logical (uncompressed) size from its
|
|
@@ -630,13 +595,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
630
595
|
return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
631
596
|
}
|
|
632
597
|
|
|
598
|
+
// Reads only a bulk file's header (row count, time range, key range) — no column data — for merge
|
|
599
|
+
// planning. Returns undefined for a missing/corrupt file so the planner just leaves it out.
|
|
600
|
+
private async readBulkHeader(fileName: string): Promise<BulkHeaderInfo | undefined> {
|
|
601
|
+
try {
|
|
602
|
+
const raw = await this.makeRawGetRange(fileName);
|
|
603
|
+
const fileId = nullJoin(this.name, fileName);
|
|
604
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
605
|
+
return await loadBulkHeader(opened.getRange, opened.uncompressedSize);
|
|
606
|
+
} catch {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
633
611
|
// Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
|
|
634
612
|
// Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
|
|
635
613
|
// missing or unreadable so the planner simply leaves it out of any merge.
|
|
636
614
|
private async fileLogicalSize(fileName: string): Promise<number | undefined> {
|
|
637
615
|
try {
|
|
638
616
|
const raw = await this.makeRawGetRange(fileName);
|
|
639
|
-
if (!raw) return undefined;
|
|
640
617
|
const fileId = nullJoin(this.name, fileName);
|
|
641
618
|
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
642
619
|
return opened.uncompressedSize;
|
|
@@ -665,16 +642,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
665
642
|
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
|
|
666
643
|
}
|
|
667
644
|
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
// merge
|
|
673
|
-
|
|
674
|
-
private async mergeFilesBase(files: BulkFileInfo[], timestamp: number): Promise<string[]> {
|
|
675
|
-
const storage = await this.storage();
|
|
676
|
-
const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
|
|
677
|
-
|
|
645
|
+
// Resolves a set of readers (stream + bulk) by ACTUAL write-time into merged rows + per-row times,
|
|
646
|
+
// plus the surviving tombstones (keys whose newest event is a delete). For each key/column, the
|
|
647
|
+
// value with the newest write-time across readers wins (non-ABSENT); the row's time is the newest of
|
|
648
|
+
// those. A key is deleted iff its newest delete is newer than its newest set. This is the same
|
|
649
|
+
// time-resolution reads use, captured so a merge can write the result back as bulk + a carry stream.
|
|
650
|
+
private async resolveReaders(readers: BaseBulkDatabaseReader[]): Promise<{ rows: Record<string, unknown>[]; times: number[]; deletes: Map<string, number> }> {
|
|
678
651
|
const loaded = await Promise.all(readers.map(async reader => {
|
|
679
652
|
const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
|
|
680
653
|
for (const col of reader.columns) {
|
|
@@ -682,21 +655,36 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
682
655
|
const entries = await reader.getColumn(col.column);
|
|
683
656
|
cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
|
|
684
657
|
}
|
|
685
|
-
return { keyTimes: reader.keyTimes, cols };
|
|
658
|
+
return { keyTimes: reader.keyTimes, deleteTimes: reader.deleteTimes, cols };
|
|
686
659
|
}));
|
|
687
660
|
|
|
688
|
-
const
|
|
689
|
-
const allCols = new Set<string>();
|
|
661
|
+
const deleteTime = new Map<string, number>();
|
|
690
662
|
for (const l of loaded) {
|
|
691
|
-
|
|
692
|
-
for (const
|
|
663
|
+
if (!l.deleteTimes) continue;
|
|
664
|
+
for (const [k, t] of l.deleteTimes) deleteTime.set(k, Math.max(deleteTime.get(k) ?? -Infinity, t));
|
|
693
665
|
}
|
|
666
|
+
const keyTime = new Map<string, number>();
|
|
667
|
+
for (const l of loaded) {
|
|
668
|
+
for (const [k, t] of l.keyTimes) keyTime.set(k, Math.max(keyTime.get(k) ?? -Infinity, t));
|
|
669
|
+
}
|
|
670
|
+
const allCols = new Set<string>();
|
|
671
|
+
for (const l of loaded) for (const c of l.cols.keys()) allCols.add(c);
|
|
694
672
|
|
|
695
|
-
const
|
|
696
|
-
const
|
|
673
|
+
const rows: Record<string, unknown>[] = [];
|
|
674
|
+
const times: number[] = [];
|
|
675
|
+
const deletes = new Map<string, number>();
|
|
676
|
+
const allKeys = new Set<string>([...keyTime.keys(), ...deleteTime.keys()]);
|
|
697
677
|
for (const key of allKeys) {
|
|
678
|
+
const setT = keyTime.get(key) ?? -Infinity;
|
|
679
|
+
const delT = deleteTime.get(key) ?? -Infinity;
|
|
680
|
+
if (setT <= delT) {
|
|
681
|
+
// The newest event for this key is a delete — carry the tombstone forward so it keeps
|
|
682
|
+
// suppressing any older set living in a file outside this merge.
|
|
683
|
+
if (delT > -Infinity) deletes.set(key, delT);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
698
686
|
const row: Record<string, unknown> = { [KEY_COLUMN]: key };
|
|
699
|
-
let rowTime =
|
|
687
|
+
let rowTime = setT;
|
|
700
688
|
for (const col of allCols) {
|
|
701
689
|
let bestTime = -Infinity;
|
|
702
690
|
let bestVal: unknown;
|
|
@@ -708,121 +696,186 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
708
696
|
}
|
|
709
697
|
if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
|
|
710
698
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
mergedTimes.push(rowTime === -Infinity ? 0 : rowTime);
|
|
699
|
+
rows.push(row);
|
|
700
|
+
times.push(rowTime === -Infinity ? 0 : rowTime);
|
|
714
701
|
}
|
|
715
|
-
|
|
716
|
-
const names: string[] = [];
|
|
717
|
-
for (const buffer of buildFileBuffer(mergedRows, mergedTimes)) {
|
|
718
|
-
const name = newFileName(timestamp);
|
|
719
|
-
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
720
|
-
names.push(name);
|
|
721
|
-
}
|
|
722
|
-
return names;
|
|
702
|
+
return { rows, times, deletes };
|
|
723
703
|
}
|
|
724
704
|
|
|
725
|
-
// The
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
return await this.mergeFilesLocked();
|
|
737
|
-
} finally {
|
|
738
|
-
releaseMergeLock(this.name, writerId);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
705
|
+
// The one merge primitive. Reads the given bulk + stream files (skipping any that vanished or won't
|
|
706
|
+
// parse — their data lives elsewhere), resolves them by write-time, writes the result back as fresh
|
|
707
|
+
// key-sorted ~256MB bulk file(s) plus a carry stream for surviving tombstones, THEN deletes the
|
|
708
|
+
// inputs it consumed. Output is always written before any delete, so a crash leaves duplicates (next
|
|
709
|
+
// merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
|
|
710
|
+
// only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
|
|
711
|
+
// didn't change while we read it. Returns whether it produced anything.
|
|
712
|
+
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]): Promise<boolean> {
|
|
713
|
+
const storage = await this.storage();
|
|
714
|
+
const timestamp = nextFileTime();
|
|
715
|
+
const now = Date.now();
|
|
741
716
|
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
717
|
+
const consumedBulk: BulkFileInfo[] = [];
|
|
718
|
+
const bulkReaders: BaseBulkDatabaseReader[] = [];
|
|
719
|
+
await Promise.all(bulkFiles.map(async f => {
|
|
720
|
+
try {
|
|
721
|
+
const r = await this.loadFileReader(f.fileName);
|
|
722
|
+
bulkReaders.push(r);
|
|
723
|
+
consumedBulk.push(f); // only files we actually read are safe to delete afterwards
|
|
724
|
+
} catch { /* missing or corrupt — skip; its data lives in another file */ }
|
|
725
|
+
}));
|
|
726
|
+
|
|
727
|
+
const streamData = await this.loadStreamEntries(streamFiles);
|
|
728
|
+
const ordered = this.orderStreamEntries(streamData.entries);
|
|
729
|
+
const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
|
|
730
|
+
|
|
731
|
+
const readers = streamReader ? [streamReader, ...bulkReaders] : bulkReaders;
|
|
732
|
+
if (!readers.length) return false;
|
|
733
|
+
|
|
734
|
+
const { rows, times, deletes } = await this.resolveReaders(readers);
|
|
735
|
+
|
|
736
|
+
// Write all outputs BEFORE deleting any input, so a throw mid-write just leaves duplicates.
|
|
737
|
+
const newNames: string[] = [];
|
|
738
|
+
if (rows.length) {
|
|
739
|
+
for (const buffer of buildFileBuffer(rows, times)) {
|
|
740
|
+
const name = newFileName(timestamp);
|
|
741
|
+
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
742
|
+
newNames.push(name);
|
|
762
743
|
}
|
|
763
|
-
if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
|
|
764
|
-
batch.push(files[i]);
|
|
765
|
-
batchBytes += size;
|
|
766
744
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const removed = new Set<string>();
|
|
771
|
-
const newBulkNames: string[] = [];
|
|
772
|
-
for (const runFiles of batches) {
|
|
773
|
-
// batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
|
|
774
|
-
const produced = await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
|
|
775
|
-
for (const f of runFiles) removed.add(f.fileName);
|
|
776
|
-
newBulkNames.push(...produced);
|
|
745
|
+
if (deletes.size) {
|
|
746
|
+
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
747
|
+
await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
|
|
777
748
|
}
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
const
|
|
781
|
-
|
|
749
|
+
|
|
750
|
+
const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
|
|
751
|
+
for (const f of consumedBulk) await remove(f.fileName);
|
|
752
|
+
for (const f of streamFiles) {
|
|
753
|
+
if (await this.canDeleteStream(f, now, streamData.sizes)) await remove(f.fileName);
|
|
754
|
+
}
|
|
755
|
+
|
|
782
756
|
this.resetReader();
|
|
783
|
-
|
|
784
|
-
return batches.length;
|
|
757
|
+
return newNames.length > 0 || deletes.size > 0;
|
|
785
758
|
}
|
|
786
759
|
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if (now -
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
760
|
+
// A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
|
|
761
|
+
// age (its writer has provably started a fresh file), OR cross-tab sync is active (so the seal we
|
|
762
|
+
// broadcast reached peers) and its size hasn't changed since we read it (nothing was appended during
|
|
763
|
+
// the merge). When neither holds we leave it: its data is now duplicated into bulk (resolved by time)
|
|
764
|
+
// and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
|
|
765
|
+
// data, but the aged check also rules out the rare sparse-offset append race.)
|
|
766
|
+
private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>): Promise<boolean> {
|
|
767
|
+
if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
|
|
768
|
+
if (!isSyncSupported()) return false;
|
|
769
|
+
const readSize = sizes.get(f.fileName);
|
|
770
|
+
if (readSize === undefined) return false;
|
|
771
|
+
let info;
|
|
772
|
+
try { info = await (await this.storage()).getInfo(f.fileName); } catch { return false; }
|
|
773
|
+
return !!info && info.size === readSize;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// The merge policy. Up to two passes:
|
|
777
|
+
// 1) Consolidate recent fragmentation: take the newest files up to ~FIRST_MERGE_BYTES and, if they
|
|
778
|
+
// number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs, merge them
|
|
779
|
+
// into one file. Seals first so recent stream data is complete; in Node (no cross-tab seal) only
|
|
780
|
+
// aged streams are folded, so we never re-fold the same un-deletable stream forever.
|
|
781
|
+
// 2) Key-stratify: sort all keys, walk them in ~KEY_GROUP_BYTES groups, and rewrite the single group
|
|
782
|
+
// whose fraction of duplicate (multi-file) keys is highest above DUP_THRESHOLD — merging every
|
|
783
|
+
// bulk file overlapping that key range. Over time this sorts the data into key-disjoint files.
|
|
784
|
+
// Returns whether either pass merged anything.
|
|
785
|
+
private async testMerge(): Promise<boolean> {
|
|
786
|
+
let merged = false;
|
|
787
|
+
|
|
788
|
+
// ── Pass 1: consolidate recent files. ──
|
|
789
|
+
// Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
|
|
790
|
+
// actually fold recent streams; in Node it would just churn — fragmenting streams every pass for
|
|
791
|
+
// no benefit, since canDeleteStream there only deletes aged files anyway.
|
|
792
|
+
const foldRecentStreams = isSyncSupported(); // see canDeleteStream: else we'd re-fold forever
|
|
793
|
+
if (foldRecentStreams) {
|
|
794
|
+
syncBroadcastSeal(this.name);
|
|
795
|
+
this.streamFileName = undefined; // seal our own current stream so its recent data is complete
|
|
796
|
+
}
|
|
797
|
+
{
|
|
798
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
799
|
+
const bulkMeta = await Promise.all(bulkFiles.map(async f => {
|
|
800
|
+
const [size, header] = await Promise.all([this.fileLogicalSize(f.fileName), this.readBulkHeader(f.fileName)]);
|
|
801
|
+
return { kind: "bulk" as const, file: f, bytes: size ?? 0, time: header?.maxTime || f.timestamp };
|
|
802
|
+
}));
|
|
803
|
+
const streamMeta: { kind: "stream"; file: StreamFileInfo; bytes: number; time: number }[] = [];
|
|
804
|
+
for (const f of streamFiles) {
|
|
805
|
+
const aged = Date.now() - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs;
|
|
806
|
+
if (!foldRecentStreams && !aged) continue;
|
|
807
|
+
let bytes = 0;
|
|
808
|
+
try { const info = await (await this.storage()).getInfo(f.fileName); bytes = info?.size ?? 0; } catch { bytes = 0; }
|
|
809
|
+
streamMeta.push({ kind: "stream", file: f, bytes, time: f.timestamp });
|
|
810
810
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
811
|
+
const items = [...bulkMeta, ...streamMeta].sort((a, b) => b.time - a.time);
|
|
812
|
+
const recent: typeof items = [];
|
|
813
|
+
let recentBytes = 0;
|
|
814
|
+
for (const it of items) {
|
|
815
|
+
recent.push(it);
|
|
816
|
+
recentBytes += it.bytes;
|
|
817
|
+
if (recentBytes >= FIRST_MERGE_BYTES) break;
|
|
816
818
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
819
|
+
const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
|
|
820
|
+
const triggered = recent.length >= 2
|
|
821
|
+
&& (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs);
|
|
822
|
+
if (triggered) {
|
|
823
|
+
const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
|
|
824
|
+
const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
|
|
825
|
+
if (await this.mergeFileSet(rb, rs)) merged = true;
|
|
822
826
|
}
|
|
823
|
-
} catch {
|
|
824
|
-
// ignore — cleanup is opportunistic; the next pass will catch up
|
|
825
827
|
}
|
|
828
|
+
|
|
829
|
+
// ── Pass 2: key-stratify the bulk files to remove duplication. ──
|
|
830
|
+
{
|
|
831
|
+
const { bulkFiles } = await this.listFiles();
|
|
832
|
+
if (bulkFiles.length >= 2) {
|
|
833
|
+
const infos = await Promise.all(bulkFiles.map(async f => {
|
|
834
|
+
try {
|
|
835
|
+
const reader = await this.loadFileReader(f.fileName);
|
|
836
|
+
const keys = reader.keys;
|
|
837
|
+
let min = keys[0], max = keys[0];
|
|
838
|
+
for (const k of keys) { if (k < min) min = k; if (k > max) max = k; }
|
|
839
|
+
return { file: f, keys, bytes: reader.totalBytes, min, max };
|
|
840
|
+
} catch {
|
|
841
|
+
return { file: f, keys: [] as string[], bytes: 0, min: undefined as string | undefined, max: undefined as string | undefined };
|
|
842
|
+
}
|
|
843
|
+
}));
|
|
844
|
+
const usable = infos.filter(i => i.keys.length > 0);
|
|
845
|
+
const keyCount = new Map<string, number>();
|
|
846
|
+
let totalSlots = 0, totalBytes = 0;
|
|
847
|
+
for (const i of usable) {
|
|
848
|
+
totalBytes += i.bytes;
|
|
849
|
+
for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
|
|
850
|
+
}
|
|
851
|
+
if (totalSlots > 0) {
|
|
852
|
+
const bytesPerSlot = totalBytes / totalSlots;
|
|
853
|
+
const sortedKeys = [...keyCount.keys()].sort();
|
|
854
|
+
// Walk sorted keys forming ~KEY_GROUP_BYTES groups; remember the group with the highest
|
|
855
|
+
// duplicate fraction over the threshold (the most benefit), then merge its files.
|
|
856
|
+
let best: { lo: string; hi: string; dup: number } | undefined;
|
|
857
|
+
let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
|
|
858
|
+
for (let i = 0; i < sortedKeys.length; i++) {
|
|
859
|
+
const c = keyCount.get(sortedKeys[i])!;
|
|
860
|
+
gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
|
|
861
|
+
if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
|
|
862
|
+
const dup = (gSlots - gUnique) / gSlots;
|
|
863
|
+
if (dup > DUP_THRESHOLD && (!best || dup > best.dup)) best = { lo: sortedKeys[gStart], hi: sortedKeys[i], dup };
|
|
864
|
+
gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (best) {
|
|
868
|
+
const lo = best.lo, hi = best.hi;
|
|
869
|
+
const groupFiles = usable
|
|
870
|
+
.filter(i => i.min !== undefined && i.max !== undefined && i.min <= hi && i.max >= lo)
|
|
871
|
+
.map(i => i.file);
|
|
872
|
+
if (groupFiles.length >= 2 && await this.mergeFileSet(groupFiles, [])) merged = true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return merged;
|
|
826
879
|
}
|
|
827
880
|
|
|
828
881
|
private formatInfo(reader: ResolvedReader): string {
|