sliftutils 1.4.13 → 1.4.15
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 +24 -0
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +7 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +8 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +15 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +112 -14
- package/storage/FileFolderAPI.d.ts +2 -0
- package/storage/FileFolderAPI.tsx +7 -0
- package/storage/remoteFileServer.ts +10 -1
- package/storage/remoteFileStorage.ts +1 -0
package/index.d.ts
CHANGED
|
@@ -865,6 +865,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
865
865
|
}>;
|
|
866
866
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
867
867
|
compact(): Promise<void>;
|
|
868
|
+
/**
|
|
869
|
+
* Whether this collection's storage is served over the network (a remote server) rather than local
|
|
870
|
+
* disk. Apps can branch on this to adapt to the higher latency. Note: over the network the database
|
|
871
|
+
* skips automatic background compaction by default — call the static
|
|
872
|
+
* `BulkDatabase2.enableNetworkCompaction()` once to opt in.
|
|
873
|
+
*/
|
|
874
|
+
isRemote(): Promise<boolean>;
|
|
868
875
|
/**
|
|
869
876
|
* Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
|
|
870
877
|
* avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
|
|
@@ -902,6 +909,8 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
902
909
|
mergeSpacingMs: number;
|
|
903
910
|
firstMergeTriggerFiles: number;
|
|
904
911
|
firstMergeTriggerRangeMs: number;
|
|
912
|
+
streamFoldTriggerRows: number;
|
|
913
|
+
streamFoldTriggerBytes: number;
|
|
905
914
|
writeFlushMaxDelayMs: number;
|
|
906
915
|
};
|
|
907
916
|
export interface ReactiveDeps {
|
|
@@ -924,13 +933,19 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
924
933
|
private currentFlushDelay;
|
|
925
934
|
private lastWriteTime;
|
|
926
935
|
static clearCache(): void;
|
|
936
|
+
static enableNetworkCompaction(): void;
|
|
927
937
|
storage: {
|
|
928
938
|
(): Promise<FileStorage>;
|
|
929
939
|
reset(): void;
|
|
930
940
|
set(newValue: Promise<FileStorage>): void;
|
|
931
941
|
};
|
|
942
|
+
isRemote(): Promise<boolean>;
|
|
943
|
+
private streamNeedsFold;
|
|
944
|
+
private automaticCompactionAllowed;
|
|
932
945
|
private overlay;
|
|
933
946
|
private streamTimes;
|
|
947
|
+
private streamRowsOnDisk;
|
|
948
|
+
private streamBytesOnDisk;
|
|
934
949
|
private streamFileName;
|
|
935
950
|
private lastMergeCheck;
|
|
936
951
|
private getStreamFileName;
|
|
@@ -1014,6 +1029,13 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
1014
1029
|
column: string;
|
|
1015
1030
|
byteSize: number;
|
|
1016
1031
|
}[]>;
|
|
1032
|
+
getKeyStats(): Promise<{
|
|
1033
|
+
rawKeys: number;
|
|
1034
|
+
finalKeys: number;
|
|
1035
|
+
wastedKeys: number;
|
|
1036
|
+
duplication: number;
|
|
1037
|
+
readers: number;
|
|
1038
|
+
}>;
|
|
1017
1039
|
getReaderInfo(): Promise<{
|
|
1018
1040
|
rowCount: number;
|
|
1019
1041
|
totalBytes: number;
|
|
@@ -1365,6 +1387,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1365
1387
|
readonly kind: "directory";
|
|
1366
1388
|
readonly name: string;
|
|
1367
1389
|
readonly fullPath?: string;
|
|
1390
|
+
readonly isRemote?: boolean;
|
|
1368
1391
|
removeEntry(key: string, options?: {
|
|
1369
1392
|
recursive?: boolean;
|
|
1370
1393
|
}): Promise<void>;
|
|
@@ -1452,6 +1475,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
|
|
|
1452
1475
|
};
|
|
1453
1476
|
export type FileStorage = IStorageRaw & {
|
|
1454
1477
|
folder: NestedFileStorage;
|
|
1478
|
+
isRemote?: boolean;
|
|
1455
1479
|
};
|
|
1456
1480
|
export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
|
|
1457
1481
|
export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
|
package/package.json
CHANGED
|
@@ -100,6 +100,13 @@ export interface IBulkDatabase2<T extends {
|
|
|
100
100
|
}>;
|
|
101
101
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
102
102
|
compact(): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Whether this collection's storage is served over the network (a remote server) rather than local
|
|
105
|
+
* disk. Apps can branch on this to adapt to the higher latency. Note: over the network the database
|
|
106
|
+
* skips automatic background compaction by default — call the static
|
|
107
|
+
* `BulkDatabase2.enableNetworkCompaction()` once to opt in.
|
|
108
|
+
*/
|
|
109
|
+
isRemote(): Promise<boolean>;
|
|
103
110
|
/**
|
|
104
111
|
* Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
|
|
105
112
|
* avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
|
|
@@ -79,6 +79,14 @@ export interface IBulkDatabase2<T extends { key: string }> {
|
|
|
79
79
|
/** Consolidate on-disk files. Optional to call; the database also does this in the background. */
|
|
80
80
|
compact(): Promise<void>;
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Whether this collection's storage is served over the network (a remote server) rather than local
|
|
84
|
+
* disk. Apps can branch on this to adapt to the higher latency. Note: over the network the database
|
|
85
|
+
* skips automatic background compaction by default — call the static
|
|
86
|
+
* `BulkDatabase2.enableNetworkCompaction()` once to opt in.
|
|
87
|
+
*/
|
|
88
|
+
isRemote(): Promise<boolean>;
|
|
89
|
+
|
|
82
90
|
/**
|
|
83
91
|
* Flush buffered stream writes to disk now. Writes are coalesced and flushed on a ramping delay (to
|
|
84
92
|
* avoid the browser rewriting the whole stream file per write), so a write's promise resolving means
|
|
@@ -5,6 +5,8 @@ export declare const bulkDatabase2Timing: {
|
|
|
5
5
|
mergeSpacingMs: number;
|
|
6
6
|
firstMergeTriggerFiles: number;
|
|
7
7
|
firstMergeTriggerRangeMs: number;
|
|
8
|
+
streamFoldTriggerRows: number;
|
|
9
|
+
streamFoldTriggerBytes: number;
|
|
8
10
|
writeFlushMaxDelayMs: number;
|
|
9
11
|
};
|
|
10
12
|
export interface ReactiveDeps {
|
|
@@ -27,13 +29,19 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
27
29
|
private currentFlushDelay;
|
|
28
30
|
private lastWriteTime;
|
|
29
31
|
static clearCache(): void;
|
|
32
|
+
static enableNetworkCompaction(): void;
|
|
30
33
|
storage: {
|
|
31
34
|
(): Promise<FileStorage>;
|
|
32
35
|
reset(): void;
|
|
33
36
|
set(newValue: Promise<FileStorage>): void;
|
|
34
37
|
};
|
|
38
|
+
isRemote(): Promise<boolean>;
|
|
39
|
+
private streamNeedsFold;
|
|
40
|
+
private automaticCompactionAllowed;
|
|
35
41
|
private overlay;
|
|
36
42
|
private streamTimes;
|
|
43
|
+
private streamRowsOnDisk;
|
|
44
|
+
private streamBytesOnDisk;
|
|
37
45
|
private streamFileName;
|
|
38
46
|
private lastMergeCheck;
|
|
39
47
|
private getStreamFileName;
|
|
@@ -117,6 +125,13 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
117
125
|
column: string;
|
|
118
126
|
byteSize: number;
|
|
119
127
|
}[]>;
|
|
128
|
+
getKeyStats(): Promise<{
|
|
129
|
+
rawKeys: number;
|
|
130
|
+
finalKeys: number;
|
|
131
|
+
wastedKeys: number;
|
|
132
|
+
duplication: number;
|
|
133
|
+
readers: number;
|
|
134
|
+
}>;
|
|
120
135
|
getReaderInfo(): Promise<{
|
|
121
136
|
rowCount: number;
|
|
122
137
|
totalBytes: number;
|
|
@@ -77,6 +77,11 @@ export const bulkDatabase2Timing = {
|
|
|
77
77
|
firstMergeTriggerFiles: 20,
|
|
78
78
|
// ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
|
|
79
79
|
firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
|
|
80
|
+
// Tier-0 stream data can't be read per-cell — a read pulls the whole stream — so once the stream
|
|
81
|
+
// exceeds BOTH of these it's folded into bulk (columnar, range-readable) promptly, bypassing the merge
|
|
82
|
+
// throttle. The byte bound matters most over the network, where pulling the whole stream is expensive.
|
|
83
|
+
streamFoldTriggerRows: 100,
|
|
84
|
+
streamFoldTriggerBytes: 64 * 1024 * 1024,
|
|
80
85
|
// Max delay (per collection) before buffered stream writes are flushed to disk; the delay ramps up to
|
|
81
86
|
// this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
|
|
82
87
|
// the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
|
|
@@ -132,6 +137,11 @@ export type StorageFactory = (path: string) => Promise<FileStorage>;
|
|
|
132
137
|
const LOAD_SIGNAL = NULL + "load";
|
|
133
138
|
const OVERLAY_SIGNAL = NULL + "overlay";
|
|
134
139
|
|
|
140
|
+
// Over network storage we skip automatic (background) compaction by default — reading and rewriting whole
|
|
141
|
+
// files over the network is expensive. An app that wants it anyway opts in once via
|
|
142
|
+
// BulkDatabaseBase.enableNetworkCompaction(). Explicit compact()/tryMergeNow() calls are unaffected.
|
|
143
|
+
let networkCompactionEnabled = false;
|
|
144
|
+
|
|
135
145
|
let fileNameCounter = 0;
|
|
136
146
|
// Random per-process id baked into file names so two processes (tabs) writing the same collection
|
|
137
147
|
// never collide on a name when they pick the same timestamp/counter in the same millisecond.
|
|
@@ -216,7 +226,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
216
226
|
// ramping delay (see WRITE_FLUSH_FIRST_STEP_MS / writeFlushMaxDelayMs). Each buffered item keeps an
|
|
217
227
|
// `apply` that re-applies its overlay mutation, so resetReader (which clears the overlay) can restore
|
|
218
228
|
// writes that aren't on disk yet. Removed from the buffer only once their append succeeds.
|
|
219
|
-
private pendingAppends: { framed: Buffer; apply: () => void }[] = [];
|
|
229
|
+
private pendingAppends: { framed: Buffer; apply: () => void; rows: number }[] = [];
|
|
220
230
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
221
231
|
private flushChain: Promise<void> = Promise.resolve();
|
|
222
232
|
private currentFlushDelay = 0;
|
|
@@ -228,8 +238,34 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
228
238
|
blockCache.clear();
|
|
229
239
|
}
|
|
230
240
|
|
|
241
|
+
// Opt in to automatic compaction even when the storage is remote (off by default — see
|
|
242
|
+
// networkCompactionEnabled). Global; affects every collection. Explicit compact()/tryMergeNow() always
|
|
243
|
+
// run regardless of this.
|
|
244
|
+
public static enableNetworkCompaction() {
|
|
245
|
+
networkCompactionEnabled = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
231
248
|
public storage = lazy(async () => this.storageFactory(`${BULK_ROOT_FOLDER}/${this.name}`));
|
|
232
249
|
|
|
250
|
+
// True when this collection's storage is served over the network (a remote server). Apps can branch on
|
|
251
|
+
// this to adjust behavior for the higher latency (e.g. show a "slower storage" hint, prefetch less).
|
|
252
|
+
public async isRemote(): Promise<boolean> {
|
|
253
|
+
return !!(await this.storage()).isRemote;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Whether the tier-0 stream is big enough to fold into bulk now (both bounds): too many rows AND too
|
|
257
|
+
// many bytes to keep reading whole. See bulkDatabase2Timing.streamFoldTrigger*.
|
|
258
|
+
private streamNeedsFold(): boolean {
|
|
259
|
+
return this.streamRowsOnDisk >= bulkDatabase2Timing.streamFoldTriggerRows && this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Automatic (background) compaction is skipped over the network unless the app opted in. Explicit
|
|
263
|
+
// compact()/tryMergeNow() bypass this.
|
|
264
|
+
private async automaticCompactionAllowed(): Promise<boolean> {
|
|
265
|
+
if (networkCompactionEnabled) return true;
|
|
266
|
+
return !(await this.storage()).isRemote;
|
|
267
|
+
}
|
|
268
|
+
|
|
233
269
|
// In-memory overlay of pending writes/deletes. It takes priority over the loaded readers, so writes
|
|
234
270
|
// are reflected in reads without reloading. Reads observe the relevant signal; mutations invalidate it.
|
|
235
271
|
//
|
|
@@ -242,6 +278,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
242
278
|
// overlay to decide whether an incoming remote write is actually newer than what we have.
|
|
243
279
|
private streamTimes = new Map<string, number>();
|
|
244
280
|
|
|
281
|
+
// Approximate size of the tier-0 stream data on disk: set accurately from the last reader build, then
|
|
282
|
+
// kept current as each flush appends more. Drives the stream-fold trigger (see streamNeedsFold);
|
|
283
|
+
// reset on resetReader, since after a merge/reset the next reader build re-measures it.
|
|
284
|
+
private streamRowsOnDisk = 0;
|
|
285
|
+
private streamBytesOnDisk = 0;
|
|
286
|
+
|
|
245
287
|
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
246
288
|
// so concurrent writers never touch the same file.
|
|
247
289
|
private streamFileName: string | undefined;
|
|
@@ -319,6 +361,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
319
361
|
if (streamData.missing) filesChanged = true;
|
|
320
362
|
if (filesChanged && !tolerateMissing) throw new FilesChangedError();
|
|
321
363
|
|
|
364
|
+
// Accurate stream size as of this load; subsequent flushes keep it current (see doFlush).
|
|
365
|
+
this.streamRowsOnDisk = streamData.entries.length;
|
|
366
|
+
this.streamBytesOnDisk = streamData.totalBytes;
|
|
367
|
+
|
|
322
368
|
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
323
369
|
// The join resolves purely by write-time, so reader order doesn't matter.
|
|
324
370
|
const readers: BaseBulkDatabaseReader[] = [];
|
|
@@ -389,6 +435,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
389
435
|
this.baseFields.clear();
|
|
390
436
|
this.baseFieldsLoading.clear();
|
|
391
437
|
this.overlay.clear();
|
|
438
|
+
// The next reader build re-measures the stream; clear the estimate so a just-folded stream
|
|
439
|
+
// doesn't keep looking "heavy" and re-trigger a fold before then.
|
|
440
|
+
this.streamRowsOnDisk = 0;
|
|
441
|
+
this.streamBytesOnDisk = 0;
|
|
392
442
|
for (const p of this.pendingAppends) p.apply();
|
|
393
443
|
this.deps.invalidate(LOAD_SIGNAL);
|
|
394
444
|
this.deps.invalidate(OVERLAY_SIGNAL);
|
|
@@ -422,7 +472,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
422
472
|
const apply = () => { for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time); };
|
|
423
473
|
this.deps.batch(apply);
|
|
424
474
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
425
|
-
await this.streamAppend(framed, apply);
|
|
475
|
+
await this.streamAppend(framed, apply, stamped.length);
|
|
426
476
|
void this.maybeMerge();
|
|
427
477
|
}
|
|
428
478
|
|
|
@@ -437,7 +487,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
437
487
|
const apply = () => { for (const { time, key } of stamped) this.setOverlayDeleted(key, time); };
|
|
438
488
|
this.deps.batch(apply);
|
|
439
489
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
440
|
-
await this.streamAppend(frameDeletes(stamped), apply);
|
|
490
|
+
await this.streamAppend(frameDeletes(stamped), apply, stamped.length);
|
|
441
491
|
void this.maybeMerge();
|
|
442
492
|
}
|
|
443
493
|
|
|
@@ -445,8 +495,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
445
495
|
// `apply` re-applies this write's overlay mutation if resetReader clears the overlay before it's on
|
|
446
496
|
// disk. Awaits durability only on an immediate (idle/Node) flush, so a single action — then close — is
|
|
447
497
|
// saved at once; a burst returns fast and is flushed in the background.
|
|
448
|
-
private async streamAppend(framed: Buffer, apply: () => void): Promise<void> {
|
|
449
|
-
this.pendingAppends.push({ framed, apply });
|
|
498
|
+
private async streamAppend(framed: Buffer, apply: () => void, rows: number): Promise<void> {
|
|
499
|
+
this.pendingAppends.push({ framed, apply, rows });
|
|
450
500
|
const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
|
|
451
501
|
const now = Date.now();
|
|
452
502
|
// Immediate when batching is off (Node / real append), or for the first write after a lull.
|
|
@@ -487,6 +537,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
487
537
|
await storage.append(this.getStreamFileName(), combined);
|
|
488
538
|
// New writes added during the await are after `batch`, so removing the front is exactly the flushed set.
|
|
489
539
|
this.pendingAppends.splice(0, batch.length);
|
|
540
|
+
// Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
|
|
541
|
+
this.streamBytesOnDisk += combined.length;
|
|
542
|
+
for (const p of batch) this.streamRowsOnDisk += p.rows;
|
|
490
543
|
}
|
|
491
544
|
|
|
492
545
|
public async update(entry: Partial<T> & { key: string }): Promise<void> {
|
|
@@ -603,10 +656,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
603
656
|
return entries.map(e => e.entry);
|
|
604
657
|
}
|
|
605
658
|
|
|
606
|
-
// Throttled, fire-and-forget after writes: run a background merge check at most once per interval
|
|
659
|
+
// Throttled, fire-and-forget after writes: run a background merge check at most once per interval — but
|
|
660
|
+
// a tier-0 stream that's grown too big folds promptly, bypassing the throttle. Skipped entirely over
|
|
661
|
+
// the network unless the app opted in (see automaticCompactionAllowed).
|
|
607
662
|
private async maybeMerge(): Promise<void> {
|
|
663
|
+
if (!await this.automaticCompactionAllowed()) return;
|
|
608
664
|
const now = Date.now();
|
|
609
|
-
if (now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
|
|
665
|
+
if (!this.streamNeedsFold() && now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
|
|
610
666
|
this.lastMergeCheck = now;
|
|
611
667
|
try {
|
|
612
668
|
await this.tryMergeNow();
|
|
@@ -637,7 +693,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
637
693
|
syncBroadcastSeal(this.name);
|
|
638
694
|
this.streamFileName = undefined;
|
|
639
695
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
640
|
-
|
|
696
|
+
// compact() merges every file on disk, so nothing older survives outside it — tombstones for
|
|
697
|
+
// fully-deleted keys can be dropped rather than carried into a fresh carry stream.
|
|
698
|
+
if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
|
|
641
699
|
} finally {
|
|
642
700
|
releaseMergeLock(this.name, writerId);
|
|
643
701
|
}
|
|
@@ -662,7 +720,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
662
720
|
const selStream = streamFiles.filter(f =>
|
|
663
721
|
f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
|
|
664
722
|
if (selBulk.length + selStream.length < 2) return;
|
|
665
|
-
|
|
723
|
+
// timeLo <= 0 reaches the start of time: every older file overlaps [timeLo, timeHi] and is in the
|
|
724
|
+
// merge, so no older set survives outside it and surviving tombstones can be dropped.
|
|
725
|
+
await this.mergeFileSet(selBulk, selStream, timeLo <= 0);
|
|
666
726
|
}
|
|
667
727
|
|
|
668
728
|
// Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
|
|
@@ -804,7 +864,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
804
864
|
// merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
|
|
805
865
|
// only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
|
|
806
866
|
// didn't change while we read it. Returns whether it produced anything.
|
|
807
|
-
|
|
867
|
+
// `includesOldest` means this merge consumes every file at or before its time range — there is no
|
|
868
|
+
// file before it on disk. A surviving tombstone only exists to suppress an OLDER set in some file
|
|
869
|
+
// outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
|
|
870
|
+
// nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
|
|
871
|
+
// merge that reaches time 0 are the cases where this holds.)
|
|
872
|
+
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false): Promise<boolean> {
|
|
808
873
|
const storage = await this.storage();
|
|
809
874
|
const timestamp = nextFileTime();
|
|
810
875
|
const now = Date.now();
|
|
@@ -837,7 +902,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
837
902
|
newNames.push(name);
|
|
838
903
|
}
|
|
839
904
|
}
|
|
840
|
-
if
|
|
905
|
+
// Carry surviving tombstones forward only if older files exist outside this merge that they still
|
|
906
|
+
// need to suppress; when this merge includes the oldest data there's nothing older to suppress.
|
|
907
|
+
const carriedDeletes = includesOldest ? 0 : deletes.size;
|
|
908
|
+
if (carriedDeletes) {
|
|
841
909
|
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
842
910
|
await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
|
|
843
911
|
}
|
|
@@ -849,7 +917,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
849
917
|
}
|
|
850
918
|
|
|
851
919
|
this.resetReader();
|
|
852
|
-
return newNames.length > 0 ||
|
|
920
|
+
return newNames.length > 0 || carriedDeletes > 0;
|
|
853
921
|
}
|
|
854
922
|
|
|
855
923
|
// A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
|
|
@@ -939,8 +1007,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
939
1007
|
if (recentBytes >= FIRST_MERGE_BYTES) break;
|
|
940
1008
|
}
|
|
941
1009
|
const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
|
|
942
|
-
|
|
943
|
-
|
|
1010
|
+
// Fold when there's enough fragmentation to consolidate, OR when a foldable stream has grown too
|
|
1011
|
+
// big to keep reading whole (even if it's the only recent file) — see streamNeedsFold.
|
|
1012
|
+
const heavyStreamRecent = this.streamNeedsFold() && recent.some(i => i.kind === "stream");
|
|
1013
|
+
const triggered =
|
|
1014
|
+
recent.length >= 2 && (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs)
|
|
1015
|
+
|| heavyStreamRecent && recent.length >= 1;
|
|
944
1016
|
if (triggered) {
|
|
945
1017
|
const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
|
|
946
1018
|
const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
|
|
@@ -1177,6 +1249,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1177
1249
|
return reader.columns;
|
|
1178
1250
|
}
|
|
1179
1251
|
|
|
1252
|
+
// Raw vs. resolved key counts: how much duplicate/stale key data is sitting on disk that a compact()
|
|
1253
|
+
// would collapse. rawKeys counts every key-slot across all loaded files — each set and each delete
|
|
1254
|
+
// tombstone (a key written into N files counts N times); finalKeys is the number of live resolved
|
|
1255
|
+
// keys (after newest-write-wins and tombstones). Both come straight from the already-loaded reader, so
|
|
1256
|
+
// this is ~free. wastedKeys = rawKeys - finalKeys; duplication = rawKeys / finalKeys (well above 1 ⇒
|
|
1257
|
+
// fragmented, compaction would shrink it).
|
|
1258
|
+
public async getKeyStats(): Promise<{ rawKeys: number; finalKeys: number; wastedKeys: number; duplication: number; readers: number }> {
|
|
1259
|
+
const reader = await this.reader();
|
|
1260
|
+
const rawKeys = reader.rawKeyCount;
|
|
1261
|
+
const finalKeys = reader.keys.length;
|
|
1262
|
+
return {
|
|
1263
|
+
rawKeys,
|
|
1264
|
+
finalKeys,
|
|
1265
|
+
wastedKeys: rawKeys - finalKeys,
|
|
1266
|
+
duplication: finalKeys ? rawKeys / finalKeys : 0,
|
|
1267
|
+
readers: reader.readerCount,
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1180
1271
|
public async getReaderInfo() {
|
|
1181
1272
|
let reader = await this.reader();
|
|
1182
1273
|
return {
|
|
@@ -1212,6 +1303,11 @@ type ResolvedReader = {
|
|
|
1212
1303
|
rowCount: number;
|
|
1213
1304
|
totalBytes: number;
|
|
1214
1305
|
keys: string[];
|
|
1306
|
+
// Total key-slots across every loaded reader — every set AND every delete tombstone (a key stored in
|
|
1307
|
+
// N files counts N times) — and how many readers there are. Already in memory after the join, so
|
|
1308
|
+
// getKeyStats is ~free; rawKeyCount vs keys.length is how much duplication a compaction would collapse.
|
|
1309
|
+
rawKeyCount: number;
|
|
1310
|
+
readerCount: number;
|
|
1215
1311
|
columns: { column: string; byteSize: number }[];
|
|
1216
1312
|
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
1217
1313
|
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
|
|
@@ -1256,6 +1352,8 @@ async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<R
|
|
|
1256
1352
|
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
1257
1353
|
rowCount: keys.length,
|
|
1258
1354
|
keys,
|
|
1355
|
+
rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
|
|
1356
|
+
readerCount: databases.length,
|
|
1259
1357
|
columns,
|
|
1260
1358
|
async getColumn(column) {
|
|
1261
1359
|
const perReader = await Promise.all(databases.map(async db => {
|
|
@@ -51,6 +51,7 @@ export type DirectoryWrapper = {
|
|
|
51
51
|
readonly kind: "directory";
|
|
52
52
|
readonly name: string;
|
|
53
53
|
readonly fullPath?: string;
|
|
54
|
+
readonly isRemote?: boolean;
|
|
54
55
|
removeEntry(key: string, options?: {
|
|
55
56
|
recursive?: boolean;
|
|
56
57
|
}): Promise<void>;
|
|
@@ -138,6 +139,7 @@ export type NestedFileStorage = {
|
|
|
138
139
|
};
|
|
139
140
|
export type FileStorage = IStorageRaw & {
|
|
140
141
|
folder: NestedFileStorage;
|
|
142
|
+
isRemote?: boolean;
|
|
141
143
|
};
|
|
142
144
|
export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
|
|
143
145
|
export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
|
|
@@ -63,6 +63,9 @@ export type DirectoryWrapper = {
|
|
|
63
63
|
// Full path from the storage root, for diagnostics/logging (the native handle doesn't expose paths,
|
|
64
64
|
// so it's optional). e.g. "bulkDatabases2/myCollection".
|
|
65
65
|
readonly fullPath?: string;
|
|
66
|
+
// True when this storage is served over the network (a remote server) rather than a local disk, so
|
|
67
|
+
// callers can adapt for the higher latency/cost (local/native handles leave it undefined = false).
|
|
68
|
+
readonly isRemote?: boolean;
|
|
66
69
|
removeEntry(key: string, options?: { recursive?: boolean }): Promise<void>;
|
|
67
70
|
getFileHandle(key: string, options?: { create?: boolean }): Promise<FileWrapper>;
|
|
68
71
|
getDirectoryHandle(key: string, options?: { create?: boolean }): Promise<DirectoryWrapper>;
|
|
@@ -509,6 +512,9 @@ export type NestedFileStorage = {
|
|
|
509
512
|
|
|
510
513
|
export type FileStorage = IStorageRaw & {
|
|
511
514
|
folder: NestedFileStorage;
|
|
515
|
+
// Mirrors DirectoryWrapper.isRemote: true when this storage is served over the network. Lets callers
|
|
516
|
+
// (e.g. BulkDatabase2, which skips automatic compaction over the network by default) adapt.
|
|
517
|
+
isRemote?: boolean;
|
|
512
518
|
};
|
|
513
519
|
|
|
514
520
|
let appendQueue = cache((key: string) => {
|
|
@@ -705,6 +711,7 @@ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
|
|
|
705
711
|
return {
|
|
706
712
|
...wrapHandleFiles(handle),
|
|
707
713
|
folder: wrapHandleNested(handle),
|
|
714
|
+
isRemote: handle.isRemote,
|
|
708
715
|
};
|
|
709
716
|
}
|
|
710
717
|
|
|
@@ -317,7 +317,16 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
|
|
|
317
317
|
if (!accessLog.size) return;
|
|
318
318
|
const rows = [...accessLog.values()].sort((a, b) => b.bytes - a.bytes);
|
|
319
319
|
accessLog.clear();
|
|
320
|
-
|
|
320
|
+
// Fixed-width columns first so lines align, then the variable-length path last. count is the
|
|
321
|
+
// number of requests folded into this row (R = requests, not a multiplier).
|
|
322
|
+
const time = new Date().toTimeString().slice(0, 8);
|
|
323
|
+
for (const e of rows) {
|
|
324
|
+
const size = formatBytes(e.bytes).padStart(9);
|
|
325
|
+
const reqs = (e.count + "R").padStart(6);
|
|
326
|
+
const op = `[${e.op.padEnd(5)}]`;
|
|
327
|
+
const ip = e.ip.padEnd(15);
|
|
328
|
+
console.log(` ${time} ${size} ${reqs} ${op} ${ip} ${e.path}`);
|
|
329
|
+
}
|
|
321
330
|
}, 5000);
|
|
322
331
|
flushTimer.unref?.();
|
|
323
332
|
}
|
|
@@ -377,6 +377,7 @@ class RemoteDirectoryWrapper implements DirectoryWrapper {
|
|
|
377
377
|
// Mirror the native FileSystemDirectoryHandle shape (name/kind/entries) so code written against the
|
|
378
378
|
// native API — e.g. recursive walks using `handle.entries()` — works the same over the network.
|
|
379
379
|
readonly kind = "directory" as const;
|
|
380
|
+
readonly isRemote = true;
|
|
380
381
|
get name() { return baseName(this.dirPath); }
|
|
381
382
|
get fullPath() { return this.dirPath; }
|
|
382
383
|
async removeEntry(key: string): Promise<void> {
|