sliftutils 1.4.66 → 1.4.68

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
@@ -853,6 +853,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
853
853
  isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
854
854
  /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
855
855
  isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
856
+ /**
857
+ * Reactive: true while a merge is rewriting this collection's files (background `maybeMerge` or
858
+ * an explicit `compact`/`merge`/`tryMergeNow`). Becomes false as soon as the new index is swapped
859
+ * in — the deferred-delete cleanup window is NOT counted. Use this in a UI to show a per-database
860
+ * "compacting…" indicator.
861
+ */
862
+ isCompactingSync(): boolean;
856
863
  /**
857
864
  * Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
858
865
  * getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
@@ -926,7 +933,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
926
933
  export declare const BULK_ROOT_FOLDER = "bulkDatabases2";
927
934
  export declare const bulkDatabase2Timing: {
928
935
  streamSealAgeMs: number;
929
- mergeCheckIntervalMs: number;
936
+ visibleMergeIntervalMs: number;
930
937
  mergeSpacingMs: number;
931
938
  firstMergeTriggerFiles: number;
932
939
  firstMergeTriggerRangeMs: number;
@@ -960,6 +967,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
960
967
  private storageFactory;
961
968
  private config;
962
969
  constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
970
+ private setupVisibilityMergeCheck;
963
971
  private reader;
964
972
  private subCaches;
965
973
  private pendingAppends;
@@ -970,7 +978,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
970
978
  private streamFileName;
971
979
  private currentStreamFileName;
972
980
  private currentStreamFileBytes;
973
- private lastMergeCheck;
981
+ private mergeInFlight;
974
982
  private streamRowsOnDisk;
975
983
  private streamBytesOnDisk;
976
984
  private fileSetPollTimer;
@@ -1030,6 +1038,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1030
1038
  private fileLogicalSize;
1031
1039
  private handleUnreadableFile;
1032
1040
  private mergeFileSet;
1041
+ private mergeFileSetInner;
1033
1042
  private canDeleteStream;
1034
1043
  private mergeSpacingDelay;
1035
1044
  private testMerge;
@@ -1059,6 +1068,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1059
1068
  }[] | undefined;
1060
1069
  isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean;
1061
1070
  isColumnLoadedSync<C extends keyof T>(column: C): boolean;
1071
+ isCompactingSync(): boolean;
1062
1072
  getColumnInfo(): Promise<{
1063
1073
  column: string;
1064
1074
  byteSize: number;
@@ -1240,6 +1250,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseMerge" {
1240
1250
  }): Promise<{
1241
1251
  outputs: PlannedMergeOutput[];
1242
1252
  carriedDeletes: Map<string, number>;
1253
+ usedSourceNames: Set<string>;
1243
1254
  }>;
1244
1255
  export {};
1245
1256
 
@@ -1279,6 +1290,10 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseReader" {
1279
1290
  isKeyWatched(key: string): boolean;
1280
1291
  isLiveNow(key: string): boolean;
1281
1292
  localTime(key: string): number;
1293
+ private compactingCount;
1294
+ beginCompaction(): void;
1295
+ endCompaction(): void;
1296
+ isCompactingSync(): boolean;
1282
1297
  private notifyOverlayMutation;
1283
1298
  getKeys(): Promise<string[]>;
1284
1299
  getColumn<C extends keyof T>(column: C): Promise<{
@@ -1514,8 +1529,12 @@ declare module "sliftutils/storage/BulkDatabase2/blockCache" {
1514
1529
  }
1515
1530
 
1516
1531
  declare module "sliftutils/storage/BulkDatabase2/mergeLock" {
1532
+ import type { FileStorage } from "../FileFolderAPI";
1517
1533
  export declare function tryAcquireMergeLock(collection: string, holderId: string): boolean;
1518
1534
  export declare function releaseMergeLock(collection: string, holderId: string): void;
1535
+ export declare function tryAcquireMergeFileLock(storage: FileStorage, holderId: string): Promise<boolean>;
1536
+ export declare function startMergeFileLockHeartbeat(storage: FileStorage, holderId: string): () => void;
1537
+ export declare function releaseMergeFileLock(storage: FileStorage, holderId: string): Promise<void>;
1519
1538
 
1520
1539
  }
1521
1540
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.66",
3
+ "version": "1.4.68",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -88,6 +88,13 @@ export interface IBulkDatabase2<T extends {
88
88
  isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean;
89
89
  /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
90
90
  isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
91
+ /**
92
+ * Reactive: true while a merge is rewriting this collection's files (background `maybeMerge` or
93
+ * an explicit `compact`/`merge`/`tryMergeNow`). Becomes false as soon as the new index is swapped
94
+ * in — the deferred-delete cleanup window is NOT counted. Use this in a UI to show a per-database
95
+ * "compacting…" indicator.
96
+ */
97
+ isCompactingSync(): boolean;
91
98
  /**
92
99
  * Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
93
100
  * getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
@@ -74,6 +74,14 @@ export interface IBulkDatabase2<T extends { key: string }> {
74
74
  /** Reactive: whether a whole column is loaded yet (see isFieldLoadedSync). */
75
75
  isColumnLoadedSync<Column extends keyof T>(column: Column): boolean;
76
76
 
77
+ /**
78
+ * Reactive: true while a merge is rewriting this collection's files (background `maybeMerge` or
79
+ * an explicit `compact`/`merge`/`tryMergeNow`). Becomes false as soon as the new index is swapped
80
+ * in — the deferred-delete cleanup window is NOT counted. Use this in a UI to show a per-database
81
+ * "compacting…" indicator.
82
+ */
83
+ isCompactingSync(): boolean;
84
+
77
85
  /**
78
86
  * Whether a row (key) is currently being watched by some reactive observer (getSingleFieldObjSync /
79
87
  * getSingleFieldSync). Lets callers skip per-row work when nothing's watching. Non-reactive query;
@@ -2,7 +2,7 @@ import type { FileStorage } from "../FileFolderAPI";
2
2
  export declare const BULK_ROOT_FOLDER = "bulkDatabases2";
3
3
  export declare const bulkDatabase2Timing: {
4
4
  streamSealAgeMs: number;
5
- mergeCheckIntervalMs: number;
5
+ visibleMergeIntervalMs: number;
6
6
  mergeSpacingMs: number;
7
7
  firstMergeTriggerFiles: number;
8
8
  firstMergeTriggerRangeMs: number;
@@ -36,6 +36,7 @@ export declare class BulkDatabaseBase<T extends {
36
36
  private storageFactory;
37
37
  private config;
38
38
  constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
39
+ private setupVisibilityMergeCheck;
39
40
  private reader;
40
41
  private subCaches;
41
42
  private pendingAppends;
@@ -46,7 +47,7 @@ export declare class BulkDatabaseBase<T extends {
46
47
  private streamFileName;
47
48
  private currentStreamFileName;
48
49
  private currentStreamFileBytes;
49
- private lastMergeCheck;
50
+ private mergeInFlight;
50
51
  private streamRowsOnDisk;
51
52
  private streamBytesOnDisk;
52
53
  private fileSetPollTimer;
@@ -106,6 +107,7 @@ export declare class BulkDatabaseBase<T extends {
106
107
  private fileLogicalSize;
107
108
  private handleUnreadableFile;
108
109
  private mergeFileSet;
110
+ private mergeFileSetInner;
109
111
  private canDeleteStream;
110
112
  private mergeSpacingDelay;
111
113
  private testMerge;
@@ -135,6 +137,7 @@ export declare class BulkDatabaseBase<T extends {
135
137
  }[] | undefined;
136
138
  isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean;
137
139
  isColumnLoadedSync<C extends keyof T>(column: C): boolean;
140
+ isCompactingSync(): boolean;
138
141
  getColumnInfo(): Promise<{
139
142
  column: string;
140
143
  byteSize: number;
@@ -1,4 +1,5 @@
1
1
  import { sort } from "socket-function/src/misc";
2
+ import { runInSerial } from "socket-function/src/batching";
2
3
  import { getTimeUnique } from "socket-function/src/bits";
3
4
  import { lazy } from "socket-function/src/caching";
4
5
  import { isNode } from "typesafecss";
@@ -17,7 +18,7 @@ import { blue, magenta } from "socket-function/src/formatting/logColors";
17
18
  import { STREAM_EXTENSION, frameDeletes, frameRows, streamReaderFromEntries } from "./streamLog";
18
19
  import { broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, connect as syncConnect, isSyncSupported, RemoteWrite } from "./syncClient";
19
20
  import { DELETED } from "./WriteOverlay";
20
- import { releaseMergeLock, tryAcquireMergeLock } from "./mergeLock";
21
+ import { releaseMergeFileLock, releaseMergeLock, startMergeFileLockHeartbeat, tryAcquireMergeFileLock, tryAcquireMergeLock } from "./mergeLock";
21
22
  import {
22
23
  BulkFileInfo,
23
24
  LoadedIndex,
@@ -41,11 +42,19 @@ const MAX_INDEX_RELOAD_ATTEMPTS = 3;
41
42
  const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
42
43
  const KEY_GROUP_BYTES = 800 * 1024 * 1024;
43
44
  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;
44
50
  const WRITE_FLUSH_FIRST_STEP_MS = 250;
45
51
 
46
52
  export const bulkDatabase2Timing = {
47
53
  streamSealAgeMs: 10 * 60 * 60 * 1000,
48
- mergeCheckIntervalMs: 30 * 60 * 1000,
54
+ // Wait this long after the tab becomes visible before the first merge check, then check this often
55
+ // afterwards. Tab being hidden cancels the timers (no merges while in background). Browser-only;
56
+ // Node compactors (e.g. remoteFileServer) drive their own polling.
57
+ visibleMergeIntervalMs: 5 * 60 * 1000,
49
58
  mergeSpacingMs: 5 * 60 * 1000,
50
59
  firstMergeTriggerFiles: 20,
51
60
  firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
@@ -164,10 +173,43 @@ export class BulkDatabaseBase<T extends { key: string }> {
164
173
  }
165
174
  this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
166
175
  (this.fileSetPollTimer as { unref?: () => void }).unref?.();
176
+ this.setupVisibilityMergeCheck();
167
177
  BulkDatabaseBase.liveInstances.add(this);
168
178
  BulkDatabaseBase.startMemoryWatchdog();
169
179
  }
170
180
 
181
+ // Every `visibleMergeIntervalMs` of being-visible time, run a merge check. First check fires
182
+ // `visibleMergeIntervalMs` after the tab becomes visible (not on initial load — gives the user a
183
+ // moment of session commitment before we start churning the FS), then every interval after. Tab
184
+ // hiding cancels the timers; visible-again restarts. Node has no document — there we consider
185
+ // ourselves always-visible and just install the interval directly.
186
+ private setupVisibilityMergeCheck(): void {
187
+ let firstTimer: ReturnType<typeof setTimeout> | undefined;
188
+ let intervalTimer: ReturnType<typeof setInterval> | undefined;
189
+ const stop = () => {
190
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = undefined; }
191
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = undefined; }
192
+ };
193
+ const start = () => {
194
+ if (firstTimer || intervalTimer) return;
195
+ firstTimer = setTimeout(() => {
196
+ firstTimer = undefined;
197
+ void this.maybeMerge();
198
+ intervalTimer = setInterval(() => void this.maybeMerge(), bulkDatabase2Timing.visibleMergeIntervalMs);
199
+ (intervalTimer as { unref?: () => void }).unref?.();
200
+ }, bulkDatabase2Timing.visibleMergeIntervalMs);
201
+ (firstTimer as { unref?: () => void }).unref?.();
202
+ };
203
+ if (typeof document === "undefined") { start(); return; }
204
+ try {
205
+ document.addEventListener("visibilitychange", () => {
206
+ if (document.visibilityState === "visible") start();
207
+ else stop();
208
+ });
209
+ if (document.visibilityState === "visible") start();
210
+ } catch { /* not in a DOM context */ }
211
+ }
212
+
171
213
  private reader: BulkDatabaseReader<T>;
172
214
  private subCaches: SubReaderCaches = { bulk: new Map(), stream: new Map() };
173
215
 
@@ -180,7 +222,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
180
222
  private streamFileName: string | undefined;
181
223
  private currentStreamFileName: string | undefined;
182
224
  private currentStreamFileBytes = 0;
183
- private lastMergeCheck = Date.now();
225
+ // In-process re-entry guard: if a merge is running, additional triggers (visibility timer, writes,
226
+ // tryMergeNow calls) return immediately instead of queueing. Keeps the visibility-timer firings
227
+ // from stacking behind a long merge.
228
+ private mergeInFlight = false;
184
229
  // Running counters of stream-tier rows + bytes on disk. Seeded from each LoadedIndex build, then
185
230
  // incremented per flush so the fold-trigger checks current data without an extra directory listing.
186
231
  private streamRowsOnDisk = 0;
@@ -380,7 +425,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
380
425
  });
381
426
  for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
382
427
  await this.streamAppend(framed, stamped.length);
383
- void this.maybeMerge();
428
+ void this.maybeMerge({ onlyIfStreamHeavy: true });
384
429
  }
385
430
 
386
431
  public async delete(key: string): Promise<void> {
@@ -396,7 +441,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
396
441
  });
397
442
  for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
398
443
  await this.streamAppend(frameDeletes(stamped), stamped.length);
399
- void this.maybeMerge();
444
+ void this.maybeMerge({ onlyIfStreamHeavy: true });
400
445
  }
401
446
 
402
447
  // Coalesce stream appends on a ramping per-collection schedule (the browser rewrites the whole
@@ -533,11 +578,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
533
578
  }
534
579
 
535
580
  // ── merge policy ─────────────────────────────────────────────────────────────────────────────────
536
- private async maybeMerge(): Promise<void> {
581
+ // Called both periodically (visibility timer) and on writes (with the streamNeedsFold gate so a
582
+ // small write doesn't drag the merge through). The mergeInFlight check in tryMergeNow skips any
583
+ // call that arrives while a previous one is still running — so 5-min timer ticks don't pile up.
584
+ private async maybeMerge(opts: { onlyIfStreamHeavy?: boolean } = {}): Promise<void> {
537
585
  if (!await this.automaticCompactionAllowed()) return;
538
- const now = Date.now();
539
- if (!this.streamNeedsFold() && now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
540
- this.lastMergeCheck = now;
586
+ if (opts.onlyIfStreamHeavy && !this.streamNeedsFold()) return;
541
587
  try {
542
588
  await this.tryMergeNow();
543
589
  } catch (e) {
@@ -545,17 +591,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
545
591
  }
546
592
  }
547
593
 
594
+ // Returns immediately with { lockFailed: true } if another call is already in flight (same
595
+ // instance), if the localStorage lock is held by another tab, or if the cross-process file lock
596
+ // says another process owns it. Otherwise: acquires both locks, runs testMerge, releases.
548
597
  public async tryMergeNow(): Promise<{ merged: boolean; lockFailed: boolean }> {
598
+ if (this.mergeInFlight) return { merged: false, lockFailed: true };
549
599
  if (!tryAcquireMergeLock(this.name, writerId)) return { merged: false, lockFailed: true };
600
+ const storage = await this.storage();
601
+ const haveFileLock = await tryAcquireMergeFileLock(storage, writerId);
602
+ if (!haveFileLock) {
603
+ releaseMergeLock(this.name, writerId);
604
+ return { merged: false, lockFailed: true };
605
+ }
606
+ this.mergeInFlight = true;
607
+ const stopHeartbeat = startMergeFileLockHeartbeat(storage, writerId);
550
608
  try {
551
609
  return { merged: await this.testMerge(), lockFailed: false };
552
610
  } finally {
611
+ stopHeartbeat();
612
+ await releaseMergeFileLock(storage, writerId);
553
613
  releaseMergeLock(this.name, writerId);
614
+ this.mergeInFlight = false;
554
615
  }
555
616
  }
556
617
 
557
618
  public async compact(): Promise<void> {
619
+ if (this.mergeInFlight) return;
558
620
  if (!tryAcquireMergeLock(this.name, writerId)) return;
621
+ const storage = await this.storage();
622
+ const haveFileLock = await tryAcquireMergeFileLock(storage, writerId);
623
+ if (!haveFileLock) { releaseMergeLock(this.name, writerId); return; }
624
+ this.mergeInFlight = true;
625
+ const stopHeartbeat = startMergeFileLockHeartbeat(storage, writerId);
559
626
  try {
560
627
  await this.flushPending();
561
628
  syncBroadcastSeal(this.name);
@@ -565,7 +632,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
565
632
  // can be dropped (nothing left to suppress).
566
633
  if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
567
634
  } finally {
635
+ stopHeartbeat();
636
+ await releaseMergeFileLock(storage, writerId);
568
637
  releaseMergeLock(this.name, writerId);
638
+ this.mergeInFlight = false;
569
639
  }
570
640
  }
571
641
 
@@ -631,10 +701,40 @@ export class BulkDatabaseBase<T extends { key: string }> {
631
701
  // leaves duplicates (next merge dedupes) rather than a gap. After the file set changes on disk,
632
702
  // we trigger an index rebuild + atomic swap; once swap completes, the consumed files' block-cache
633
703
  // entries are evicted (no consumer can ask for them now).
634
- private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> {
704
+ //
705
+ // Serialized: foldOwnStream + merge(timeLo, timeHi) + testMerge's runMerge all hit this; the lock
706
+ // covers tryMergeNow/compact but those two paths bypass it. runInSerial queues so they never
707
+ // collide on the file set.
708
+ private mergeFileSet = runInSerial(async (bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> => {
709
+ this.reader.beginCompaction();
710
+ try {
711
+ return await this.mergeFileSetInner(bulkFiles, streamFiles, includesOldest, forceDeleteStreams);
712
+ } finally {
713
+ this.reader.endCompaction();
714
+ }
715
+ });
716
+
717
+ private async mergeFileSetInner(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest: boolean, forceDeleteStreams: boolean): Promise<boolean> {
635
718
  const storage = await this.storage();
636
719
  const timestamp = nextFileTime();
637
720
 
721
+ // The caller's bulkFiles came from a `listFiles()` snapshot. Between then and now any file
722
+ // could be gone (another tab's deferred delete, manual cleanup). The subCaches reader for a
723
+ // deleted file is silently stale — its in-memory metadata still resolves, but the very next
724
+ // getRange against disk throws. Re-verify existence up-front and drop the cache entry for
725
+ // anything that vanished, so we don't even try to plan around a ghost file.
726
+ const verifiedBulkFiles = (await Promise.all(bulkFiles.map(async f => {
727
+ try {
728
+ const info = await storage.getInfo(f.fileName);
729
+ if (!info) { this.subCaches.bulk.delete(f.fileName); return undefined; }
730
+ return f;
731
+ } catch {
732
+ this.subCaches.bulk.delete(f.fileName);
733
+ return undefined;
734
+ }
735
+ }))).filter((f): f is BulkFileInfo => f !== undefined);
736
+ bulkFiles = verifiedBulkFiles;
737
+
638
738
  const consumedBulk: BulkFileInfo[] = [];
639
739
  const bulkReaders: BaseBulkDatabaseReader[] = [];
640
740
  await Promise.all(bulkFiles.map(async f => {
@@ -712,6 +812,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
712
812
  // correct). Block-cache eviction for inputs happens on the SECOND rebuild after deletion.
713
813
  await this.triggerRebuild();
714
814
 
815
+ // Only the sources runPlannedMerge actually used are safe to delete — a source it dropped
816
+ // mid-plan (file gone, corruption) still holds data we couldn't merge in, so leave it on disk
817
+ // and let a future merge re-attempt it.
818
+ const usedConsumedBulk = consumedBulk.filter(f => mergeResult.usedSourceNames.has(f.fileName));
819
+ const usedStreamFiles = streamFiles.filter(f => mergeResult.usedSourceNames.has(f.fileName) || mergeResult.usedSourceNames.has("(streams)"));
820
+
715
821
  // Deferred delete: give the FS time to durably flush new writes before removing the inputs.
716
822
  // If we crash before the timer fires, the inputs survive as duplicates and a later key-stratify
717
823
  // pass dedupes them. We DON'T await this — the merge returns "done" as soon as the new outputs
@@ -720,8 +826,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
720
826
  const timer = setTimeout(() => {
721
827
  void (async () => {
722
828
  try {
723
- for (const f of consumedBulk) await remove(f.fileName);
724
- for (const f of streamFiles) {
829
+ for (const f of usedConsumedBulk) await remove(f.fileName);
830
+ for (const f of usedStreamFiles) {
725
831
  if (await this.canDeleteStream(f, Date.now(), streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
726
832
  }
727
833
  await this.triggerRebuild();
@@ -832,6 +938,33 @@ export class BulkDatabaseBase<T extends { key: string }> {
832
938
  }
833
939
  }
834
940
 
941
+ // Pass 1.5 (emergency dedup): Pass 1 has had its chance. If the bulk tier is fat AND mostly
942
+ // duplicates, the per-group walk below (5 min spacing per group) takes hours for an extreme
943
+ // state (1000+ files, 99% dup) — fold every bulk file in one merge and skip the walk.
944
+ {
945
+ const { bulkFiles } = await this.listFiles();
946
+ if (bulkFiles.length >= 2) {
947
+ const storage = await this.storage();
948
+ let totalBytes = 0;
949
+ let totalSlots = 0;
950
+ const uniqueKeys = new Set<string>();
951
+ for (const f of bulkFiles) {
952
+ try {
953
+ const reader = await loadFileReader(this.name, storage, f, this.subCaches.bulk);
954
+ totalBytes += reader.totalBytes;
955
+ totalSlots += reader.keys.length;
956
+ for (const k of reader.keys) uniqueKeys.add(k);
957
+ } catch { /* skip unreadable */ }
958
+ }
959
+ const dupFraction = totalSlots ? (totalSlots - uniqueKeys.size) / totalSlots : 0;
960
+ if (totalBytes > EMERGENCY_DEDUP_BYTES && dupFraction > EMERGENCY_DEDUP_FRACTION) {
961
+ 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`);
962
+ if (!await runMerge(bulkFiles, [])) return merged;
963
+ return merged;
964
+ }
965
+ }
966
+ }
967
+
835
968
  // Pass 2: key-stratified deduplication. Disjoint key ranges → one group's merge doesn't
836
969
  // change another's duplication; re-select each group's files at merge time (set shifts).
837
970
  const groups = await this.findDuplicateGroups();
@@ -932,6 +1065,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
932
1065
  return this.reader.isColumnLoadedSync(column);
933
1066
  }
934
1067
 
1068
+ // Reactive: true while a merge is rewriting this collection's files. Use this in UI to show a
1069
+ // "compacting…" indicator. Counts both background merges (maybeMerge) and explicit compact/merge
1070
+ // calls. Becomes false once the new index is swapped in (the deferred-delete window is NOT counted).
1071
+ public isCompactingSync(): boolean {
1072
+ return this.reader.isCompactingSync();
1073
+ }
1074
+
935
1075
  public async getColumnInfo() {
936
1076
  const index = await this.ensureIndex();
937
1077
  return index.reader.columns;
@@ -46,5 +46,6 @@ export declare function runPlannedMerge(config: {
46
46
  }): Promise<{
47
47
  outputs: PlannedMergeOutput[];
48
48
  carriedDeletes: Map<string, number>;
49
+ usedSourceNames: Set<string>;
49
50
  }>;
50
51
  export {};
@@ -92,16 +92,42 @@ export async function runPlannedMerge(config: {
92
92
  // console.log so standalone callers still get identifiable output.
93
93
  log?: (line: string) => void;
94
94
  writeFile: (data: Buffer) => Promise<{ name: string; size: number }>;
95
- }): Promise<{ outputs: PlannedMergeOutput[]; carriedDeletes: Map<string, number> }> {
95
+ }): Promise<{ outputs: PlannedMergeOutput[]; carriedDeletes: Map<string, number>; usedSourceNames: Set<string> }> {
96
96
  const targetFileBytes = config.targetFileBytes ?? TARGET_FILE_BYTES;
97
97
  const targetBatchBytes = config.targetBatchBytes ?? DEFAULT_OUTPUT_BATCH_BYTES;
98
98
  const log = config.log ?? (line => console.log(`${blue(config.collectionName)} ${line}`));
99
99
 
100
100
  // ─────────────────────────────────────────── Phase 1: plan ───────────────────────────────────────────
101
- // Aggregate keyTimes + deleteTimes across all sources (max per key).
101
+ // Load per-source column indexes FIRST so a file that's gone (deleted between caller's listFiles and
102
+ // now) gets caught here and excluded from the merge entirely. Each source's load is wrapped in a
103
+ // try/catch; on failure we mark the source bad, log a "skipped" line, and the rest of planning +
104
+ // execute pretends the source doesn't exist. The caller only deletes inputs that were actually used.
105
+ const sourceOk = new Array<boolean>(config.sources.length).fill(true);
106
+ const indexesPerSource: Map<string, ColumnIndex>[] = await Promise.all(config.sources.map(async (src, si) => {
107
+ const map = new Map<string, ColumnIndex>();
108
+ try {
109
+ await Promise.all(src.columns
110
+ .filter(c => c.column !== KEY_COLUMN)
111
+ .map(async c => { map.set(c.column, await src.getColumnIndex(c.column)); }));
112
+ } catch (e) {
113
+ sourceOk[si] = false;
114
+ log(`${magenta("skipped")} ${config.sourceNames[si]}: ${(e as Error).message}`);
115
+ }
116
+ return map;
117
+ }));
118
+ const usedSourceNames = new Set<string>();
119
+ for (let si = 0; si < config.sources.length; si++) {
120
+ if (sourceOk[si]) usedSourceNames.add(config.sourceNames[si]);
121
+ }
122
+
123
+ // Aggregate keyTimes + deleteTimes across GOOD sources only (max per key). A bad source's cached
124
+ // metadata might claim a key as newest, but we can't actually read its cells — letting it win the
125
+ // time resolution would just drop that key out of the output.
102
126
  const deleteTime = new Map<string, number>();
103
127
  const keyTime = new Map<string, number>();
104
- for (const src of config.sources) {
128
+ for (let si = 0; si < config.sources.length; si++) {
129
+ if (!sourceOk[si]) continue;
130
+ const src = config.sources[si];
105
131
  for (const [k, t] of src.keyTimes) {
106
132
  const prev = keyTime.get(k);
107
133
  if (prev === undefined || t > prev) keyTime.set(k, t);
@@ -127,28 +153,19 @@ export async function runPlannedMerge(config: {
127
153
  if (dT >= sT) carriedDeletes.set(k, dT);
128
154
  }
129
155
 
130
- // All distinct value columns (KEY_COLUMN excluded — it's added at file-assembly time). Order: first-
131
- // seen across sources, matching the existing builders.
156
+ // All distinct value columns from GOOD sources (KEY_COLUMN excluded — it's added at file-assembly
157
+ // time). Order: first-seen, matching the existing builders.
132
158
  const allColumns: string[] = [];
133
159
  const seenCols = new Set<string>();
134
- for (const src of config.sources) {
135
- for (const c of src.columns) {
160
+ for (let si = 0; si < config.sources.length; si++) {
161
+ if (!sourceOk[si]) continue;
162
+ for (const c of config.sources[si].columns) {
136
163
  if (c.column === KEY_COLUMN || seenCols.has(c.column)) continue;
137
164
  seenCols.add(c.column);
138
165
  allColumns.push(c.column);
139
166
  }
140
167
  }
141
168
 
142
- // Load every (source, column) index in parallel — small data (offsets+types), no values pulled. Each
143
- // index is kept for the executor too, so it can read contiguous row-ranges from the right source.
144
- const indexesPerSource: Map<string, ColumnIndex>[] = await Promise.all(config.sources.map(async src => {
145
- const map = new Map<string, ColumnIndex>();
146
- await Promise.all(allColumns.map(async col => {
147
- map.set(col, await src.getColumnIndex(col));
148
- }));
149
- return map;
150
- }));
151
-
152
169
  // For each live key, for each column, find the winning source: among sources that have this key, the
153
170
  // one whose keyTime is largest AND whose column-index reports non-ABSENT. Record source + sourceRow +
154
171
  // byteLen + type so the planner can size offsets and the executor can copy the bytes.
@@ -161,6 +178,7 @@ export async function runPlannedMerge(config: {
161
178
  let bestTime = -Infinity;
162
179
  let best: CellChoice | undefined;
163
180
  for (let si = 0; si < config.sources.length; si++) {
181
+ if (!sourceOk[si]) continue;
164
182
  const src = config.sources[si];
165
183
  const t = src.keyTimes.get(key);
166
184
  if (t === undefined) continue;
@@ -251,7 +269,7 @@ export async function runPlannedMerge(config: {
251
269
  const writtenBytes = outputs.reduce((a, o) => a + o.size, 0);
252
270
  log(`${magenta("execute")}: ${outputs.length} file(s), ${fmtBytes(writtenBytes)} written`);
253
271
 
254
- return { outputs, carriedDeletes };
272
+ return { outputs, carriedDeletes, usedSourceNames };
255
273
  }
256
274
 
257
275
  function buildOutputPlan(
@@ -31,6 +31,10 @@ export declare class BulkDatabaseReader<T extends {
31
31
  isKeyWatched(key: string): boolean;
32
32
  isLiveNow(key: string): boolean;
33
33
  localTime(key: string): number;
34
+ private compactingCount;
35
+ beginCompaction(): void;
36
+ endCompaction(): void;
37
+ isCompactingSync(): boolean;
34
38
  private notifyOverlayMutation;
35
39
  getKeys(): Promise<string[]>;
36
40
  getColumn<C extends keyof T>(column: C): Promise<{
@@ -7,6 +7,7 @@ import { blue, red } from "socket-function/src/formatting/logColors";
7
7
  const NULL = String.fromCharCode(0);
8
8
  const LOAD_SIGNAL = NULL + "load";
9
9
  const OVERLAY_SIGNAL = NULL + "overlay";
10
+ const COMPACTING_SIGNAL = NULL + "compacting";
10
11
  const TRIGGER_THROTTLE_FIRST_STEP_MS = 16;
11
12
 
12
13
  function nullJoin(a: string, b: string): string {
@@ -88,6 +89,22 @@ export class BulkDatabaseReader<T extends { key: string }> {
88
89
  return -Infinity;
89
90
  }
90
91
 
92
+ // Counter (so concurrent / nested merges in the same instance compose correctly). Signal fires
93
+ // immediately, not through the trigger throttle — UI spinners should show right away.
94
+ private compactingCount = 0;
95
+ beginCompaction(): void {
96
+ this.compactingCount++;
97
+ if (this.compactingCount === 1) this.cfg.deps.invalidate(COMPACTING_SIGNAL);
98
+ }
99
+ endCompaction(): void {
100
+ this.compactingCount--;
101
+ if (this.compactingCount === 0) this.cfg.deps.invalidate(COMPACTING_SIGNAL);
102
+ }
103
+ isCompactingSync(): boolean {
104
+ this.cfg.deps.observe(COMPACTING_SIGNAL);
105
+ return this.compactingCount > 0;
106
+ }
107
+
91
108
  private notifyOverlayMutation(key: string, columns: Iterable<string> | "all"): void {
92
109
  this.dataGen++;
93
110
  if (columns === "all") this.columnCache.clear();
@@ -1,2 +1,6 @@
1
+ import type { FileStorage } from "../FileFolderAPI";
1
2
  export declare function tryAcquireMergeLock(collection: string, holderId: string): boolean;
2
3
  export declare function releaseMergeLock(collection: string, holderId: string): void;
4
+ export declare function tryAcquireMergeFileLock(storage: FileStorage, holderId: string): Promise<boolean>;
5
+ export declare function startMergeFileLockHeartbeat(storage: FileStorage, holderId: string): () => void;
6
+ export declare function releaseMergeFileLock(storage: FileStorage, holderId: string): Promise<void>;
@@ -6,6 +6,14 @@
6
6
  // localStorage is unavailable (Node) — there concurrent merges are harmless, which the Node stress
7
7
  // tests exercise. localStorage has no atomic compare-and-swap, so we write-then-reread to shrink the
8
8
  // race window, and a TTL frees a lock left behind by a tab that crashed or closed mid-merge.
9
+ //
10
+ // File-lock layer (see acquireMergeFileLock at the bottom): for cross-PROCESS safety (different
11
+ // browsers, different machines pointing at the same remote storage), we ALSO write a `.merge-lock`
12
+ // file inside the collection's storage with our ID. The protocol is check-write-wait-recheck — last
13
+ // writer wins after the 15s settle window. The localStorage lock above covers same-origin tabs; this
14
+ // covers everything else (separate processes, remote shared storage).
15
+
16
+ import type { FileStorage } from "../FileFolderAPI";
9
17
 
10
18
  const LOCK_TTL_MS = 30 * 1000;
11
19
 
@@ -48,3 +56,57 @@ export function releaseMergeLock(collection: string, holderId: string): void {
48
56
  const existing = ls.getItem(key);
49
57
  if (existing && existing.startsWith(holderId + ":")) ls.removeItem(key);
50
58
  }
59
+
60
+ // ── File-based lock (cross-process / shared storage) ──────────────────────────────────────────────
61
+ const FILE_LOCK_KEY = ".merge-lock";
62
+ const FILE_LOCK_TTL_MS = 5 * 60 * 1000;
63
+ const FILE_LOCK_SETTLE_MS = 15 * 1000;
64
+ const FILE_LOCK_HEARTBEAT_MS = 2 * 60 * 1000;
65
+
66
+ async function readMergeFileLock(storage: FileStorage): Promise<{ id: string; time: number } | undefined> {
67
+ try {
68
+ const buf = await storage.get(FILE_LOCK_KEY);
69
+ if (!buf) return undefined;
70
+ const s = buf.toString("utf8");
71
+ const colon = s.lastIndexOf(":");
72
+ if (colon < 0) return undefined;
73
+ const time = parseInt(s.slice(colon + 1), 10);
74
+ if (!Number.isFinite(time)) return undefined;
75
+ return { id: s.slice(0, colon), time };
76
+ } catch { return undefined; }
77
+ }
78
+
79
+ async function writeMergeFileLock(storage: FileStorage, holderId: string, time: number): Promise<void> {
80
+ await storage.set(FILE_LOCK_KEY, Buffer.from(`${holderId}:${time}`, "utf8") as Buffer);
81
+ }
82
+
83
+ // Acquire the cross-process file lock. Resolves to true ONLY if, after writing our ID and waiting
84
+ // FILE_LOCK_SETTLE_MS for concurrent writers to settle, the file still names us. Caller must
85
+ // releaseMergeFileLock() on the same storage + holderId when done.
86
+ export async function tryAcquireMergeFileLock(storage: FileStorage, holderId: string): Promise<boolean> {
87
+ const now = Date.now();
88
+ const existing = await readMergeFileLock(storage);
89
+ if (existing && existing.id !== holderId && now - existing.time < FILE_LOCK_TTL_MS) return false;
90
+ await writeMergeFileLock(storage, holderId, now);
91
+ await new Promise(r => setTimeout(r, FILE_LOCK_SETTLE_MS));
92
+ const after = await readMergeFileLock(storage);
93
+ return !!after && after.id === holderId;
94
+ }
95
+
96
+ // Keep the lock fresh. The merge might run longer than FILE_LOCK_TTL_MS; without periodic re-stamping,
97
+ // another process would see the lock as stale and acquire it. Returns a stop function the caller calls
98
+ // in `finally`. Heartbeat is best-effort: a failed write is logged but not propagated.
99
+ export function startMergeFileLockHeartbeat(storage: FileStorage, holderId: string): () => void {
100
+ const interval = setInterval(() => {
101
+ void writeMergeFileLock(storage, holderId, Date.now()).catch(() => { /* best-effort */ });
102
+ }, FILE_LOCK_HEARTBEAT_MS);
103
+ (interval as { unref?: () => void }).unref?.();
104
+ return () => clearInterval(interval);
105
+ }
106
+
107
+ export async function releaseMergeFileLock(storage: FileStorage, holderId: string): Promise<void> {
108
+ try {
109
+ const existing = await readMergeFileLock(storage);
110
+ if (existing && existing.id === holderId) await storage.remove(FILE_LOCK_KEY);
111
+ } catch { /* ignore */ }
112
+ }