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
|
@@ -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
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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.
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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.
|
|
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
|
|