sliftutils 1.3.1 → 1.4.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.
@@ -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 that is still in progress (another thread), so we can't delete
36
- // it on sight. Once it has been unreadable for longer than this (by its filename timestamp), no
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
- // All the time thresholds, mutable so tests can shrink them from hours to milliseconds. The ordering
41
- // invariant relies on: streamSealAgeMs < foldDataAgeMs (a file is sealed well before it's foldable),
42
- // and foldTriggerAgeMs >= foldDataAgeMs.
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), so no
45
- // file is ever appended to past this age.
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
- // A fold reads every stream file CREATED longer ago than this (all sealed, with margin), and moves
48
- // only the entries whose WRITE-TIME is older than this into bulk; newer entries are re-streamed.
49
- // This single cutoff is what keeps every bulk write strictly older than every stream write.
50
- foldDataAgeMs: 12 * 60 * 60 * 1000,
51
- // We don't bother folding until some stream file is older than this (fold in big infrequent batches).
52
- foldTriggerAgeMs: 36 * 60 * 60 * 1000,
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
- private lastCleanup = 0;
193
- private lastFoldCheck = 0;
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
- const { bulkFiles, streamFiles } = await this.getValidFiles();
230
- // Load everything in parallel: each bulk file's columnar reader, plus all streamed entries.
231
- // A corrupt/truncated bulk file is skipped with a warning rather than breaking the load or
232
- // returning bad values: the write protocol always writes a new file before removing the old
233
- // ones it supersedes, so a partially-written file's data still exists in another file.
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
- let recent = await syncConnect(this.name, write => this.applyRemote(write));
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
- await this.maybeRolloverStream();
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
- await this.maybeRolloverStream();
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
- // Resolves the authoritative on-disk state via manifests (see manifest.ts): valid bulk files
389
- // (newest-first) + valid stream files, plus the chosen manifest and the raw name lists the
390
- // commit/cleanup paths need. No manifest at all => every bulk file is valid (back-compat). Stream
391
- // files are valid unless the chosen manifest lists them as already folded into a bulk file.
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 manifestNames: string[] = [];
404
- const allBulkNames: string[] = [];
405
- const allStreamNames: string[] = [];
429
+ const bulkFiles: BulkFileInfo[] = [];
430
+ const streamFiles: StreamFileInfo[] = [];
406
431
  for (const n of names) {
407
- if (isManifestName(n)) manifestNames.push(n);
408
- else if (n.endsWith(FILE_EXTENSION)) allBulkNames.push(n);
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 data range (the rows
452
- // are being written now). Used by the large-batch write path. Commits a new manifest adding them to
453
- // the valid set. The rows carry time=now, so the join orders them correctly against any older stream
454
- // entry for the same key (newer time wins) — no clobber.
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 startTime = nextFileTime();
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(startTime);
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
- private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number }> {
542
- if (!streamFiles.length) return { entries: [], totalBytes: 0 };
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 (cleanup) just yields undefined.
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 || info.size === 0) return undefined;
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
- // Folding is purely age-driven and done in big infrequent batches: once some stream file is older
584
- // than the trigger age, fold everything past the data cutoff. Throttled so we don't scan on every write.
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.lastFoldCheck < bulkDatabase2Timing.foldCheckIntervalMs) return;
588
- this.lastFoldCheck = now;
589
- const { streamFiles } = await this.getValidFiles();
590
- if (streamFiles.some(f => now - f.timestamp >= bulkDatabase2Timing.foldTriggerAgeMs)) {
591
- await this.consolidate();
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
- // Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
596
- // more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
597
- // so it never loads the whole collection into memory a multi-GB collection settles into several
598
- // capped files (sealed files are left as-is) rather than one giant one.
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
- let merged = false;
601
- while (await this.mergeFiles() > 0) merged = true;
602
- if (merged) this.resetReader();
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
- private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
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) return undefined;
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
- // Merges exactly the files it's given, returning the new file name(s). It does NOT delete the inputs
669
- // or write a manifest — the caller (mergeFiles) commits one manifest for the whole pass. The merge is
670
- // per-COLUMN by write-TIME: for each key, each column takes the value with the newest write-time
671
- // across the merged files (non-ABSENT), and the output row's time is the newest of those so the
672
- // merge preserves correct time-resolution (only collapsing per-column times within a single output
673
- // row, the accepted per-row corner). The caller keeps the input under MERGE_MAX_BYTES.
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 allKeys = new Set<string>();
689
- const allCols = new Set<string>();
661
+ const deleteTime = new Map<string, number>();
690
662
  for (const l of loaded) {
691
- for (const k of l.keyTimes.keys()) allKeys.add(k);
692
- for (const c of l.cols.keys()) allCols.add(c);
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 mergedRows: Record<string, unknown>[] = [];
696
- const mergedTimes: number[] = [];
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 = -Infinity;
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
- for (const l of loaded) { const t = l.keyTimes.get(key); if (t !== undefined && t > rowTime) rowTime = t; }
712
- mergedRows.push(row);
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 cap-aware merge planner. Walks the valid files newest-first and merges contiguous runs of
726
- // non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
727
- // loads more than that into memory. A file at/over MERGE_MIN_BYTES is sealed (left untouched) and
728
- // breaks the run, as does an unreadable file. The whole pass is committed as ONE new manifest:
729
- // valid bulk = (old valid - consumed) + merged outputs; the consumed files are left on disk for
730
- // cleanup. Returns the number of runs merged (0 means nothing left to consolidate).
731
- private async mergeFiles(): Promise<number> {
732
- // Best-effort cross-tab lock: if another tab is already merging, skip the manifest backstop
733
- // keeps us correct, and we'd only be racing to orphan each other's output. No-op in Node.
734
- if (!tryAcquireMergeLock(this.name, writerId)) return 0;
735
- try {
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
- private async mergeFilesLocked(): Promise<number> {
743
- const startTime = nextFileTime();
744
- const view = await this.getValidFiles();
745
- const files = view.bulkFiles;
746
- const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
747
-
748
- const batches: BulkFileInfo[][] = [];
749
- let batch: BulkFileInfo[] = [];
750
- let batchBytes = 0;
751
- const flush = () => {
752
- // A run of one file is pointless to "merge" — only consolidate when there are at least two.
753
- if (batch.length >= 2) batches.push(batch);
754
- batch = [];
755
- batchBytes = 0;
756
- };
757
- for (let i = 0; i < files.length; i++) {
758
- const size = sizes[i];
759
- if (size === undefined || size >= MERGE_MIN_BYTES) {
760
- flush();
761
- continue;
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
- flush();
768
- if (!batches.length) return 0;
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
- const validBulkFiles = files.map(f => f.fileName).filter(n => !removed.has(n)).concat(newBulkNames);
779
- const ignoredStreamFiles = view.manifest?.ignoredStreamFiles || [];
780
- const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
781
- await this.commitManifest(startTime, readFiles, validBulkFiles, ignoredStreamFiles);
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
- await this.cleanup();
784
- return batches.length;
757
+ return newNames.length > 0 || deletes.size > 0;
785
758
  }
786
759
 
787
- // Deletes files no longer referenced by the authoritative manifest that have sat long enough that
788
- // no reader still resolving an older manifest needs them: superseded/orphaned bulk files,
789
- // folded-away (ignored) stream files, and every manifest but the newest. Age-gated by each file's
790
- // own name timestamp and throttled per instance. Best-effort a failed remove (another writer beat
791
- // us to it) is ignored. We never delete a file whose name we can't parse for an age.
792
- private async cleanup(): Promise<void> {
793
- const now = Date.now();
794
- if (now - this.lastCleanup < bulkDatabase2Timing.cleanupIntervalMs) return;
795
- this.lastCleanup = now;
796
- // Best-effort and must never throw: it runs fire-and-forget from reads, and the directory could
797
- // even be removed out from under us (e.g. the collection is being deleted) mid-scan.
798
- try {
799
- const storage = await this.storage();
800
- const view = await this.getValidFiles();
801
- const validBulk = new Set(view.bulkFiles.map(f => f.fileName));
802
- const validStream = new Set(view.streamFiles.map(f => f.fileName));
803
- const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
804
-
805
- for (const name of view.allBulkNames) {
806
- if (validBulk.has(name)) continue;
807
- const info = parseFileName(name);
808
- if (!info || now - info.timestamp < bulkDatabase2Timing.cleanupAgeMs) continue;
809
- await remove(name);
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
- for (const name of view.allStreamNames) {
812
- if (validStream.has(name)) continue;
813
- const info = parseStreamFileName(name);
814
- if (!info || now - info.timestamp < bulkDatabase2Timing.cleanupAgeMs) continue;
815
- await remove(name);
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
- for (const name of view.manifestNames) {
818
- if (name === view.manifestName) continue;
819
- const startTime = parseManifestStartTime(name);
820
- if (startTime === undefined || now - startTime < bulkDatabase2Timing.cleanupAgeMs) continue;
821
- await remove(name);
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 {