sliftutils 1.4.69 → 1.4.71

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 CHANGED
@@ -940,7 +940,6 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
940
940
  streamFoldHardLimitBytes: number;
941
941
  writeFlushMaxDelayMs: number;
942
942
  fileSetPollIntervalMs: number;
943
- deleteDeferMs: number;
944
943
  memoryFlushHeapBytes: number;
945
944
  memoryFlushMinCollectionBytes: number;
946
945
  memoryFlushThrottleMs: number;
@@ -1023,6 +1022,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1023
1022
  key: string;
1024
1023
  })[]): Promise<void>;
1025
1024
  private listFiles;
1025
+ private processMarkers;
1026
1026
  private writeBulkFile;
1027
1027
  private maybeMerge;
1028
1028
  tryMergeNow: () => Promise<void>;
@@ -1532,6 +1532,25 @@ declare module "sliftutils/storage/BulkDatabase2/mergeLock" {
1532
1532
 
1533
1533
  }
1534
1534
 
1535
+ declare module "sliftutils/storage/BulkDatabase2/mergeMarkers" {
1536
+ import type { FileStorage } from "../FileFolderAPI";
1537
+ export type DeleteMarker = {
1538
+ fileName: string;
1539
+ deleteFiles: string[];
1540
+ replacedBy: string[];
1541
+ time: number;
1542
+ };
1543
+ export declare function isMarkerFile(name: string): boolean;
1544
+ export declare function writeDeleteMarker(storage: FileStorage, config: {
1545
+ deleteFiles: string[];
1546
+ replacedBy: string[];
1547
+ }): Promise<void>;
1548
+ export declare function readDeleteMarkers(storage: FileStorage, allNames: string[]): Promise<DeleteMarker[]>;
1549
+ export declare function markerExclusions(markers: DeleteMarker[]): Set<string>;
1550
+ export declare function processDeleteMarkers(storage: FileStorage, markers: DeleteMarker[], allNames: string[]): Promise<void>;
1551
+
1552
+ }
1553
+
1535
1554
  declare module "sliftutils/storage/BulkDatabase2/streamLog" {
1536
1555
  /// <reference types="node" />
1537
1556
  /// <reference types="node" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.69",
3
+ "version": "1.4.71",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -12,7 +12,6 @@ export declare const bulkDatabase2Timing: {
12
12
  streamFoldHardLimitBytes: number;
13
13
  writeFlushMaxDelayMs: number;
14
14
  fileSetPollIntervalMs: number;
15
- deleteDeferMs: number;
16
15
  memoryFlushHeapBytes: number;
17
16
  memoryFlushMinCollectionBytes: number;
18
17
  memoryFlushThrottleMs: number;
@@ -95,6 +94,7 @@ export declare class BulkDatabaseBase<T extends {
95
94
  key: string;
96
95
  })[]): Promise<void>;
97
96
  private listFiles;
97
+ private processMarkers;
98
98
  private writeBulkFile;
99
99
  private maybeMerge;
100
100
  tryMergeNow: () => Promise<void>;
@@ -19,6 +19,7 @@ import { STREAM_EXTENSION, frameDeletes, frameRows, streamReaderFromEntries } fr
19
19
  import { broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, connect as syncConnect, isSyncSupported, RemoteWrite } from "./syncClient";
20
20
  import { DELETED } from "./WriteOverlay";
21
21
  import { releaseMergeFileLock, releaseMergeLock, startMergeFileLockHeartbeat, tryAcquireMergeFileLock, tryAcquireMergeLock } from "./mergeLock";
22
+ import { markerExclusions, processDeleteMarkers, readDeleteMarkers, writeDeleteMarker } from "./mergeMarkers";
22
23
  import {
23
24
  BulkFileInfo,
24
25
  LoadedIndex,
@@ -42,11 +43,11 @@ const MAX_INDEX_RELOAD_ATTEMPTS = 3;
42
43
  const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
43
44
  const KEY_GROUP_BYTES = 800 * 1024 * 1024;
44
45
  const DUP_THRESHOLD = 0.4;
45
- // Emergency Pass 2 short-circuit: once Pass 1 has had a chance to fold streams, if the bulk tier is
46
- // big enough AND the overall key duplication fraction is high, do ONE merge across every bulk file
47
- // instead of the per-key-group walk (which spaces merges 5 min apart — 16 hours for 200 groups).
48
- const EMERGENCY_DEDUP_BYTES = 512 * 1024 * 1024;
49
- const EMERGENCY_DEDUP_FRACTION = 0.5;
46
+ // Whole-tier dedup short-circuit (after Pass 1): when the bulk tier is over this size AND the overall
47
+ // key duplication fraction is over this threshold, fold every bulk file in one merge instead of the
48
+ // per-key-group walk (which spaces merges 5 min apart — 16 h for 200 groups).
49
+ const DEDUP_TRIGGER_BYTES = 512 * 1024 * 1024;
50
+ const DEDUP_TRIGGER_FRACTION = 0.5;
50
51
  const WRITE_FLUSH_FIRST_STEP_MS = 250;
51
52
 
52
53
  export const bulkDatabase2Timing = {
@@ -66,12 +67,6 @@ export const bulkDatabase2Timing = {
66
67
  // the whole stream file per write.
67
68
  writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
68
69
  fileSetPollIntervalMs: 30 * 60 * 1000,
69
- // Defer deletion of a merge's consumed inputs by this long. The OS may not durably flush the new
70
- // outputs immediately, so if we deleted the inputs and crashed before the writes settled we'd lose
71
- // data. Within the window the inputs sit as duplicates of the outputs; if we never crash, the timer
72
- // fires and removes them. If we DO crash before the timer, the duplicates stay until the next
73
- // key-stratify pass (high duplicate-key fraction → it merges them again, dedup completes).
74
- deleteDeferMs: 15 * 60 * 1000,
75
70
  memoryFlushHeapBytes: 1500 * 1024 * 1024,
76
71
  memoryFlushMinCollectionBytes: 100 * 1024 * 1024,
77
72
  memoryFlushThrottleMs: 15 * 60 * 1000,
@@ -550,9 +545,16 @@ export class BulkDatabaseBase<T extends { key: string }> {
550
545
  private async listFiles(): Promise<{ bulkFiles: BulkFileInfo[]; streamFiles: StreamFileInfo[] }> {
551
546
  const storage = await this.storage();
552
547
  const names = await storage.getKeys();
548
+ // A consumed-merge input is hidden from reads the instant its deletion marker exists — that's
549
+ // what stops it from being re-read and re-merged. Physical deletion + marker cleanup runs on a
550
+ // throttle (not inline) so a read isn't slowed by it.
551
+ const markers = await readDeleteMarkers(storage, names);
552
+ const excluded = markerExclusions(markers);
553
+ if (markers.length) void this.processMarkers();
553
554
  const bulkFiles: BulkFileInfo[] = [];
554
555
  const streamFiles: StreamFileInfo[] = [];
555
556
  for (const n of names) {
557
+ if (excluded.has(n)) continue;
556
558
  if (n.endsWith(FILE_EXTENSION)) { const p = parseFileName(n); if (p) bulkFiles.push(p); }
557
559
  else if (n.endsWith(STREAM_EXTENSION)) { const p = parseStreamFileName(n); if (p) streamFiles.push(p); }
558
560
  }
@@ -564,6 +566,16 @@ export class BulkDatabaseBase<T extends { key: string }> {
564
566
  return { bulkFiles, streamFiles };
565
567
  }
566
568
 
569
+ // Throttled marker housekeeping: delete inputs whose replacement outputs have landed (or whose
570
+ // marker has aged out) and retire fulfilled markers. Triggered from listFiles, so it's gated to
571
+ // avoid a delete storm under heavy reads.
572
+ private processMarkers = throttleFunction(30 * 1000, async () => {
573
+ const storage = await this.storage();
574
+ const names = await storage.getKeys();
575
+ const markers = await readDeleteMarkers(storage, names);
576
+ if (markers.length) await processDeleteMarkers(storage, markers, names);
577
+ });
578
+
567
579
  private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
568
580
  const storage = await this.storage();
569
581
  const timestamp = nextFileTime();
@@ -809,36 +821,29 @@ export class BulkDatabaseBase<T extends { key: string }> {
809
821
  for (const line of steps) console.log(line);
810
822
  console.groupEnd();
811
823
 
812
- // Rebuild + swap NOW so the index sees the new outputs; the old inputs are still on disk until
813
- // the deferred-delete timer fires (the index briefly serves both, resolved by time so reads are
814
- // correct). Block-cache eviction for inputs happens on the SECOND rebuild after deletion.
815
- await this.triggerRebuild();
816
-
817
- // Only the sources runPlannedMerge actually used are safe to delete — a source it dropped
818
- // mid-plan (file gone, corruption) still holds data we couldn't merge in, so leave it on disk
819
- // and let a future merge re-attempt it.
824
+ // Only the sources runPlannedMerge actually used can be retired a source it dropped mid-plan
825
+ // (file gone, corruption) still holds data we couldn't merge in, so leave it on disk and let a
826
+ // future merge re-attempt it.
820
827
  const usedConsumedBulk = consumedBulk.filter(f => mergeResult.usedSourceNames.has(f.fileName));
821
828
  const usedStreamFiles = streamFiles.filter(f => mergeResult.usedSourceNames.has(f.fileName) || mergeResult.usedSourceNames.has("(streams)"));
829
+ // A stream may still be appended to after we read it (the writer hasn't sealed/moved on); only
830
+ // retire streams canDeleteStream clears — the rest stay live and a later merge re-folds them
831
+ // once sealed. Bulk files are immutable, so a used one is always safe to retire.
832
+ const deletableStreams: string[] = [];
833
+ for (const f of usedStreamFiles) {
834
+ if (await this.canDeleteStream(f, Date.now(), streamData.sizes, forceDeleteStreams)) deletableStreams.push(f.fileName);
835
+ }
836
+ const deleteFiles = [...usedConsumedBulk.map(f => f.fileName), ...deletableStreams];
822
837
 
823
- // Deferred delete: give the FS time to durably flush new writes before removing the inputs.
824
- // If we crash before the timer fires, the inputs survive as duplicates and a later key-stratify
825
- // pass dedupes them. We DON'T await this the merge returns "done" as soon as the new outputs
826
- // are written and the index is swapped in.
827
- const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
828
- const timer = setTimeout(() => {
829
- void (async () => {
830
- try {
831
- for (const f of usedConsumedBulk) await remove(f.fileName);
832
- for (const f of usedStreamFiles) {
833
- if (await this.canDeleteStream(f, Date.now(), streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
834
- }
835
- await this.triggerRebuild();
836
- } catch (e) {
837
- console.warn(`${this.name}: deferred delete of consumed merge inputs failed: ${(e as Error).message}`);
838
- }
839
- })();
840
- }, bulkDatabase2Timing.deleteDeferMs);
841
- (timer as { unref?: () => void }).unref?.();
838
+ // Write a deletion marker instead of deleting inline. From the next read on, listFiles hides
839
+ // these inputs (so they're never re-merged the loop fix), and processMarkers removes them
840
+ // physically once the outputs have landed or the marker ages out. Marker BEFORE the rebuild so
841
+ // the freshly-swapped index already excludes the retired inputs.
842
+ if (deleteFiles.length) await writeDeleteMarker(storage, { deleteFiles, replacedBy: outNames });
843
+
844
+ // Rebuild + swap so the index sees the new outputs and drops the retired inputs. Block-cache
845
+ // eviction for the dropped inputs happens here (they're no longer in the new index's fileSet).
846
+ await this.triggerRebuild();
842
847
  return newNames.length > 0 || carriedDeletes > 0;
843
848
  }
844
849
 
@@ -941,9 +946,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
941
946
  }
942
947
  }
943
948
 
944
- // Pass 1.5 (emergency dedup): Pass 1 has had its chance. If the bulk tier is fat AND mostly
945
- // duplicates, the per-group walk below (5 min spacing per group) takes hours for an extreme
946
- // state (1000+ files, 99% dup) — fold every bulk file in one merge and skip the walk.
949
+ // Whole-tier dedup short-circuit (between Pass 1 and the per-group Pass 2): when the bulk tier
950
+ // is big enough AND mostly duplicates, fold every bulk file in one merge. Avoids the per-group
951
+ // walk's 5-min spacing × N-groups latency.
947
952
  {
948
953
  const { bulkFiles } = await this.listFiles();
949
954
  if (bulkFiles.length >= 2) {
@@ -960,8 +965,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
960
965
  } catch { /* skip unreadable */ }
961
966
  }
962
967
  const dupFraction = totalSlots ? (totalSlots - uniqueKeys.size) / totalSlots : 0;
963
- if (totalBytes > EMERGENCY_DEDUP_BYTES && dupFraction > EMERGENCY_DEDUP_FRACTION) {
964
- console.log(`${blue(this.name)} ${magenta("emergency-dedup")}: ${fmtBytes(totalBytes)} across ${bulkFiles.length} bulk file(s), ${Math.round(dupFraction * 100)}% duplicate key-slots — folding all at once`);
968
+ if (totalBytes > DEDUP_TRIGGER_BYTES && dupFraction > DEDUP_TRIGGER_FRACTION) {
969
+ console.log(`${blue(this.name)} ${magenta("dedup")}: ${fmtBytes(totalBytes)} across ${bulkFiles.length} bulk file(s), ${Math.round(dupFraction * 100)}% duplicate key-slots — folding all at once`);
965
970
  if (!await runMerge(bulkFiles, [])) return merged;
966
971
  return merged;
967
972
  }
@@ -0,0 +1,15 @@
1
+ import type { FileStorage } from "../FileFolderAPI";
2
+ export type DeleteMarker = {
3
+ fileName: string;
4
+ deleteFiles: string[];
5
+ replacedBy: string[];
6
+ time: number;
7
+ };
8
+ export declare function isMarkerFile(name: string): boolean;
9
+ export declare function writeDeleteMarker(storage: FileStorage, config: {
10
+ deleteFiles: string[];
11
+ replacedBy: string[];
12
+ }): Promise<void>;
13
+ export declare function readDeleteMarkers(storage: FileStorage, allNames: string[]): Promise<DeleteMarker[]>;
14
+ export declare function markerExclusions(markers: DeleteMarker[]): Set<string>;
15
+ export declare function processDeleteMarkers(storage: FileStorage, markers: DeleteMarker[], allNames: string[]): Promise<void>;
@@ -0,0 +1,89 @@
1
+ // Deletion markers: how a merge retires the input files it consumed WITHOUT deleting them inline.
2
+ //
3
+ // The old approach deleted consumed inputs on a 15-min timer. If the tab closed before the timer fired
4
+ // the inputs survived AND were still read — so the next merge re-merged the same duplicates, forever
5
+ // (the "1739 files, 155GB, 99% duplicate" loop). Markers fix both halves: the consumed inputs are
6
+ // EXCLUDED from reads the moment the marker exists (so they're never re-merged), and their physical
7
+ // deletion is driven off the marker on a later read instead of an in-process timer.
8
+ //
9
+ // A marker names the input files it wants gone (`deleteFiles`) and the output files that supersede them
10
+ // (`replacedBy`). On every read we:
11
+ // 1) exclude every `deleteFiles` entry from the active file set (unconditionally — that's the loop fix);
12
+ // 2) delete a marker's `deleteFiles` once its `replacedBy` outputs all exist OR the marker is older than
13
+ // the grace window (a crash-safety floor so a marker whose outputs never landed still gets cleaned);
14
+ // 3) once a marker's `deleteFiles` are all gone, and the marker has aged past the grace window, delete
15
+ // the marker itself (kept around briefly so a concurrent reader still excludes the files consistently).
16
+
17
+ import type { FileStorage } from "../FileFolderAPI";
18
+
19
+ const MARKER_PREFIX = ".delete-marker_";
20
+ const MARKER_GRACE_MS = 5 * 60 * 1000;
21
+
22
+ export type DeleteMarker = {
23
+ fileName: string;
24
+ deleteFiles: string[];
25
+ replacedBy: string[];
26
+ time: number;
27
+ };
28
+
29
+ export function isMarkerFile(name: string): boolean {
30
+ return name.startsWith(MARKER_PREFIX);
31
+ }
32
+
33
+ export async function writeDeleteMarker(storage: FileStorage, config: { deleteFiles: string[]; replacedBy: string[] }): Promise<void> {
34
+ const time = Date.now();
35
+ const name = `${MARKER_PREFIX}${time}_${Math.random().toString(36).slice(2, 10)}`;
36
+ const body = JSON.stringify({ deleteFiles: config.deleteFiles, replacedBy: config.replacedBy, time });
37
+ await storage.set(name, Buffer.from(body, "utf8") as Buffer);
38
+ }
39
+
40
+ export async function readDeleteMarkers(storage: FileStorage, allNames: string[]): Promise<DeleteMarker[]> {
41
+ const markerNames = allNames.filter(isMarkerFile);
42
+ const markers = await Promise.all(markerNames.map(async (fileName): Promise<DeleteMarker | undefined> => {
43
+ try {
44
+ const buf = await storage.get(fileName);
45
+ if (!buf) return undefined;
46
+ const parsed = JSON.parse(buf.toString("utf8")) as { deleteFiles?: unknown; replacedBy?: unknown; time?: unknown };
47
+ if (!Array.isArray(parsed.deleteFiles) || !Array.isArray(parsed.replacedBy)) return undefined;
48
+ return {
49
+ fileName,
50
+ deleteFiles: parsed.deleteFiles as string[],
51
+ replacedBy: parsed.replacedBy as string[],
52
+ time: typeof parsed.time === "number" ? parsed.time : 0,
53
+ };
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }));
58
+ return markers.filter((m): m is DeleteMarker => !!m);
59
+ }
60
+
61
+ // The set of files that any marker wants deleted — these must be dropped from the active read set.
62
+ export function markerExclusions(markers: DeleteMarker[]): Set<string> {
63
+ const excluded = new Set<string>();
64
+ for (const m of markers) for (const n of m.deleteFiles) excluded.add(n);
65
+ return excluded;
66
+ }
67
+
68
+ // Act on each marker (see file header). Idempotent and best-effort: every removal is guarded, so two
69
+ // processes (or two reads) running this at once just race to the same already-correct end state.
70
+ export async function processDeleteMarkers(storage: FileStorage, markers: DeleteMarker[], allNames: string[]): Promise<void> {
71
+ const present = new Set(allNames);
72
+ const now = Date.now();
73
+ for (const marker of markers) {
74
+ const stillPresent = marker.deleteFiles.filter(n => present.has(n));
75
+ if (!stillPresent.length) {
76
+ // The files this marker retired are gone; once it's aged past the grace window, retire the marker too.
77
+ if (now - marker.time > MARKER_GRACE_MS) {
78
+ try { await storage.remove(marker.fileName); } catch { /* already gone */ }
79
+ }
80
+ continue;
81
+ }
82
+ const replacedExists = marker.replacedBy.length > 0 && marker.replacedBy.every(n => present.has(n));
83
+ if (replacedExists || now - marker.time > MARKER_GRACE_MS) {
84
+ for (const n of stillPresent) {
85
+ try { await storage.remove(n); } catch { /* already gone */ }
86
+ }
87
+ }
88
+ }
89
+ }