sliftutils 1.4.65 → 1.4.67

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;
@@ -936,6 +943,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
936
943
  streamFoldHardLimitBytes: number;
937
944
  writeFlushMaxDelayMs: number;
938
945
  fileSetPollIntervalMs: number;
946
+ deleteDeferMs: number;
939
947
  memoryFlushHeapBytes: number;
940
948
  memoryFlushMinCollectionBytes: number;
941
949
  memoryFlushThrottleMs: number;
@@ -1029,6 +1037,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1029
1037
  private fileLogicalSize;
1030
1038
  private handleUnreadableFile;
1031
1039
  private mergeFileSet;
1040
+ private mergeFileSetInner;
1032
1041
  private canDeleteStream;
1033
1042
  private mergeSpacingDelay;
1034
1043
  private testMerge;
@@ -1058,6 +1067,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1058
1067
  }[] | undefined;
1059
1068
  isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean;
1060
1069
  isColumnLoadedSync<C extends keyof T>(column: C): boolean;
1070
+ isCompactingSync(): boolean;
1061
1071
  getColumnInfo(): Promise<{
1062
1072
  column: string;
1063
1073
  byteSize: number;
@@ -1278,6 +1288,10 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseReader" {
1278
1288
  isKeyWatched(key: string): boolean;
1279
1289
  isLiveNow(key: string): boolean;
1280
1290
  localTime(key: string): number;
1291
+ private compactingCount;
1292
+ beginCompaction(): void;
1293
+ endCompaction(): void;
1294
+ isCompactingSync(): boolean;
1281
1295
  private notifyOverlayMutation;
1282
1296
  getKeys(): Promise<string[]>;
1283
1297
  getColumn<C extends keyof T>(column: C): Promise<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.65",
3
+ "version": "1.4.67",
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;
@@ -12,6 +12,7 @@ export declare const bulkDatabase2Timing: {
12
12
  streamFoldHardLimitBytes: number;
13
13
  writeFlushMaxDelayMs: number;
14
14
  fileSetPollIntervalMs: number;
15
+ deleteDeferMs: number;
15
16
  memoryFlushHeapBytes: number;
16
17
  memoryFlushMinCollectionBytes: number;
17
18
  memoryFlushThrottleMs: number;
@@ -105,6 +106,7 @@ export declare class BulkDatabaseBase<T extends {
105
106
  private fileLogicalSize;
106
107
  private handleUnreadableFile;
107
108
  private mergeFileSet;
109
+ private mergeFileSetInner;
108
110
  private canDeleteStream;
109
111
  private mergeSpacingDelay;
110
112
  private testMerge;
@@ -134,6 +136,7 @@ export declare class BulkDatabaseBase<T extends {
134
136
  }[] | undefined;
135
137
  isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean;
136
138
  isColumnLoadedSync<C extends keyof T>(column: C): boolean;
139
+ isCompactingSync(): boolean;
137
140
  getColumnInfo(): Promise<{
138
141
  column: string;
139
142
  byteSize: number;
@@ -57,6 +57,12 @@ export const bulkDatabase2Timing = {
57
57
  // the whole stream file per write.
58
58
  writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
59
59
  fileSetPollIntervalMs: 30 * 60 * 1000,
60
+ // Defer deletion of a merge's consumed inputs by this long. The OS may not durably flush the new
61
+ // outputs immediately, so if we deleted the inputs and crashed before the writes settled we'd lose
62
+ // data. Within the window the inputs sit as duplicates of the outputs; if we never crash, the timer
63
+ // fires and removes them. If we DO crash before the timer, the duplicates stay until the next
64
+ // key-stratify pass (high duplicate-key fraction → it merges them again, dedup completes).
65
+ deleteDeferMs: 15 * 60 * 1000,
60
66
  memoryFlushHeapBytes: 1500 * 1024 * 1024,
61
67
  memoryFlushMinCollectionBytes: 100 * 1024 * 1024,
62
68
  memoryFlushThrottleMs: 15 * 60 * 1000,
@@ -626,9 +632,17 @@ export class BulkDatabaseBase<T extends { key: string }> {
626
632
  // we trigger an index rebuild + atomic swap; once swap completes, the consumed files' block-cache
627
633
  // entries are evicted (no consumer can ask for them now).
628
634
  private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> {
635
+ this.reader.beginCompaction();
636
+ try {
637
+ return await this.mergeFileSetInner(bulkFiles, streamFiles, includesOldest, forceDeleteStreams);
638
+ } finally {
639
+ this.reader.endCompaction();
640
+ }
641
+ }
642
+
643
+ private async mergeFileSetInner(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest: boolean, forceDeleteStreams: boolean): Promise<boolean> {
629
644
  const storage = await this.storage();
630
645
  const timestamp = nextFileTime();
631
- const now = Date.now();
632
646
 
633
647
  const consumedBulk: BulkFileInfo[] = [];
634
648
  const bulkReaders: BaseBulkDatabaseReader[] = [];
@@ -702,15 +716,30 @@ export class BulkDatabaseBase<T extends { key: string }> {
702
716
  for (const line of steps) console.log(line);
703
717
  console.groupEnd();
704
718
 
705
- const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
706
- for (const f of consumedBulk) await remove(f.fileName);
707
- for (const f of streamFiles) {
708
- if (await this.canDeleteStream(f, now, streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
709
- }
710
-
711
- // File set changed — rebuild + swap. After the swap, consumed files' block-cache entries are
712
- // evicted (no reader will request them now).
719
+ // Rebuild + swap NOW so the index sees the new outputs; the old inputs are still on disk until
720
+ // the deferred-delete timer fires (the index briefly serves both, resolved by time so reads are
721
+ // correct). Block-cache eviction for inputs happens on the SECOND rebuild after deletion.
713
722
  await this.triggerRebuild();
723
+
724
+ // Deferred delete: give the FS time to durably flush new writes before removing the inputs.
725
+ // If we crash before the timer fires, the inputs survive as duplicates and a later key-stratify
726
+ // pass dedupes them. We DON'T await this — the merge returns "done" as soon as the new outputs
727
+ // are written and the index is swapped in.
728
+ const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
729
+ const timer = setTimeout(() => {
730
+ void (async () => {
731
+ try {
732
+ for (const f of consumedBulk) await remove(f.fileName);
733
+ for (const f of streamFiles) {
734
+ if (await this.canDeleteStream(f, Date.now(), streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
735
+ }
736
+ await this.triggerRebuild();
737
+ } catch (e) {
738
+ console.warn(`${this.name}: deferred delete of consumed merge inputs failed: ${(e as Error).message}`);
739
+ }
740
+ })();
741
+ }, bulkDatabase2Timing.deleteDeferMs);
742
+ (timer as { unref?: () => void }).unref?.();
714
743
  return newNames.length > 0 || carriedDeletes > 0;
715
744
  }
716
745
 
@@ -912,6 +941,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
912
941
  return this.reader.isColumnLoadedSync(column);
913
942
  }
914
943
 
944
+ // Reactive: true while a merge is rewriting this collection's files. Use this in UI to show a
945
+ // "compacting…" indicator. Counts both background merges (maybeMerge) and explicit compact/merge
946
+ // calls. Becomes false once the new index is swapped in (the deferred-delete window is NOT counted).
947
+ public isCompactingSync(): boolean {
948
+ return this.reader.isCompactingSync();
949
+ }
950
+
915
951
  public async getColumnInfo() {
916
952
  const index = await this.ensureIndex();
917
953
  return index.reader.columns;
@@ -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();