sliftutils 1.4.19 → 1.4.20

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
@@ -912,6 +912,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
912
912
  streamFoldTriggerRows: number;
913
913
  streamFoldTriggerBytes: number;
914
914
  writeFlushMaxDelayMs: number;
915
+ fileSetPollIntervalMs: number;
915
916
  };
916
917
  export interface ReactiveDeps {
917
918
  observe(signal: string): void;
@@ -950,6 +951,9 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
950
951
  private streamTimes;
951
952
  private columnCache;
952
953
  private readerKeys;
954
+ private loadedFileSet;
955
+ private readerEpoch;
956
+ private fileSetPollTimer;
953
957
  private dataGen;
954
958
  private pendingSignals;
955
959
  private triggerTimer;
@@ -971,7 +975,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
971
975
  private syncSetup;
972
976
  private localTime;
973
977
  private applyRemote;
978
+ private clearReaderState;
974
979
  private resetReader;
980
+ private reloadReader;
981
+ private readWithReload;
982
+ private pollFileSet;
975
983
  write(entry: T): Promise<void>;
976
984
  writeBatch(entries: T[]): Promise<void>;
977
985
  delete(key: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.19",
3
+ "version": "1.4.20",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -8,6 +8,7 @@ export declare const bulkDatabase2Timing: {
8
8
  streamFoldTriggerRows: number;
9
9
  streamFoldTriggerBytes: number;
10
10
  writeFlushMaxDelayMs: number;
11
+ fileSetPollIntervalMs: number;
11
12
  };
12
13
  export interface ReactiveDeps {
13
14
  observe(signal: string): void;
@@ -46,6 +47,9 @@ export declare class BulkDatabaseBase<T extends {
46
47
  private streamTimes;
47
48
  private columnCache;
48
49
  private readerKeys;
50
+ private loadedFileSet;
51
+ private readerEpoch;
52
+ private fileSetPollTimer;
49
53
  private dataGen;
50
54
  private pendingSignals;
51
55
  private triggerTimer;
@@ -67,7 +71,11 @@ export declare class BulkDatabaseBase<T extends {
67
71
  private syncSetup;
68
72
  private localTime;
69
73
  private applyRemote;
74
+ private clearReaderState;
70
75
  private resetReader;
76
+ private reloadReader;
77
+ private readWithReload;
78
+ private pollFileSet;
71
79
  write(entry: T): Promise<void>;
72
80
  writeBatch(entries: T[]): Promise<void>;
73
81
  delete(key: string): Promise<void>;
@@ -45,6 +45,10 @@ const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
45
45
  // -view bug), which a later reload resolves.
46
46
  const MAX_READ_ATTEMPTS = 8;
47
47
 
48
+ // A read that hits a file a concurrent merge deleted reloads the index and retries; if it keeps hitting
49
+ // missing files this many times it gives up and throws (so a genuinely-unreadable file can't loop forever).
50
+ const MAX_INDEX_RELOAD_ATTEMPTS = 3;
51
+
48
52
  // The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
49
53
  // file (half the target, so it has room to grow before it needs splitting). The key-stratified second
50
54
  // merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
@@ -87,6 +91,9 @@ export const bulkDatabase2Timing = {
87
91
  // the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
88
92
  // the whole stream file per write.
89
93
  writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
94
+ // We proactively re-list files this often; if the set changed under us (another tab/process merged) we
95
+ // reload the index, so reads pick up the change even without first hitting a missing-file error.
96
+ fileSetPollIntervalMs: 30 * 60 * 1000,
90
97
  };
91
98
 
92
99
  // Marks a key as deleted in the in-memory overlay.
@@ -236,6 +243,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
236
243
  }
237
244
  } catch { /* not in a DOM context */ }
238
245
  }
246
+ // Proactively notice another writer's merge (files added/removed) and reload the index, so reads
247
+ // stay correct even without first hitting a missing-file error.
248
+ this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
249
+ (this.fileSetPollTimer as { unref?: () => void }).unref?.();
239
250
  }
240
251
 
241
252
  // ---- buffered stream writes (per collection) ----
@@ -305,6 +316,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
305
316
  // update of an existing key (only the written columns change) from adding/removing a key (which appears
306
317
  // in / disappears from EVERY column). Set on reader build, cleared on reset.
307
318
  private readerKeys: Set<string> | undefined;
319
+
320
+ // ---- the on-disk index ----
321
+ // The "index" is the loaded reader (file headers + key columns joined into a resolved view) plus the
322
+ // set of files it was built from. A concurrent merge in another tab/process rewrites files out from
323
+ // under us; we notice either when a read hits a deleted file (readWithReload) or via the periodic poll
324
+ // (pollFileSet), and reload the index — cheap, since a build only reads headers + key columns.
325
+ private loadedFileSet: Set<string> | undefined; // file names the current reader was built from
326
+ // Bumped every time the index is cleared/reloaded. A failed read captures it before reading and only
327
+ // triggers a reload if it's unchanged afterward — so N concurrent failures coalesce onto ONE rebuild
328
+ // (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
329
+ private readerEpoch = 0;
330
+ private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
308
331
  // Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
309
332
  // and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
310
333
  // the cache and may swap the reader) can never leave a stale entry behind.
@@ -468,6 +491,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
468
491
  // Live keys of this reader, so per-column cache invalidation can tell an update of an existing key
469
492
  // (only its set columns change) from a key add/remove (which touches every column).
470
493
  this.readerKeys = new Set(joined.keys);
494
+ // The files this index was built from, so the poll can detect a concurrent merge changing the set.
495
+ this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
471
496
 
472
497
  let time = Date.now() - start;
473
498
  if (time > 50) {
@@ -513,31 +538,85 @@ export class BulkDatabaseBase<T extends { key: string }> {
513
538
  });
514
539
  }
515
540
 
516
- // Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
517
- // changes (large direct-bulk write, rollover, compact) after the data has been persisted. Writes
518
- // still buffered (not yet on disk) are re-applied so the reset doesn't drop them from reads — they
519
- // aren't in the reloaded reader until their append lands.
541
+ // Drop the loaded index (reader) and everything derived from it, so the next read rebuilds via
542
+ // buildReader. Does NOT touch the overlay callers decide what to do with pending writes.
543
+ private clearReaderState() {
544
+ this.reader.reset();
545
+ this.baseColumns.clear();
546
+ this.baseColumnsLoading.clear();
547
+ this.baseFields.clear();
548
+ this.baseFieldsLoading.clear();
549
+ this.columnCache.clear();
550
+ this.readerKeys = undefined; // the next build repopulates it
551
+ this.loadedFileSet = undefined; // ditto
552
+ // The next build re-measures the stream; clear the estimate so a just-folded stream doesn't keep
553
+ // looking "heavy" and re-trigger a fold before then.
554
+ this.streamRowsOnDisk = 0;
555
+ this.streamBytesOnDisk = 0;
556
+ this.dataGen++;
557
+ this.readerEpoch++; // so an in-flight read knows the index changed under it (see readWithReload)
558
+ }
559
+
560
+ // Reset the loaded reader AND drop the overlay. Used only on structural changes WE made (large
561
+ // direct-bulk write, rollover, compact) after the data has been persisted. Writes still buffered (not
562
+ // yet on disk) are re-applied so the reset doesn't drop them from reads — they aren't in the reloaded
563
+ // reader until their append lands.
520
564
  private resetReader() {
521
565
  this.deps.batch(() => {
522
- this.reader.reset();
523
- this.baseColumns.clear();
524
- this.baseColumnsLoading.clear();
525
- this.baseFields.clear();
526
- this.baseFieldsLoading.clear();
566
+ this.clearReaderState();
527
567
  this.overlay.clear();
528
- this.dataGen++;
529
- this.columnCache.clear();
530
- this.readerKeys = undefined; // the next reader build repopulates it
531
- // The next reader build re-measures the stream; clear the estimate so a just-folded stream
532
- // doesn't keep looking "heavy" and re-trigger a fold before then.
533
- this.streamRowsOnDisk = 0;
534
- this.streamBytesOnDisk = 0;
535
568
  for (const p of this.pendingAppends) p.apply();
536
569
  this.invalidateSignal(LOAD_SIGNAL);
537
570
  this.invalidateSignal(OVERLAY_SIGNAL);
538
571
  });
539
572
  }
540
573
 
574
+ // Reload just the on-disk index after the file set changed UNDER us (a concurrent merge). Keeps the
575
+ // overlay: our pending writes are in-memory and independent of which files exist on disk.
576
+ private reloadReader() {
577
+ this.deps.batch(() => {
578
+ this.clearReaderState();
579
+ this.invalidateSignal(LOAD_SIGNAL);
580
+ this.invalidateSignal(OVERLAY_SIGNAL);
581
+ });
582
+ }
583
+
584
+ // Run a read against the loaded index; if a file vanished mid-read (a concurrent merge deleted it),
585
+ // reload the index and retry — looping until it succeeds or we've tried MAX_INDEX_RELOAD_ATTEMPTS times.
586
+ // Reloads coalesce: a failing read only resets the index if nobody has reset it since the read grabbed
587
+ // its reader (readerEpoch unchanged). So N concurrent failures cause ONE rebuild — the first resets, the
588
+ // rest see the bumped epoch and just await the in-flight rebuild that lazy() shares — and a good rebuild
589
+ // is never thrown away by a straggler.
590
+ private async readWithReload<R>(fn: (reader: ResolvedReader) => Promise<R>): Promise<R> {
591
+ for (let attempt = 0; ; attempt++) {
592
+ const reader = await this.reader();
593
+ const epoch = this.readerEpoch; // epoch of the reader we're about to use
594
+ try {
595
+ return await fn(reader);
596
+ } catch (e) {
597
+ if (!(e instanceof MissingFileError) || attempt >= MAX_INDEX_RELOAD_ATTEMPTS) throw e;
598
+ if (this.readerEpoch === epoch) this.reloadReader();
599
+ // else: another reader already reloaded the index — loop and use its rebuild.
600
+ }
601
+ }
602
+ }
603
+
604
+ // Proactively reload the index if the on-disk file set changed under us (a merge in another tab/
605
+ // process), so reads pick up the new files even without first hitting a read error. Cheap — just lists
606
+ // the directory. Runs on a timer (fileSetPollIntervalMs).
607
+ private async pollFileSet(): Promise<void> {
608
+ if (!this.loadedFileSet) return; // reader not built yet → nothing loaded to compare against
609
+ let current: Set<string>;
610
+ try {
611
+ const { bulkFiles, streamFiles } = await this.listFiles();
612
+ current = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
613
+ } catch { return; }
614
+ const prev = this.loadedFileSet;
615
+ if (!prev) return; // reloaded during the await
616
+ const changed = current.size !== prev.size || [...current].some(n => !prev.has(n));
617
+ if (changed) this.reloadReader();
618
+ }
619
+
541
620
  // ---- writes ----
542
621
 
543
622
  public async write(entry: T): Promise<void> {
@@ -1210,11 +1289,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
1210
1289
  // column not set in the overlay entry — fall through to disk for this column
1211
1290
  }
1212
1291
  let time = Date.now();
1213
- let reader = await this.reader();
1214
- const r = await reader.getSingleField(key, col);
1292
+ const r = await this.readWithReload(reader => reader.getSingleField(key, col));
1215
1293
  time = Date.now() - time;
1216
1294
  if (time > 50) {
1217
- console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
1295
+ console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
1218
1296
  }
1219
1297
  if (r === undefined) {
1220
1298
  // Not live on disk; but if the overlay holds the key (a partial write of a not-yet-on-disk
@@ -1232,12 +1310,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
1232
1310
  if (cached) return cached as { key: string; value: T[Column]; time: number }[];
1233
1311
  const gen = this.dataGen;
1234
1312
  let time = Date.now();
1235
- let reader = await this.reader();
1236
- let base = await reader.getColumn(col);
1313
+ let base = await this.readWithReload(reader => reader.getColumn(col));
1237
1314
  let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1238
1315
  time = Date.now() - time;
1239
1316
  if (time > 50) {
1240
- console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
1317
+ console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
1241
1318
  }
1242
1319
  Object.freeze(result);
1243
1320
  // Only cache if no write/reset happened during the awaits above (else this result may be stale).
@@ -1274,13 +1351,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
1274
1351
  if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
1275
1352
  this.baseColumnsLoading.add(column);
1276
1353
  void (async () => {
1277
- let reader = await this.reader();
1278
- let base = await reader.getColumn(column);
1279
- this.deps.batch(() => {
1280
- this.baseColumns.set(column, base);
1354
+ try {
1355
+ const base = await this.readWithReload(reader => reader.getColumn(column));
1356
+ this.deps.batch(() => {
1357
+ this.baseColumns.set(column, base);
1358
+ this.baseColumnsLoading.delete(column);
1359
+ this.invalidateSignal(LOAD_SIGNAL);
1360
+ });
1361
+ } catch (e) {
1362
+ // The load failed (e.g. a file vanished and the reload retry also failed). Clear the loading
1363
+ // flag so a later read retries, rather than leaving the column wedged as "loading" forever.
1281
1364
  this.baseColumnsLoading.delete(column);
1282
- this.invalidateSignal(LOAD_SIGNAL);
1283
- });
1365
+ console.warn(`${this.name}.getColumnSync(${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
1366
+ }
1284
1367
  })();
1285
1368
  }
1286
1369
 
@@ -1289,13 +1372,17 @@ export class BulkDatabaseBase<T extends { key: string }> {
1289
1372
  if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
1290
1373
  this.baseFieldsLoading.add(cacheKey);
1291
1374
  void (async () => {
1292
- let reader = await this.reader();
1293
- let resolved = await reader.getSingleField(key, column);
1294
- this.deps.batch(() => {
1295
- this.baseFields.set(cacheKey, resolved);
1375
+ try {
1376
+ const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
1377
+ this.deps.batch(() => {
1378
+ this.baseFields.set(cacheKey, resolved);
1379
+ this.baseFieldsLoading.delete(cacheKey);
1380
+ this.invalidateSignal(LOAD_SIGNAL);
1381
+ });
1382
+ } catch (e) {
1296
1383
  this.baseFieldsLoading.delete(cacheKey);
1297
- this.invalidateSignal(LOAD_SIGNAL);
1298
- });
1384
+ console.warn(`${this.name}.getSingleFieldSync(${JSON.stringify(key)}, ${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
1385
+ }
1299
1386
  })();
1300
1387
  }
1301
1388