sliftutils 1.4.70 → 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
|
@@ -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,
|
|
@@ -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
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
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
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|
|
@@ -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
|
+
}
|