sliftutils 1.4.43 → 1.4.45
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,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
912
912
|
firstMergeTriggerRangeMs: number;
|
|
913
913
|
streamFoldTriggerRows: number;
|
|
914
914
|
streamFoldTriggerBytes: number;
|
|
915
|
+
streamFileMaxBytes: number;
|
|
916
|
+
streamFoldHardLimitBytes: number;
|
|
915
917
|
writeFlushMaxDelayMs: number;
|
|
916
918
|
fileSetPollIntervalMs: number;
|
|
917
919
|
};
|
|
@@ -965,6 +967,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
965
967
|
private streamRowsOnDisk;
|
|
966
968
|
private streamBytesOnDisk;
|
|
967
969
|
private streamFileName;
|
|
970
|
+
private currentStreamFileName;
|
|
971
|
+
private currentStreamFileBytes;
|
|
968
972
|
private lastMergeCheck;
|
|
969
973
|
private getStreamFileName;
|
|
970
974
|
private invalidateOverlay;
|
|
@@ -991,6 +995,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
991
995
|
flush(): Promise<void>;
|
|
992
996
|
private flushPending;
|
|
993
997
|
private doFlush;
|
|
998
|
+
private foldOwnStream;
|
|
994
999
|
update(entry: Partial<T> & {
|
|
995
1000
|
key: string;
|
|
996
1001
|
}): Promise<void>;
|
|
@@ -1038,6 +1043,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
1038
1043
|
private baseColumnsLoading;
|
|
1039
1044
|
private baseFields;
|
|
1040
1045
|
private baseFieldsLoading;
|
|
1046
|
+
private staleBaseColumns;
|
|
1047
|
+
private staleBaseFields;
|
|
1041
1048
|
private ensureBaseColumn;
|
|
1042
1049
|
private ensureBaseField;
|
|
1043
1050
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
package/package.json
CHANGED
|
@@ -8,6 +8,8 @@ export declare const bulkDatabase2Timing: {
|
|
|
8
8
|
firstMergeTriggerRangeMs: number;
|
|
9
9
|
streamFoldTriggerRows: number;
|
|
10
10
|
streamFoldTriggerBytes: number;
|
|
11
|
+
streamFileMaxBytes: number;
|
|
12
|
+
streamFoldHardLimitBytes: number;
|
|
11
13
|
writeFlushMaxDelayMs: number;
|
|
12
14
|
fileSetPollIntervalMs: number;
|
|
13
15
|
};
|
|
@@ -61,6 +63,8 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
61
63
|
private streamRowsOnDisk;
|
|
62
64
|
private streamBytesOnDisk;
|
|
63
65
|
private streamFileName;
|
|
66
|
+
private currentStreamFileName;
|
|
67
|
+
private currentStreamFileBytes;
|
|
64
68
|
private lastMergeCheck;
|
|
65
69
|
private getStreamFileName;
|
|
66
70
|
private invalidateOverlay;
|
|
@@ -87,6 +91,7 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
87
91
|
flush(): Promise<void>;
|
|
88
92
|
private flushPending;
|
|
89
93
|
private doFlush;
|
|
94
|
+
private foldOwnStream;
|
|
90
95
|
update(entry: Partial<T> & {
|
|
91
96
|
key: string;
|
|
92
97
|
}): Promise<void>;
|
|
@@ -134,6 +139,8 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
134
139
|
private baseColumnsLoading;
|
|
135
140
|
private baseFields;
|
|
136
141
|
private baseFieldsLoading;
|
|
142
|
+
private staleBaseColumns;
|
|
143
|
+
private staleBaseFields;
|
|
137
144
|
private ensureBaseColumn;
|
|
138
145
|
private ensureBaseField;
|
|
139
146
|
getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined;
|
|
@@ -86,6 +86,16 @@ export const bulkDatabase2Timing = {
|
|
|
86
86
|
// throttle. The byte bound matters most over the network, where pulling the whole stream is expensive.
|
|
87
87
|
streamFoldTriggerRows: 100,
|
|
88
88
|
streamFoldTriggerBytes: 64 * 1024 * 1024,
|
|
89
|
+
// Rolling cap on OUR OWN current stream file: once we've appended this much to it, we seal it (stop
|
|
90
|
+
// writing) and fold it into bulk immediately. Without this, many small appends grow a single stream
|
|
91
|
+
// file without bound (the per-batch ROLLOVER only catches one huge batch). We can fold our own file at
|
|
92
|
+
// once — the 10h seal-age rule is only for other writers' files; we know we're done with ours on seal.
|
|
93
|
+
streamFileMaxBytes: 50 * 1024 * 1024,
|
|
94
|
+
// HARD limit: once the stream tier passes this, fold it NOW regardless of age (even un-sealed, recent
|
|
95
|
+
// streams) — a stream this large makes every read pull a huge file, i.e. the collection becomes
|
|
96
|
+
// essentially unreadable. We still only delete a stream whose size didn't change while we read it, so
|
|
97
|
+
// a writer mid-append never loses data (it just gets re-folded next pass).
|
|
98
|
+
streamFoldHardLimitBytes: 768 * 1024 * 1024,
|
|
89
99
|
// Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
|
|
90
100
|
// this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
|
|
91
101
|
// the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
|
|
@@ -369,6 +379,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
369
379
|
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
370
380
|
// so concurrent writers never touch the same file.
|
|
371
381
|
private streamFileName: string | undefined;
|
|
382
|
+
// Rolling size of the current stream file, so we can seal+fold it once it passes STREAM_FILE_MAX_BYTES
|
|
383
|
+
// (reset whenever the file rotates). currentStreamFileName tracks which file the count belongs to.
|
|
384
|
+
private currentStreamFileName: string | undefined;
|
|
385
|
+
private currentStreamFileBytes = 0;
|
|
372
386
|
// Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
|
|
373
387
|
// first write — the first background merge check waits a full interval after construction.
|
|
374
388
|
private lastMergeCheck = Date.now();
|
|
@@ -563,6 +577,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
563
577
|
// buildReader. Does NOT touch the overlay — callers decide what to do with pending writes.
|
|
564
578
|
private clearReaderState() {
|
|
565
579
|
this.reader.reset();
|
|
580
|
+
// Preserve the last-known sync base values so a reload/compact serves them (not empty) while the
|
|
581
|
+
// fresh ones reload — observers transition old → new, never flashing through nothing.
|
|
582
|
+
for (const [k, v] of this.baseColumns) this.staleBaseColumns.set(k, v);
|
|
583
|
+
for (const [k, v] of this.baseFields) this.staleBaseFields.set(k, v);
|
|
566
584
|
this.baseColumns.clear();
|
|
567
585
|
this.baseColumnsLoading.clear();
|
|
568
586
|
this.baseFields.clear();
|
|
@@ -726,13 +744,41 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
726
744
|
const batch = this.pendingAppends.slice();
|
|
727
745
|
const combined = Buffer.concat(batch.map(p => p.framed));
|
|
728
746
|
const storage = await this.storage();
|
|
747
|
+
const fileName = this.getStreamFileName();
|
|
748
|
+
if (fileName !== this.currentStreamFileName) { // rotated (age-seal / first write) → reset the count
|
|
749
|
+
this.currentStreamFileName = fileName;
|
|
750
|
+
this.currentStreamFileBytes = 0;
|
|
751
|
+
}
|
|
729
752
|
// Throws on failure -> we don't splice, so the data stays buffered and a later flush retries it.
|
|
730
|
-
await storage.append(
|
|
753
|
+
await storage.append(fileName, combined);
|
|
731
754
|
// New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
|
|
732
755
|
this.pendingAppends.splice(0, batch.length);
|
|
733
756
|
// Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
|
|
734
757
|
this.streamBytesOnDisk += combined.length;
|
|
735
758
|
for (const p of batch) this.streamRowsOnDisk += p.rows;
|
|
759
|
+
this.currentStreamFileBytes += combined.length;
|
|
760
|
+
// Cap our own stream file: once it's grown past the limit, seal it (next write starts a fresh file)
|
|
761
|
+
// and fold this now-complete file into bulk in the background. New writes go to the new file, so the
|
|
762
|
+
// sealed one is stable and safe to fold+delete immediately — no 10h wait for our own files.
|
|
763
|
+
if (this.currentStreamFileBytes >= bulkDatabase2Timing.streamFileMaxBytes) {
|
|
764
|
+
this.streamFileName = undefined;
|
|
765
|
+
this.currentStreamFileName = undefined;
|
|
766
|
+
this.currentStreamFileBytes = 0;
|
|
767
|
+
void this.foldOwnStream(fileName);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Fold one of OUR OWN sealed stream files into bulk and delete it. Safe to delete immediately (force):
|
|
772
|
+
// we've stopped appending to it (a new file is current now), and canDeleteStream still only deletes it
|
|
773
|
+
// if its size is unchanged since we read it, so an in-flight flush can never lose data.
|
|
774
|
+
private async foldOwnStream(fileName: string): Promise<void> {
|
|
775
|
+
const info = parseStreamFileName(fileName);
|
|
776
|
+
if (!info) return;
|
|
777
|
+
try {
|
|
778
|
+
await this.mergeFileSet([], [info], false, true);
|
|
779
|
+
} catch (e) {
|
|
780
|
+
console.warn(`${this.name}: folding own stream ${fileName} failed: ${(e as Error).message}`);
|
|
781
|
+
}
|
|
736
782
|
}
|
|
737
783
|
|
|
738
784
|
public async update(entry: Partial<T> & { key: string }): Promise<void> {
|
|
@@ -1093,7 +1139,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1093
1139
|
// outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
|
|
1094
1140
|
// nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
|
|
1095
1141
|
// merge that reaches time 0 are the cases where this holds.)
|
|
1096
|
-
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false): Promise<boolean> {
|
|
1142
|
+
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> {
|
|
1097
1143
|
const storage = await this.storage();
|
|
1098
1144
|
const timestamp = nextFileTime();
|
|
1099
1145
|
const now = Date.now();
|
|
@@ -1155,7 +1201,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1155
1201
|
const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
|
|
1156
1202
|
for (const f of consumedBulk) await remove(f.fileName);
|
|
1157
1203
|
for (const f of streamFiles) {
|
|
1158
|
-
if (await this.canDeleteStream(f, now, streamData.sizes)) await remove(f.fileName);
|
|
1204
|
+
if (await this.canDeleteStream(f, now, streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
|
|
1159
1205
|
}
|
|
1160
1206
|
|
|
1161
1207
|
this.resetReader();
|
|
@@ -1168,9 +1214,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1168
1214
|
// the merge). When neither holds we leave it: its data is now duplicated into bulk (resolved by time)
|
|
1169
1215
|
// and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
|
|
1170
1216
|
// data, but the aged check also rules out the rare sparse-offset append race.)
|
|
1171
|
-
private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number
|
|
1217
|
+
private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>, force = false): Promise<boolean> {
|
|
1172
1218
|
if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
|
|
1173
|
-
|
|
1219
|
+
// Without cross-tab sync we normally can't tell a writer is done before the seal age — UNLESS the
|
|
1220
|
+
// caller forces it (the hard stream limit). Even then we only delete a stream whose size didn't
|
|
1221
|
+
// change while we read it, so a writer mid-append never loses data (it's re-folded next pass).
|
|
1222
|
+
if (!isSyncSupported() && !force) return false;
|
|
1174
1223
|
const readSize = sizes.get(f.fileName);
|
|
1175
1224
|
if (readSize === undefined) return false;
|
|
1176
1225
|
let info;
|
|
@@ -1217,6 +1266,23 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1217
1266
|
return true;
|
|
1218
1267
|
};
|
|
1219
1268
|
|
|
1269
|
+
// ── Hard stream limit: once the tier-0 stream has grown past the hard cap, fold ALL of it into bulk
|
|
1270
|
+
// NOW regardless of age — a stream this big makes every read pull a huge file, i.e. the collection
|
|
1271
|
+
// is essentially unreadable. Force-delete the folded streams (canDeleteStream still only deletes
|
|
1272
|
+
// size-stable ones, so an active writer never loses data; it's just re-folded next pass). ──
|
|
1273
|
+
{
|
|
1274
|
+
const { streamFiles } = await this.listFiles();
|
|
1275
|
+
if (streamFiles.length) {
|
|
1276
|
+
const storage = await this.storage();
|
|
1277
|
+
const sizes = await Promise.all(streamFiles.map(async f => { try { return (await storage.getInfo(f.fileName))?.size ?? 0; } catch { return 0; } }));
|
|
1278
|
+
const totalStreamBytes = sizes.reduce((a, b) => a + b, 0);
|
|
1279
|
+
if (totalStreamBytes > bulkDatabase2Timing.streamFoldHardLimitBytes) {
|
|
1280
|
+
console.log(`${blue(this.name)} stream tier ${fmtBytes(totalStreamBytes)} over hard limit ${fmtBytes(bulkDatabase2Timing.streamFoldHardLimitBytes)} — folding all streams now`);
|
|
1281
|
+
if (await this.mergeFileSet([], streamFiles, false, true)) merged = true;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1220
1286
|
// ── Pass 1: consolidate recent files. ──
|
|
1221
1287
|
// Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
|
|
1222
1288
|
// actually fold recent streams; in Node it would just churn — fragmenting streams every pass for
|
|
@@ -1249,12 +1315,16 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1249
1315
|
if (recentBytes >= FIRST_MERGE_BYTES) break;
|
|
1250
1316
|
}
|
|
1251
1317
|
const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
|
|
1252
|
-
// Fold when there's enough fragmentation to consolidate, OR when
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1318
|
+
// Fold when there's enough fragmentation to consolidate, OR when the foldable stream data here
|
|
1319
|
+
// has grown past the byte threshold (stream data can't be read per-cell, so a big stream is
|
|
1320
|
+
// costly to pull whole). The stream size is derived from THIS fresh listing — not a cached
|
|
1321
|
+
// counter — so it's correct even for an instance that never built a reader (e.g. the host's
|
|
1322
|
+
// autocompactor) and for streams that grew in place since the index was last loaded.
|
|
1323
|
+
const recentStreamBytes = recent.reduce((a, it) => a + (it.kind === "stream" ? it.bytes : 0), 0);
|
|
1324
|
+
const heavyStream = recentStreamBytes > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
1255
1325
|
const triggered =
|
|
1256
1326
|
recent.length >= 2 && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs)
|
|
1257
|
-
||
|
|
1327
|
+
|| heavyStream;
|
|
1258
1328
|
if (triggered) {
|
|
1259
1329
|
const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
|
|
1260
1330
|
const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
|
|
@@ -1416,6 +1486,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1416
1486
|
// (Map.has distinguishes "loaded" from "not loaded yet").
|
|
1417
1487
|
private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
1418
1488
|
private baseFieldsLoading = new Set<string>();
|
|
1489
|
+
// Last-known sync base values, kept across a reader reset so a reload/compact serves the previous data
|
|
1490
|
+
// instead of flashing empty while the fresh value reloads in the background. Each entry is dropped once
|
|
1491
|
+
// ensureBase* has the corresponding fresh value (so reads transition old → new, never old → empty → new).
|
|
1492
|
+
private staleBaseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
1493
|
+
private staleBaseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
1419
1494
|
|
|
1420
1495
|
private ensureBaseColumn(column: string) {
|
|
1421
1496
|
if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
|
|
@@ -1425,6 +1500,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1425
1500
|
const base = await this.readWithReload(reader => reader.getColumn(column));
|
|
1426
1501
|
this.deps.batch(() => {
|
|
1427
1502
|
this.baseColumns.set(column, base);
|
|
1503
|
+
this.staleBaseColumns.delete(column); // fresh value in hand; stop serving the stale one
|
|
1428
1504
|
this.baseColumnsLoading.delete(column);
|
|
1429
1505
|
this.invalidateSignal(LOAD_SIGNAL);
|
|
1430
1506
|
});
|
|
@@ -1446,6 +1522,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1446
1522
|
const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
|
|
1447
1523
|
this.deps.batch(() => {
|
|
1448
1524
|
this.baseFields.set(cacheKey, resolved);
|
|
1525
|
+
this.staleBaseFields.delete(cacheKey); // fresh value in hand; stop serving the stale one
|
|
1449
1526
|
this.baseFieldsLoading.delete(cacheKey);
|
|
1450
1527
|
this.invalidateSignal(LOAD_SIGNAL);
|
|
1451
1528
|
});
|
|
@@ -1470,15 +1547,30 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1470
1547
|
let entry = this.overlay.get(key);
|
|
1471
1548
|
if (entry !== undefined) {
|
|
1472
1549
|
if (entry.value === DELETED) return undefined;
|
|
1473
|
-
if (col in entry.value)
|
|
1550
|
+
if (col in entry.value) {
|
|
1551
|
+
// Warm the disk-backed base in the background even though the overlay serves this now — so
|
|
1552
|
+
// when the overlay is later cleared (e.g. compaction persists it), there's a value to serve
|
|
1553
|
+
// and the read doesn't flash empty.
|
|
1554
|
+
this.ensureBaseField(key, col);
|
|
1555
|
+
return { key, value: entry.value[col] as T[Column], time: entry.time };
|
|
1556
|
+
}
|
|
1474
1557
|
// column not set in the overlay entry — fall through to the base field cache for this column
|
|
1475
1558
|
}
|
|
1476
1559
|
let cacheKey = nullJoin(col, key);
|
|
1477
|
-
if
|
|
1560
|
+
// Use the fresh value if loaded; mid-reload fall back to the last-known one so we don't flash empty.
|
|
1561
|
+
let src: Map<string, { value: unknown; time: number } | undefined> | undefined;
|
|
1562
|
+
if (this.baseFields.has(cacheKey)) {
|
|
1563
|
+
src = this.baseFields;
|
|
1564
|
+
} else {
|
|
1478
1565
|
this.ensureBaseField(key, col);
|
|
1566
|
+
src = this.staleBaseFields.has(cacheKey) ? this.staleBaseFields : undefined;
|
|
1567
|
+
}
|
|
1568
|
+
if (!src) {
|
|
1569
|
+
// Genuine first load (nothing known); but an overlay entry makes the key live with this column unset.
|
|
1570
|
+
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
1479
1571
|
return undefined;
|
|
1480
1572
|
}
|
|
1481
|
-
const base =
|
|
1573
|
+
const base = src.get(cacheKey);
|
|
1482
1574
|
if (base === undefined) {
|
|
1483
1575
|
// Not live on disk; but an overlay entry for the key (partial write) makes it live, column unset.
|
|
1484
1576
|
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
@@ -1498,7 +1590,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1498
1590
|
let base = this.baseColumns.get(col);
|
|
1499
1591
|
if (!base) {
|
|
1500
1592
|
this.ensureBaseColumn(col);
|
|
1501
|
-
|
|
1593
|
+
// Mid-reload: serve the last-known value (patched with the current overlay) so we don't flash
|
|
1594
|
+
// empty. Don't cache it — it's stale until ensureBaseColumn swaps in the fresh value.
|
|
1595
|
+
const stale = this.staleBaseColumns.get(col);
|
|
1596
|
+
if (stale) return this.patchColumn(stale, col) as { key: string; value: T[Column]; time: number }[];
|
|
1597
|
+
return undefined; // genuine first load — nothing known yet
|
|
1502
1598
|
}
|
|
1503
1599
|
// Synchronous (no awaits) → reads the current overlay and caches it atomically; an observer re-runs
|
|
1504
1600
|
// (and the cache is cleared) on any later change, so the frozen result is safe to share.
|