sliftutils 1.4.48 → 1.4.50
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 +368 -70
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +36 -70
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +290 -1243
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +32 -0
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +224 -19
- package/storage/BulkDatabase2/BulkDatabaseMerge.d.ts +49 -0
- package/storage/BulkDatabase2/BulkDatabaseMerge.ts +398 -0
- package/storage/BulkDatabase2/BulkDatabaseReader.d.ts +71 -0
- package/storage/BulkDatabase2/BulkDatabaseReader.ts +273 -0
- package/storage/BulkDatabase2/LoadedIndex.d.ts +133 -0
- package/storage/BulkDatabase2/LoadedIndex.ts +402 -0
- package/storage/BulkDatabase2/WriteOverlay.d.ts +30 -0
- package/storage/BulkDatabase2/WriteOverlay.ts +52 -0
- package/storage/BulkDatabase2/blockCache.d.ts +1 -0
- package/storage/BulkDatabase2/blockCache.ts +6 -0
- package/storage/BulkDatabase2/streamLog.ts +78 -1
|
@@ -1,136 +1,67 @@
|
|
|
1
1
|
import { sort } from "socket-function/src/misc";
|
|
2
2
|
import { getTimeUnique } from "socket-function/src/bits";
|
|
3
|
-
import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, BulkHeaderInfo, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase, loadBulkHeader, TARGET_FILE_BYTES } from "./BulkDatabaseFormat";
|
|
4
3
|
import { lazy } from "socket-function/src/caching";
|
|
5
|
-
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
|
-
import { blue, red } from "socket-function/src/formatting/logColors";
|
|
7
|
-
import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
|
|
8
|
-
import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
|
|
9
|
-
import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
10
|
-
import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
|
|
11
4
|
import { isNode } from "typesafecss";
|
|
12
5
|
import type { FileStorage } from "../FileFolderAPI";
|
|
6
|
+
import {
|
|
7
|
+
BaseBulkDatabaseReader,
|
|
8
|
+
BulkHeaderInfo,
|
|
9
|
+
buildFileBuffer,
|
|
10
|
+
loadBulkHeader,
|
|
11
|
+
TARGET_FILE_BYTES,
|
|
12
|
+
} from "./BulkDatabaseFormat";
|
|
13
|
+
import { runPlannedMerge } from "./BulkDatabaseMerge";
|
|
14
|
+
import { blockCache, encodeCompressedBlocks } from "./blockCache";
|
|
15
|
+
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
16
|
+
import { blue } from "socket-function/src/formatting/logColors";
|
|
17
|
+
import { STREAM_EXTENSION, frameDeletes, frameRows, streamReaderFromEntries } from "./streamLog";
|
|
18
|
+
import { broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, connect as syncConnect, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
19
|
+
import { DELETED } from "./WriteOverlay";
|
|
20
|
+
import { releaseMergeLock, tryAcquireMergeLock } from "./mergeLock";
|
|
21
|
+
import {
|
|
22
|
+
BulkFileInfo,
|
|
23
|
+
LoadedIndex,
|
|
24
|
+
loadFileReader,
|
|
25
|
+
loadStreamEntries,
|
|
26
|
+
makeRawGetRange,
|
|
27
|
+
MissingFileError,
|
|
28
|
+
orderStreamEntries,
|
|
29
|
+
StreamFileInfo,
|
|
30
|
+
SubReaderCaches,
|
|
31
|
+
} from "./LoadedIndex";
|
|
32
|
+
import { BulkDatabaseReader, nullJoin } from "./BulkDatabaseReader";
|
|
13
33
|
|
|
14
|
-
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
// KNOWN BUGS (accepted, documented):
|
|
16
|
-
//
|
|
17
|
-
// • Inconsistent directory listing under concurrent merges. A read lists the directory, then loads
|
|
18
|
-
// each listed file. If a file is missing when we go to read it (a merge deleted it), we re-list and
|
|
19
|
-
// retry — the deleting merge wrote the replacement first, so the data is never gone, just moved.
|
|
20
|
-
// But a directory listing is not guaranteed atomic on every filesystem: a listing taken while
|
|
21
|
-
// another writer is mid-swap can in principle return a set of files that never simultaneously
|
|
22
|
-
// existed (e.g. the replacement but not a sibling it depends on). That yields a momentarily
|
|
23
|
-
// INCONSISTENT view — some keys may read stale or missing. It does NOT lose data on disk: a reload
|
|
24
|
-
// (refresh the page) re-lists and resolves correctly. We accept this rather than reintroduce a
|
|
25
|
-
// manifest; it's rare and OS/filesystem-dependent.
|
|
26
|
-
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
// BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
|
|
29
|
-
// folder rather than sharing bulkDatabases/. Exported so a server-side compactor can find collections.
|
|
30
34
|
export const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
31
35
|
const FILE_EXTENSION = ".bulk";
|
|
32
|
-
// A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
|
|
33
|
-
// into a bulk file (streaming thousands of rows one frame at a time would be pointless).
|
|
34
36
|
const ROLLOVER_ROWS = 5000;
|
|
35
37
|
const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
36
|
-
|
|
37
|
-
// How often the global memory-pressure watchdog samples the heap (the ACTION it may take is throttled
|
|
38
|
-
// separately, see bulkDatabase2Timing.memoryFlushThrottleMs).
|
|
39
38
|
const MEMORY_WATCHDOG_INTERVAL_MS = 60 * 1000;
|
|
40
|
-
|
|
41
|
-
// An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
|
|
42
|
-
// can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
|
|
43
|
-
// no writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
44
39
|
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
45
|
-
|
|
46
|
-
// A read lists the directory then loads each file; if a file vanished (a merge deleted it) we re-list
|
|
47
|
-
// and retry, since the merge wrote the replacement first. Bounded so a pathological merge storm can't
|
|
48
|
-
// loop forever — after this many tries we load whatever's currently there (the documented inconsistent
|
|
49
|
-
// -view bug), which a later reload resolves.
|
|
50
|
-
const MAX_READ_ATTEMPTS = 8;
|
|
51
|
-
|
|
52
|
-
// A read that hits a file a concurrent merge deleted reloads the index and retries; if it keeps hitting
|
|
53
|
-
// missing files this many times it gives up and throws (so a genuinely-unreadable file can't loop forever).
|
|
54
40
|
const MAX_INDEX_RELOAD_ATTEMPTS = 3;
|
|
55
|
-
|
|
56
|
-
// The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
|
|
57
|
-
// file (half the target, so it has room to grow before it needs splitting). The key-stratified second
|
|
58
|
-
// merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
|
|
59
|
-
// (multi-file) keys exceeds DUP_THRESHOLD — i.e. only when deduping actually buys enough.
|
|
60
41
|
const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
|
|
61
42
|
const KEY_GROUP_BYTES = 800 * 1024 * 1024;
|
|
62
43
|
const DUP_THRESHOLD = 0.4;
|
|
63
|
-
|
|
64
|
-
// The browser File System Access API has no real append — every "append" rewrites the whole file — so
|
|
65
|
-
// streaming one write at a time is quadratic. We coalesce stream writes and flush on a RAMPING delay:
|
|
66
|
-
// the first write after a lull flushes immediately (a single checkbox-then-close is saved at once), and
|
|
67
|
-
// as writes keep coming the delay doubles from this step up to writeFlushMaxDelayMs, batching a burst
|
|
68
|
-
// into one rewrite. (Node has a real append, so there the default delay is 0 = flush every write.)
|
|
69
44
|
const WRITE_FLUSH_FIRST_STEP_MS = 250;
|
|
70
45
|
|
|
71
|
-
// Time thresholds, mutable so tests can shrink them from hours to milliseconds.
|
|
72
46
|
export const bulkDatabase2Timing = {
|
|
73
|
-
// A writer stops appending to its current stream file once it's this old (starts a fresh one). A
|
|
74
|
-
// stream file older than this is therefore safe for a merge to delete: its writer has provably moved
|
|
75
|
-
// on to a new file and will never append to it again.
|
|
76
47
|
streamSealAgeMs: 10 * 60 * 60 * 1000,
|
|
77
|
-
// Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
|
|
78
48
|
mergeCheckIntervalMs: 30 * 60 * 1000,
|
|
79
|
-
// A single testMerge can do several merges (one pass-1 + several pass-2 groups). We wait this long
|
|
80
|
-
// after each one before the next, so a burst of writes doesn't rewrite every index on disk at once —
|
|
81
|
-
// it spreads the work out to keep peak lag low (important in the browser). The merge lock is kept
|
|
82
|
-
// alive across the wait. Set to 0 to disable spacing (tests).
|
|
83
49
|
mergeSpacingMs: 5 * 60 * 1000,
|
|
84
|
-
// The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
|
|
85
50
|
firstMergeTriggerFiles: 20,
|
|
86
|
-
// ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
|
|
87
51
|
firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
|
|
88
|
-
// Tier-0 stream data can't be read per-cell — a read pulls the whole stream — so once the stream
|
|
89
|
-
// exceeds BOTH of these it's folded into bulk (columnar, range-readable) promptly, bypassing the merge
|
|
90
|
-
// throttle. The byte bound matters most over the network, where pulling the whole stream is expensive.
|
|
91
52
|
streamFoldTriggerRows: 100,
|
|
92
53
|
streamFoldTriggerBytes: 64 * 1024 * 1024,
|
|
93
|
-
// Rolling cap on OUR OWN current stream file: once we've appended this much to it, we seal it (stop
|
|
94
|
-
// writing) and fold it into bulk immediately. Without this, many small appends grow a single stream
|
|
95
|
-
// file without bound (the per-batch ROLLOVER only catches one huge batch). We can fold our own file at
|
|
96
|
-
// once — the 10h seal-age rule is only for other writers' files; we know we're done with ours on seal.
|
|
97
54
|
streamFileMaxBytes: 50 * 1024 * 1024,
|
|
98
|
-
// HARD limit: once the stream tier passes this, fold it NOW regardless of age (even un-sealed, recent
|
|
99
|
-
// streams) — a stream this large makes every read pull a huge file, i.e. the collection becomes
|
|
100
|
-
// essentially unreadable. We still only delete a stream whose size didn't change while we read it, so
|
|
101
|
-
// a writer mid-append never loses data (it just gets re-folded next pass).
|
|
102
55
|
streamFoldHardLimitBytes: 768 * 1024 * 1024,
|
|
103
|
-
//
|
|
104
|
-
// this under sustained writing (see WRITE_FLUSH_FIRST_STEP_MS). 0 = flush every write immediately —
|
|
105
|
-
// the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
|
|
56
|
+
// 0 = flush every write (Node — append is real and cheap); browser ramps to 15s to avoid rewriting
|
|
106
57
|
// the whole stream file per write.
|
|
107
58
|
writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
|
|
108
|
-
// We proactively re-list files this often; if the set changed under us (another tab/process merged) we
|
|
109
|
-
// reload the index, so reads pick up the change even without first hitting a missing-file error.
|
|
110
59
|
fileSetPollIntervalMs: 30 * 60 * 1000,
|
|
111
|
-
// Memory-pressure auto-flush (browser only; needs performance.memory). When the JS heap exceeds
|
|
112
|
-
// memoryFlushHeapBytes, at most once per memoryFlushThrottleMs, drop the in-memory observable state of
|
|
113
|
-
// every collection whose loaded data exceeds memoryFlushMinCollectionBytes (they reload lazily). Tuned
|
|
114
|
-
// so it does nothing for a healthy app (heap fine) or small collections.
|
|
115
60
|
memoryFlushHeapBytes: 1500 * 1024 * 1024,
|
|
116
61
|
memoryFlushMinCollectionBytes: 100 * 1024 * 1024,
|
|
117
62
|
memoryFlushThrottleMs: 15 * 60 * 1000,
|
|
118
63
|
};
|
|
119
64
|
|
|
120
|
-
// Marks a key as deleted in the in-memory overlay.
|
|
121
|
-
const DELETED = Symbol("deleted");
|
|
122
|
-
// Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
|
|
123
|
-
// remote write only overrides a key if it's newer than what we already have.
|
|
124
|
-
type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
|
|
125
|
-
|
|
126
|
-
// Composite cache/signal keys join two arbitrary strings with a NUL separator (which can't occur in
|
|
127
|
-
// the inputs). NUL is built from a char code so an actual NUL byte never appears in this source file
|
|
128
|
-
// (which would otherwise make tools treat it as binary).
|
|
129
|
-
const NULL = String.fromCharCode(0);
|
|
130
|
-
function nullJoin(a: string, b: string): string {
|
|
131
|
-
return a + NULL + b;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
65
|
function fmtBytes(n: number): string {
|
|
135
66
|
if (n < 1024) return n + "B";
|
|
136
67
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB";
|
|
@@ -138,26 +69,17 @@ function fmtBytes(n: number): string {
|
|
|
138
69
|
return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
|
139
70
|
}
|
|
140
71
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// we "observe" a signal, and whenever it changes we "invalidate" that signal. A consumer that wants
|
|
144
|
-
// reactivity (e.g. the mobx subclass) supplies a ReactiveDeps that maps each signal string onto its
|
|
145
|
-
// own framework's dependency tracking; a consumer that doesn't can pass noopReactiveDeps.
|
|
72
|
+
// Reactivity seam (no mobx dependency in this file). The mobx subclass supplies a ReactiveDeps that
|
|
73
|
+
// maps signal strings to its own dependency tracking; non-reactive callers pass noopReactiveDeps.
|
|
146
74
|
export interface ReactiveDeps {
|
|
147
|
-
// Register `signal` as a dependency of whatever reactive context is currently reading.
|
|
148
75
|
observe(signal: string): void;
|
|
149
|
-
// Notify any context that observed `signal` that it changed.
|
|
150
76
|
invalidate(signal: string): void;
|
|
151
|
-
// Run a group of mutations + invalidations as one batch, so observers re-run at most once.
|
|
152
77
|
batch(fn: () => void): void;
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// nothing is watching (see isWatchedSync).
|
|
78
|
+
// Optional — lets writes skip per-key invalidation for rows nothing is watching. Undefined =
|
|
79
|
+
// "assume watched".
|
|
156
80
|
isObserved?(signal: string): boolean;
|
|
157
81
|
}
|
|
158
82
|
|
|
159
|
-
// A non-reactive ReactiveDeps: sync reads still return current values, they just never trigger
|
|
160
|
-
// re-renders. Use this when you don't need a UI to react to writes. Nothing is ever observed.
|
|
161
83
|
export const noopReactiveDeps: ReactiveDeps = {
|
|
162
84
|
observe() { },
|
|
163
85
|
invalidate() { },
|
|
@@ -165,82 +87,44 @@ export const noopReactiveDeps: ReactiveDeps = {
|
|
|
165
87
|
isObserved() { return false; },
|
|
166
88
|
};
|
|
167
89
|
|
|
168
|
-
// Provides the FileStorage for a given path (the caller decides where data physically lives, so this
|
|
169
|
-
// file needn't know about getFileStorageNested2 / the browser-vs-node storage details).
|
|
170
90
|
export type StorageFactory = (path: string) => Promise<FileStorage>;
|
|
171
91
|
|
|
172
|
-
// Optional per-collection configuration.
|
|
173
92
|
export type BulkDatabase2Config = {
|
|
174
|
-
//
|
|
175
|
-
// never applied all at once. When set (> 0), the notifications writes/loads emit are batched globally
|
|
176
|
-
// (across all keys) so a high-frequency write source doesn't re-run watchers on every single change: a
|
|
177
|
-
// change after a lull notifies immediately, then under sustained changes the delay doubles up to this
|
|
178
|
-
// ceiling, coalescing the burst into one notification. In-memory/async reads are always current — only
|
|
179
|
-
// the OBSERVABLE notification (the mobx re-render trigger) is delayed and merged.
|
|
93
|
+
// See BulkDatabaseReader.cfg.maxTriggerThrottleMs.
|
|
180
94
|
maxTriggerThrottleMs?: number;
|
|
181
95
|
};
|
|
182
96
|
|
|
183
|
-
// Trigger-throttle ramp: the first deferred notification waits this long, then the delay doubles on each
|
|
184
|
-
// further change up to BulkDatabase2Config.maxTriggerThrottleMs. ~16ms ≈ one animation frame, so an
|
|
185
|
-
// isolated burst still notifies within a frame.
|
|
186
|
-
const TRIGGER_THROTTLE_FIRST_STEP_MS = 16;
|
|
187
|
-
|
|
188
|
-
// The load/reset lifecycle shares one signal; every sync read observes it so it re-renders when the
|
|
189
|
-
// reader resets or a base column/field finishes loading. The overlay's per-key signal is the key
|
|
190
|
-
// itself (a point read observes just its key), plus one overlay-wide signal that whole-column reads
|
|
191
|
-
// observe so they recompute on any overlay change. The NUL prefix keeps the two special signals
|
|
192
|
-
// from ever colliding with a real data key.
|
|
193
|
-
const LOAD_SIGNAL = NULL + "load";
|
|
194
|
-
const OVERLAY_SIGNAL = NULL + "overlay";
|
|
195
|
-
|
|
196
|
-
// Over network storage we skip automatic (background) compaction by default — reading and rewriting whole
|
|
197
|
-
// files over the network is expensive. An app that wants it anyway opts in once via
|
|
198
|
-
// BulkDatabaseBase.enableNetworkCompaction(). Explicit compact()/tryMergeNow() calls are unaffected.
|
|
199
97
|
let networkCompactionEnabled = false;
|
|
200
98
|
|
|
201
99
|
let fileNameCounter = 0;
|
|
202
|
-
//
|
|
203
|
-
// never collide on a name when they pick the same timestamp/counter in the same millisecond.
|
|
100
|
+
// Per-process ID so two writers picking the same timestamp + counter never collide on a name.
|
|
204
101
|
const writerId = Math.random().toString(36).slice(2, 10);
|
|
205
|
-
function nextCounter(): number {
|
|
206
|
-
return ++fileNameCounter;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
102
|
+
function nextCounter(): number { return ++fileNameCounter; }
|
|
210
103
|
|
|
211
104
|
let lastFileTime = 0;
|
|
212
|
-
//
|
|
213
|
-
// unambiguous even when several writes land in the same millisecond. (getTimeUnique isn't used here
|
|
214
|
-
// because it may return a fractional value, which wouldn't round-trip through the integer file name.)
|
|
105
|
+
// Strictly-increasing integer so newest-first ordering is unambiguous within a millisecond.
|
|
215
106
|
function nextFileTime(): number {
|
|
216
107
|
lastFileTime = Math.max(Date.now(), lastFileTime + 1);
|
|
217
108
|
return lastFileTime;
|
|
218
109
|
}
|
|
219
110
|
|
|
220
|
-
// Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
|
|
221
|
-
// of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
|
|
222
|
-
// field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
|
|
223
111
|
function newFileName(timestamp: number): string {
|
|
224
112
|
return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
|
|
225
113
|
}
|
|
226
114
|
|
|
227
|
-
type StreamFileInfo = { fileName: string; timestamp: number };
|
|
228
|
-
|
|
229
115
|
function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
|
|
230
116
|
if (!fileName.endsWith(STREAM_EXTENSION)) return undefined;
|
|
231
117
|
const parts = fileName.slice(0, -STREAM_EXTENSION.length).split("_");
|
|
232
|
-
// stream_<timestamp>_<random>
|
|
233
118
|
if (parts.length !== 3 || parts[0] !== "stream") return undefined;
|
|
234
119
|
const timestamp = parseInt(parts[1], 10);
|
|
235
120
|
if (!Number.isFinite(timestamp)) return undefined;
|
|
236
121
|
return { fileName, timestamp };
|
|
237
122
|
}
|
|
238
123
|
|
|
124
|
+
// Accept old 3-part (level_timestamp_counter) and new 4-part (level_timestamp_writerId_counter) shapes.
|
|
239
125
|
function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
240
126
|
if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
|
|
241
127
|
const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
|
|
242
|
-
// Accept both the old 3-part (level_timestamp_counter) and new 4-part
|
|
243
|
-
// (level_timestamp_writerId_counter) shapes; level + timestamp are always the first two fields.
|
|
244
128
|
if (parts.length < 3) return undefined;
|
|
245
129
|
const level = parseInt(parts[0], 10);
|
|
246
130
|
const timestamp = parseInt(parts[1], 10);
|
|
@@ -248,14 +132,6 @@ function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
|
248
132
|
return { fileName, level, timestamp };
|
|
249
133
|
}
|
|
250
134
|
|
|
251
|
-
// A file we listed is gone now (a concurrent merge deleted it after writing its replacement). Distinct
|
|
252
|
-
// from a corrupt/torn file: missing => the data moved, so re-list and retry; corrupt => skip the file.
|
|
253
|
-
class MissingFileError extends Error { }
|
|
254
|
-
// Thrown out of a read build when a listed file went missing mid-load, so the build re-lists and retries.
|
|
255
|
-
class FilesChangedError extends Error { }
|
|
256
|
-
|
|
257
|
-
// All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
|
|
258
|
-
// Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
|
|
259
135
|
export class BulkDatabaseBase<T extends { key: string }> {
|
|
260
136
|
constructor(
|
|
261
137
|
public readonly name: string,
|
|
@@ -263,9 +139,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
263
139
|
private storageFactory: StorageFactory,
|
|
264
140
|
private config: BulkDatabase2Config = {},
|
|
265
141
|
) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
142
|
+
this.reader = new BulkDatabaseReader<T>({
|
|
143
|
+
name,
|
|
144
|
+
deps,
|
|
145
|
+
maxTriggerThrottleMs: config.maxTriggerThrottleMs,
|
|
146
|
+
});
|
|
147
|
+
this.reader.setEnsureIndex(() => this.ensureIndex());
|
|
148
|
+
|
|
269
149
|
if (typeof window !== "undefined") {
|
|
270
150
|
try {
|
|
271
151
|
window.addEventListener("pagehide", () => void this.flushPending());
|
|
@@ -276,21 +156,36 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
276
156
|
}
|
|
277
157
|
} catch { /* not in a DOM context */ }
|
|
278
158
|
}
|
|
279
|
-
// Proactively notice another writer's merge (files added/removed) and reload the index, so reads
|
|
280
|
-
// stay correct even without first hitting a missing-file error.
|
|
281
159
|
this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
|
|
282
160
|
(this.fileSetPollTimer as { unref?: () => void }).unref?.();
|
|
283
|
-
// Register for the global memory-pressure watchdog (browser only — needs performance.memory).
|
|
284
161
|
BulkDatabaseBase.liveInstances.add(this);
|
|
285
162
|
BulkDatabaseBase.startMemoryWatchdog();
|
|
286
163
|
}
|
|
287
164
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
165
|
+
private reader: BulkDatabaseReader<T>;
|
|
166
|
+
private subCaches: SubReaderCaches = { bulk: new Map(), stream: new Map() };
|
|
167
|
+
|
|
168
|
+
private pendingAppends: { framed: Buffer; rows: number }[] = [];
|
|
169
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
170
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
171
|
+
private currentFlushDelay = 0;
|
|
172
|
+
private lastWriteTime = 0;
|
|
173
|
+
|
|
174
|
+
private streamFileName: string | undefined;
|
|
175
|
+
private currentStreamFileName: string | undefined;
|
|
176
|
+
private currentStreamFileBytes = 0;
|
|
177
|
+
private lastMergeCheck = Date.now();
|
|
178
|
+
// Running counters of stream-tier rows + bytes on disk. Seeded from each LoadedIndex build, then
|
|
179
|
+
// incremented per flush so the fold-trigger checks current data without an extra directory listing.
|
|
180
|
+
private streamRowsOnDisk = 0;
|
|
181
|
+
private streamBytesOnDisk = 0;
|
|
182
|
+
|
|
183
|
+
private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
184
|
+
private rebuildPromise: Promise<void> | undefined;
|
|
185
|
+
private rebuildDirty = false;
|
|
186
|
+
private rebuildOptions: { dropStaleFallback: boolean } = { dropStaleFallback: false };
|
|
187
|
+
|
|
188
|
+
// ── memory-pressure watchdog (global, browser-only) ──
|
|
294
189
|
private static liveInstances = new Set<BulkDatabaseBase<{ key: string }>>();
|
|
295
190
|
private static memoryWatchdogStarted = false;
|
|
296
191
|
private static lastMemoryFlushMs = 0;
|
|
@@ -301,16 +196,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
301
196
|
try { return (performance as unknown as { memory?: { usedJSHeapSize?: number } })?.memory?.usedJSHeapSize; }
|
|
302
197
|
catch { return undefined; }
|
|
303
198
|
};
|
|
304
|
-
if (typeof performance === "undefined" || usedHeap() === undefined) return;
|
|
199
|
+
if (typeof performance === "undefined" || usedHeap() === undefined) return;
|
|
305
200
|
const timer = setInterval(() => {
|
|
306
201
|
const used = usedHeap();
|
|
307
202
|
if (used !== undefined) BulkDatabaseBase.checkMemoryPressure(used);
|
|
308
203
|
}, MEMORY_WATCHDOG_INTERVAL_MS);
|
|
309
204
|
(timer as { unref?: () => void }).unref?.();
|
|
310
205
|
}
|
|
311
|
-
// If the heap is over the limit (and we haven't flushed recently), drop the in-memory observable state
|
|
312
|
-
// of every collection whose loaded data exceeds the size threshold, so it reloads lazily and frees the
|
|
313
|
-
// rest. Exposed (with the heap value injected) so it's callable/testable without the real timer.
|
|
314
206
|
public static checkMemoryPressure(usedHeapBytes: number): void {
|
|
315
207
|
if (usedHeapBytes < bulkDatabase2Timing.memoryFlushHeapBytes) return;
|
|
316
208
|
const now = Date.now();
|
|
@@ -318,439 +210,147 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
318
210
|
BulkDatabaseBase.lastMemoryFlushMs = now;
|
|
319
211
|
const flushed: string[] = [];
|
|
320
212
|
for (const db of BulkDatabaseBase.liveInstances) {
|
|
321
|
-
|
|
322
|
-
|
|
213
|
+
const bytes = db.reader.index?.totalBytes ?? 0;
|
|
214
|
+
if (bytes > bulkDatabase2Timing.memoryFlushMinCollectionBytes) {
|
|
215
|
+
flushed.push(`${db.name} (${fmtBytes(bytes)})`);
|
|
323
216
|
db.reloadFromDisk();
|
|
324
217
|
}
|
|
325
218
|
}
|
|
326
|
-
if (flushed.length) console.log(`[bulk2] heap ${fmtBytes(usedHeapBytes)} over ${fmtBytes(bulkDatabase2Timing.memoryFlushHeapBytes)} — flushed
|
|
219
|
+
if (flushed.length) console.log(`[bulk2] heap ${fmtBytes(usedHeapBytes)} over ${fmtBytes(bulkDatabase2Timing.memoryFlushHeapBytes)} — flushed ${flushed.length} large collection(s): ${flushed.join(", ")}`);
|
|
327
220
|
}
|
|
328
221
|
|
|
329
|
-
// ---- buffered stream writes (per collection) ----
|
|
330
|
-
// The browser rewrites the whole stream file on every append, so we coalesce writes and flush on a
|
|
331
|
-
// ramping delay (see WRITE_FLUSH_FIRST_STEP_MS / writeFlushMaxDelayMs). Each buffered item keeps an
|
|
332
|
-
// `apply` that re-applies its overlay mutation, so resetReader (which clears the overlay) can restore
|
|
333
|
-
// writes that aren't on disk yet. Removed from the buffer only once their append succeeds.
|
|
334
|
-
private pendingAppends: { framed: Buffer; apply: () => void; rows: number }[] = [];
|
|
335
|
-
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
336
|
-
private flushChain: Promise<void> = Promise.resolve();
|
|
337
|
-
private currentFlushDelay = 0;
|
|
338
|
-
private lastWriteTime = 0;
|
|
339
|
-
|
|
340
|
-
// Block range cache is global and immutable-file-safe; clear it to simulate a cold page load (e.g.
|
|
341
|
-
// between an untimed prep step and the timed benchmark). The per-instance sub-reader caches need no
|
|
342
|
-
// clearing here — a cold load is a fresh instance, which starts with empty caches.
|
|
343
222
|
public static clearCache() {
|
|
344
223
|
blockCache.clear();
|
|
345
224
|
}
|
|
346
225
|
|
|
347
|
-
// Opt in to automatic compaction even when the storage is remote (off by default — see
|
|
348
|
-
// networkCompactionEnabled). Global; affects every collection. Explicit compact()/tryMergeNow() always
|
|
349
|
-
// run regardless of this.
|
|
350
226
|
public static enableNetworkCompaction() {
|
|
351
227
|
networkCompactionEnabled = true;
|
|
352
228
|
}
|
|
353
229
|
|
|
354
230
|
public storage = lazy(async () => this.storageFactory(`${BULK_ROOT_FOLDER}/${this.name}`));
|
|
355
231
|
|
|
356
|
-
// True when this collection's storage is served over the network (a remote server). Apps can branch on
|
|
357
|
-
// this to adjust behavior for the higher latency (e.g. show a "slower storage" hint, prefetch less).
|
|
358
232
|
public async isRemote(): Promise<boolean> {
|
|
359
233
|
return !!(await this.storage()).isRemote;
|
|
360
234
|
}
|
|
361
235
|
|
|
362
|
-
// Whether the tier-0 stream is big enough to fold into bulk now (both bounds): too many rows AND too
|
|
363
|
-
// many bytes to keep reading whole. See bulkDatabase2Timing.streamFoldTrigger*.
|
|
364
236
|
private streamNeedsFold(): boolean {
|
|
365
|
-
return this.streamRowsOnDisk >= bulkDatabase2Timing.streamFoldTriggerRows
|
|
237
|
+
return this.streamRowsOnDisk >= bulkDatabase2Timing.streamFoldTriggerRows
|
|
238
|
+
&& this.streamBytesOnDisk > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
366
239
|
}
|
|
367
240
|
|
|
368
|
-
// Automatic (background) compaction is skipped over the network unless the app opted in. Explicit
|
|
369
|
-
// compact()/tryMergeNow() bypass this.
|
|
370
241
|
private async automaticCompactionAllowed(): Promise<boolean> {
|
|
371
242
|
if (networkCompactionEnabled) return true;
|
|
372
243
|
return !(await this.storage()).isRemote;
|
|
373
244
|
}
|
|
374
245
|
|
|
375
|
-
// In-memory overlay of pending writes/deletes. It takes priority over the loaded readers, so writes
|
|
376
|
-
// are reflected in reads without reloading. Reads observe the relevant signal; mutations invalidate it.
|
|
377
|
-
//
|
|
378
|
-
// NOTE: we never bound or clear this in-memory state during normal operation (only on a structural
|
|
379
|
-
// rollover/reset, where the data has been persisted into bulk files). The whole database must be
|
|
380
|
-
// resident in memory anyway — file merging reads every row — so a database large enough to blow
|
|
381
|
-
// the in-memory cache would already fail at merge time. There is no partial-load mode.
|
|
382
|
-
private overlay = new Map<string, OverlayEntry>();
|
|
383
|
-
// Latest stream-on-disk timestamp per key (from the loaded stream files). Used together with the
|
|
384
|
-
// overlay to decide whether an incoming remote write is actually newer than what we have.
|
|
385
|
-
private streamTimes = new Map<string, number>();
|
|
386
|
-
|
|
387
|
-
// Cache of fully-resolved (overlay-patched) column results, keyed by column name. The result only
|
|
388
|
-
// changes when the overlay mutates or the reader resets, so we keep it until then — repeat whole-column
|
|
389
|
-
// reads (common in a UI re-render) are then free, however large the column. Each cached array is frozen
|
|
390
|
-
// SHALLOWLY: Object.freeze locks the array itself (callers can't mutate a shared result) but never the
|
|
391
|
-
// element objects or their values, so typed-array column values keep their fast representation.
|
|
392
|
-
private columnCache = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
393
|
-
// Live keys of the currently-loaded reader (disk only, no overlay), so a write can tell a partial
|
|
394
|
-
// update of an existing key (only the written columns change) from adding/removing a key (which appears
|
|
395
|
-
// in / disappears from EVERY column). Set on reader build, cleared on reset.
|
|
396
|
-
private readerKeys: Set<string> | undefined;
|
|
397
|
-
|
|
398
|
-
// ---- the on-disk index ----
|
|
399
|
-
// The "index" is the loaded reader (file headers + key columns joined into a resolved view) plus the
|
|
400
|
-
// set of files it was built from. A concurrent merge in another tab/process rewrites files out from
|
|
401
|
-
// under us; we notice either when a read hits a deleted file (readWithReload) or via the periodic poll
|
|
402
|
-
// (pollFileSet), and reload the index — cheap, since a build only reads headers + key columns.
|
|
403
|
-
private loadedFileSet: Set<string> | undefined; // file names the current reader was built from
|
|
404
|
-
// Total (uncompressed) size of the data the current reader spans, as a proxy for this collection's
|
|
405
|
-
// in-memory footprint — used by the memory-pressure watchdog. 0 when no reader is loaded.
|
|
406
|
-
private loadedTotalBytes = 0;
|
|
407
|
-
// Bumped every time the index is cleared/reloaded. A failed read captures it before reading and only
|
|
408
|
-
// triggers a reload if it's unchanged afterward — so N concurrent failures coalesce onto ONE rebuild
|
|
409
|
-
// (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
|
|
410
|
-
private readerEpoch = 0;
|
|
411
|
-
private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
412
|
-
|
|
413
|
-
// Per-file decoded sub-reader caches (keyed by fileName), so reloading the index re-reads only the
|
|
414
|
-
// files that actually changed — the stitch (join) still runs, but unchanged files skip re-decoding.
|
|
415
|
-
// Per-INSTANCE (not global), because a cached reader's getRange is bound to THIS instance's storage
|
|
416
|
-
// backend; in production a collection has one backend, and a "fresh client" is a new process with empty
|
|
417
|
-
// caches anyway. Bulk files are immutable, so a name maps to fixed content (cache until the file is
|
|
418
|
-
// gone). Stream files are append-only, so size is the version: reuse on a size match, else parse only
|
|
419
|
-
// the appended suffix. Pruned per build for files a merge removed (pruneFileCaches); survive the reader
|
|
420
|
-
// reset (that's the point — a reload reuses them).
|
|
421
|
-
private bulkReaderCache = new Map<string, BaseBulkDatabaseReader>();
|
|
422
|
-
private streamReaderCache = new Map<string, { readSize: number; parsedPos: number; entries: StreamEntry[] }>();
|
|
423
|
-
// Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
|
|
424
|
-
// and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
|
|
425
|
-
// the cache and may swap the reader) can never leave a stale entry behind.
|
|
426
|
-
private dataGen = 0;
|
|
427
|
-
|
|
428
|
-
// ---- trigger throttle (see BulkDatabase2Config.maxTriggerThrottleMs) ----
|
|
429
|
-
// Signals whose notification is deferred, the pending flush timer, and the ramping delay. Data state is
|
|
430
|
-
// already updated synchronously; only these observable notifications are batched/delayed.
|
|
431
|
-
private pendingSignals = new Set<string>();
|
|
432
|
-
private triggerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
433
|
-
private currentTriggerDelay = 0;
|
|
434
|
-
private lastTriggerTime = 0;
|
|
435
|
-
|
|
436
|
-
// Approximate size of the tier-0 stream data on disk: set accurately from the last reader build, then
|
|
437
|
-
// kept current as each flush appends more. Drives the stream-fold trigger (see streamNeedsFold);
|
|
438
|
-
// reset on resetReader, since after a merge/reset the next reader build re-measures it.
|
|
439
|
-
private streamRowsOnDisk = 0;
|
|
440
|
-
private streamBytesOnDisk = 0;
|
|
441
|
-
|
|
442
|
-
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
443
|
-
// so concurrent writers never touch the same file.
|
|
444
|
-
private streamFileName: string | undefined;
|
|
445
|
-
// Rolling size of the current stream file, so we can seal+fold it once it passes STREAM_FILE_MAX_BYTES
|
|
446
|
-
// (reset whenever the file rotates). currentStreamFileName tracks which file the count belongs to.
|
|
447
|
-
private currentStreamFileName: string | undefined;
|
|
448
|
-
private currentStreamFileBytes = 0;
|
|
449
|
-
// Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
|
|
450
|
-
// first write — the first background merge check waits a full interval after construction.
|
|
451
|
-
private lastMergeCheck = Date.now();
|
|
452
|
-
private getStreamFileName(): string {
|
|
453
|
-
// Seal (stop appending to) our current file once it's old enough, so no file is ever appended
|
|
454
|
-
// to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
|
|
455
|
-
if (this.streamFileName) {
|
|
456
|
-
const info = parseStreamFileName(this.streamFileName);
|
|
457
|
-
if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
|
|
458
|
-
}
|
|
459
|
-
if (!this.streamFileName) {
|
|
460
|
-
this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
461
|
-
}
|
|
462
|
-
return this.streamFileName;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Invalidate after an overlay change. `columns` is the set of columns whose resolved result actually
|
|
466
|
-
// changed — only those cached columns are dropped — or "all" when the key set itself changed (a key
|
|
467
|
-
// added or removed appears in / disappears from every column).
|
|
468
|
-
private invalidateOverlay(key: string, columns: Iterable<string> | "all") {
|
|
469
|
-
this.dataGen++;
|
|
470
|
-
if (columns === "all") this.columnCache.clear();
|
|
471
|
-
else for (const c of columns) this.columnCache.delete(c);
|
|
472
|
-
// The per-key signal only re-runs getSingleFieldObjSync watchers of THIS key; skip it when nothing
|
|
473
|
-
// is watching the row (a new watcher reads the current overlay anyway). The overlay-wide signal +
|
|
474
|
-
// column-cache drop above still cover whole-column watchers and read correctness.
|
|
475
|
-
if (this.deps.isObserved?.(key) ?? true) this.invalidateSignal(key);
|
|
476
|
-
this.invalidateSignal(OVERLAY_SIGNAL);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Whether a row (key) is currently being watched — i.e. some reactive observer is subscribed to it via
|
|
480
|
-
// getSingleFieldObjSync / getSingleFieldSync. Useful for skipping per-row work when nothing's watching.
|
|
481
|
-
// Returns true if the backend can't tell (conservative).
|
|
482
246
|
public isKeyWatched(key: string): boolean {
|
|
483
|
-
return this.
|
|
247
|
+
return this.reader.isKeyWatched(key);
|
|
484
248
|
}
|
|
485
249
|
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
if (
|
|
492
|
-
return
|
|
250
|
+
// ── index lifecycle ──────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
private async ensureIndex(): Promise<LoadedIndex<T>> {
|
|
252
|
+
if (this.reader.index) return this.reader.index;
|
|
253
|
+
await this.triggerRebuild();
|
|
254
|
+
const idx = this.reader.index;
|
|
255
|
+
if (!idx) throw new Error(`${this.name}: index failed to build`);
|
|
256
|
+
return idx;
|
|
493
257
|
}
|
|
494
258
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
259
|
+
// Coalescing rebuild loop: triggers during a build set rebuildDirty so the loop iterates once more
|
|
260
|
+
// when it finishes — N rapid triggers cause at most ONE extra rebuild after the current one ends.
|
|
261
|
+
private triggerRebuild(opts: { dropStaleFallback?: boolean } = {}): Promise<void> {
|
|
262
|
+
if (opts.dropStaleFallback) this.rebuildOptions.dropStaleFallback = true;
|
|
263
|
+
if (this.rebuildPromise) {
|
|
264
|
+
this.rebuildDirty = true;
|
|
265
|
+
return this.rebuildPromise;
|
|
266
|
+
}
|
|
267
|
+
this.rebuildPromise = (async () => {
|
|
268
|
+
try {
|
|
269
|
+
do {
|
|
270
|
+
this.rebuildDirty = false;
|
|
271
|
+
await this.doOneRebuild();
|
|
272
|
+
} while (this.rebuildDirty);
|
|
273
|
+
} finally {
|
|
274
|
+
this.rebuildPromise = undefined;
|
|
275
|
+
this.rebuildOptions.dropStaleFallback = false;
|
|
276
|
+
}
|
|
277
|
+
})();
|
|
278
|
+
return this.rebuildPromise;
|
|
513
279
|
}
|
|
514
280
|
|
|
515
|
-
private
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
281
|
+
private async doOneRebuild(): Promise<void> {
|
|
282
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
283
|
+
const storage = await this.storage();
|
|
284
|
+
const newIndex = await LoadedIndex.build<T>({
|
|
285
|
+
name: this.name,
|
|
286
|
+
storage,
|
|
287
|
+
bulkFiles,
|
|
288
|
+
streamFiles,
|
|
289
|
+
subCaches: this.subCaches,
|
|
290
|
+
onUnreadableFile: (f, msg) => this.handleUnreadableFile(f, msg),
|
|
291
|
+
});
|
|
292
|
+
const oldIndex = this.reader.index;
|
|
293
|
+
this.reader.setIndex(newIndex, { dropStaleFallback: this.rebuildOptions.dropStaleFallback });
|
|
294
|
+
this.streamRowsOnDisk = newIndex.streamRowsOnDisk;
|
|
295
|
+
this.streamBytesOnDisk = newIndex.streamBytesOnDisk;
|
|
296
|
+
if (oldIndex) {
|
|
297
|
+
for (const f of oldIndex.fileSet) {
|
|
298
|
+
if (!newIndex.fileSet.has(f)) blockCache.evict(nullJoin(this.name, f));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
520
301
|
}
|
|
521
302
|
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const wasLive = this.isLiveNow(key);
|
|
529
|
-
const existing = this.overlay.get(key);
|
|
530
|
-
const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
|
|
531
|
-
this.overlay.set(key, { time, value });
|
|
532
|
-
this.invalidateOverlay(key, wasLive ? Object.keys(row) : "all");
|
|
303
|
+
// Drop everything in-memory, hard reset. Pending un-flushed writes survive (still in overlay).
|
|
304
|
+
public reloadFromDisk(): void {
|
|
305
|
+
this.subCaches.bulk.clear();
|
|
306
|
+
this.subCaches.stream.clear();
|
|
307
|
+
this.reader.index?.dropLoadedValues();
|
|
308
|
+
void this.triggerRebuild({ dropStaleFallback: true });
|
|
533
309
|
}
|
|
534
310
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
311
|
+
// External-merge detection: if some other tab/process changed the file set under us, rebuild.
|
|
312
|
+
private async pollFileSet(): Promise<void> {
|
|
313
|
+
if (!this.reader.index) return;
|
|
314
|
+
let current: Set<string>;
|
|
315
|
+
try {
|
|
316
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
317
|
+
current = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
|
|
318
|
+
} catch { return; }
|
|
319
|
+
const prev = this.reader.index?.fileSet;
|
|
320
|
+
if (!prev) return;
|
|
321
|
+
const changed = current.size !== prev.size || [...current].some(n => !prev.has(n));
|
|
322
|
+
if (changed) void this.triggerRebuild();
|
|
540
323
|
}
|
|
541
324
|
|
|
542
|
-
private
|
|
543
|
-
|
|
544
|
-
// replacement first, so the data isn't gone — it just moved to a file our stale listing didn't
|
|
545
|
-
// include. So on a missing file we re-list and rebuild. Bounded; the last attempt tolerates a
|
|
546
|
-
// missing file (loads whatever is there — the documented inconsistent-view bug, fixed by reload).
|
|
547
|
-
let start = Date.now();
|
|
325
|
+
private async readWithRetry<R>(fn: () => Promise<R>): Promise<R> {
|
|
326
|
+
await this.ensureIndex();
|
|
548
327
|
for (let attempt = 0; ; attempt++) {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (e instanceof
|
|
553
|
-
|
|
328
|
+
const before = this.reader.index;
|
|
329
|
+
try { return await fn(); }
|
|
330
|
+
catch (e) {
|
|
331
|
+
if (!(e instanceof MissingFileError) || attempt >= MAX_INDEX_RELOAD_ATTEMPTS) throw e;
|
|
332
|
+
if (this.reader.index === before) await this.triggerRebuild();
|
|
554
333
|
}
|
|
555
334
|
}
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
// One read build over a directory listing. Loads every bulk file's columnar reader plus all streamed
|
|
559
|
-
// entries, then joins them by write-time. A corrupt/torn bulk file is skipped with a warning (its
|
|
560
|
-
// data lives in another file). A *missing* file (deleted by a concurrent merge) throws
|
|
561
|
-
// FilesChangedError so the caller re-lists — unless tolerateMissing, when we proceed without it.
|
|
562
|
-
private async buildReader(start: number, tolerateMissing: boolean): Promise<ResolvedReader> {
|
|
563
|
-
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
564
|
-
let filesChanged = false;
|
|
565
|
-
const [bulkReadersRaw, streamData] = await Promise.all([
|
|
566
|
-
Promise.all(bulkFiles.map(async f => {
|
|
567
|
-
try {
|
|
568
|
-
return await this.loadFileReader(f.fileName);
|
|
569
|
-
} catch (e) {
|
|
570
|
-
if (e instanceof MissingFileError) { filesChanged = true; return undefined; }
|
|
571
|
-
await this.handleUnreadableFile(f, (e as Error).message);
|
|
572
|
-
return undefined;
|
|
573
|
-
}
|
|
574
|
-
})),
|
|
575
|
-
this.loadStreamEntries(streamFiles),
|
|
576
|
-
]);
|
|
577
|
-
if (streamData.missing) filesChanged = true;
|
|
578
|
-
if (filesChanged && !tolerateMissing) throw new FilesChangedError();
|
|
579
|
-
|
|
580
|
-
// Accurate stream size as of this load; subsequent flushes keep it current (see doFlush).
|
|
581
|
-
this.streamRowsOnDisk = streamData.entries.length;
|
|
582
|
-
this.streamBytesOnDisk = streamData.totalBytes;
|
|
583
|
-
|
|
584
|
-
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
585
|
-
// The join resolves purely by write-time, so reader order doesn't matter.
|
|
586
|
-
const readers: BaseBulkDatabaseReader[] = [];
|
|
587
|
-
const ordered = this.orderStreamEntries(streamData.entries);
|
|
588
|
-
if (ordered.length) {
|
|
589
|
-
const stream = streamReaderFromEntries(ordered, streamData.totalBytes);
|
|
590
|
-
readers.push(stream.reader);
|
|
591
|
-
this.streamTimes = stream.times;
|
|
592
|
-
} else {
|
|
593
|
-
this.streamTimes = new Map();
|
|
594
|
-
}
|
|
595
|
-
readers.push(...bulkReaders);
|
|
596
|
-
const joined = await joinBulkDatabases(readers);
|
|
597
|
-
// Live keys of this reader, so per-column cache invalidation can tell an update of an existing key
|
|
598
|
-
// (only its set columns change) from a key add/remove (which touches every column).
|
|
599
|
-
this.readerKeys = new Set(joined.keys);
|
|
600
|
-
// The files this index was built from, so the poll can detect a concurrent merge changing the set.
|
|
601
|
-
this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
|
|
602
|
-
this.loadedTotalBytes = joined.totalBytes; // footprint proxy for the memory-pressure watchdog
|
|
603
|
-
|
|
604
|
-
// Evict cached sub-readers for files a merge removed, so the caches track the live file set.
|
|
605
|
-
this.pruneFileCaches(bulkFiles, streamFiles);
|
|
606
|
-
|
|
607
|
-
let time = Date.now() - start;
|
|
608
|
-
if (time > 50) {
|
|
609
|
-
// Bytes we actually had to read: the full stream files + each bulk file's key column (the
|
|
610
|
-
// part we read on load to get its keys).
|
|
611
|
-
let bytesRead = streamData.totalBytes;
|
|
612
|
-
for (const r of bulkReaders) bytesRead += r.columns.find(c => c.column === KEY_COLUMN)?.byteSize ?? 0;
|
|
613
|
-
console.log(`${blue(this.name)} loaded in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files, read ${blue(formatNumber(bytesRead))}B)`);
|
|
614
|
-
}
|
|
615
|
-
return joined;
|
|
616
335
|
}
|
|
617
336
|
|
|
618
|
-
//
|
|
619
|
-
// collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
|
|
620
|
-
// We wait for the reader (and thus streamTimes) first so conflict resolution can see disk
|
|
621
|
-
// timestamps, then peers reply to our hello with recent writes that may not be on disk yet (applied
|
|
622
|
-
// through the same applyRemote callback).
|
|
337
|
+
// ── cross-tab sync ───────────────────────────────────────────────────────────────────────────────
|
|
623
338
|
private syncSetup = lazy(async () => {
|
|
624
339
|
if (!isSyncSupported()) return;
|
|
625
|
-
await this.
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
let recent = await syncConnect(this.name, write => this.applyRemote(write), () => { this.streamFileName = undefined; });
|
|
629
|
-
for (let write of recent) this.applyRemote(write);
|
|
340
|
+
await this.ensureIndex();
|
|
341
|
+
const recent = await syncConnect(this.name, w => this.applyRemote(w), () => { this.streamFileName = undefined; });
|
|
342
|
+
for (const w of recent) this.applyRemote(w);
|
|
630
343
|
});
|
|
631
344
|
|
|
632
|
-
// The timestamp of the value we currently hold for a key (overlay first, then disk stream).
|
|
633
|
-
private localTime(key: string): number {
|
|
634
|
-
let entry = this.overlay.get(key);
|
|
635
|
-
if (entry) return entry.time;
|
|
636
|
-
let streamTime = this.streamTimes.get(key);
|
|
637
|
-
if (streamTime !== undefined) return streamTime;
|
|
638
|
-
return -Infinity;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// Applies a write received from another tab. Only takes effect if it's newer than what we have,
|
|
642
|
-
// so it never clobbers our own (or disk's) more recent write for the same key.
|
|
643
345
|
private applyRemote(write: RemoteWrite) {
|
|
644
|
-
if (write.time <= this.localTime(write.key)) return;
|
|
645
|
-
this.deps.batch(() => {
|
|
646
|
-
if (write.deleted) this.setOverlayDeleted(write.key, write.time);
|
|
647
|
-
else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Drop the loaded index (reader) and everything derived from it, so the next read rebuilds via
|
|
652
|
-
// buildReader. Does NOT touch the overlay — callers decide what to do with pending writes.
|
|
653
|
-
private clearReaderState() {
|
|
654
|
-
this.reader.reset();
|
|
655
|
-
// Preserve the last-known sync base values so a reload/compact serves them (not empty) while the
|
|
656
|
-
// fresh ones reload — observers transition old → new, never flashing through nothing.
|
|
657
|
-
for (const [k, v] of this.baseColumns) this.staleBaseColumns.set(k, v);
|
|
658
|
-
for (const [k, v] of this.baseFields) this.staleBaseFields.set(k, v);
|
|
659
|
-
this.baseColumns.clear();
|
|
660
|
-
this.baseColumnsLoading.clear();
|
|
661
|
-
this.baseFields.clear();
|
|
662
|
-
this.baseFieldsLoading.clear();
|
|
663
|
-
this.columnCache.clear();
|
|
664
|
-
this.readerKeys = undefined; // the next build repopulates it
|
|
665
|
-
this.loadedFileSet = undefined; // ditto
|
|
666
|
-
this.loadedTotalBytes = 0; // nothing loaded ⇒ no footprint for the memory watchdog
|
|
667
|
-
// The next build re-measures the stream; clear the estimate so a just-folded stream doesn't keep
|
|
668
|
-
// looking "heavy" and re-trigger a fold before then.
|
|
669
|
-
this.streamRowsOnDisk = 0;
|
|
670
|
-
this.streamBytesOnDisk = 0;
|
|
671
|
-
this.dataGen++;
|
|
672
|
-
this.readerEpoch++; // so an in-flight read knows the index changed under it (see readWithReload)
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Reset the loaded reader AND drop the overlay. Used only on structural changes WE made (large
|
|
676
|
-
// direct-bulk write, rollover, compact) after the data has been persisted. Writes still buffered (not
|
|
677
|
-
// yet on disk) are re-applied so the reset doesn't drop them from reads — they aren't in the reloaded
|
|
678
|
-
// reader until their append lands.
|
|
679
|
-
private resetReader() {
|
|
346
|
+
if (write.time <= this.reader.localTime(write.key)) return;
|
|
680
347
|
this.deps.batch(() => {
|
|
681
|
-
this.
|
|
682
|
-
this.
|
|
683
|
-
for (const p of this.pendingAppends) p.apply();
|
|
684
|
-
this.invalidateSignal(LOAD_SIGNAL);
|
|
685
|
-
this.invalidateSignal(OVERLAY_SIGNAL);
|
|
348
|
+
if (write.deleted) this.reader.applyDelete(write.key, write.time);
|
|
349
|
+
else this.reader.applyWrite(write.key, write.value as Record<string, unknown>, write.time);
|
|
686
350
|
});
|
|
687
351
|
}
|
|
688
352
|
|
|
689
|
-
//
|
|
690
|
-
// overlay: our pending writes are in-memory and independent of which files exist on disk.
|
|
691
|
-
private reloadReader() {
|
|
692
|
-
this.deps.batch(() => {
|
|
693
|
-
this.clearReaderState();
|
|
694
|
-
this.invalidateSignal(LOAD_SIGNAL);
|
|
695
|
-
this.invalidateSignal(OVERLAY_SIGNAL);
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// Drop ALL of this collection's in-memory loaded caches (the resolved reader, the sync base column /
|
|
700
|
-
// field caches and their stale fallbacks, the per-file decoded readers) and re-trigger every watcher,
|
|
701
|
-
// so they re-request and reload from disk. Unlike the automatic reloads, this is a genuine full clear
|
|
702
|
-
// (no stale-fallback served — watchers go through a real loading state). Pending un-flushed writes (the
|
|
703
|
-
// overlay) are KEPT, so nothing not-yet-on-disk is lost. Per-collection (this instance only).
|
|
704
|
-
public reloadFromDisk(): void {
|
|
705
|
-
this.deps.batch(() => {
|
|
706
|
-
this.clearReaderState(); // drops reader + base caches (→ stale) + column cache; bumps gens
|
|
707
|
-
this.staleBaseColumns.clear(); // and the stale fallbacks — a genuine full clear, not a swap
|
|
708
|
-
this.staleBaseFields.clear();
|
|
709
|
-
this.bulkReaderCache.clear(); // force re-decode from disk
|
|
710
|
-
this.streamReaderCache.clear();
|
|
711
|
-
this.invalidateSignal(LOAD_SIGNAL);
|
|
712
|
-
this.invalidateSignal(OVERLAY_SIGNAL);
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Run a read against the loaded index; if a file vanished mid-read (a concurrent merge deleted it),
|
|
717
|
-
// reload the index and retry — looping until it succeeds or we've tried MAX_INDEX_RELOAD_ATTEMPTS times.
|
|
718
|
-
// Reloads coalesce: a failing read only resets the index if nobody has reset it since the read grabbed
|
|
719
|
-
// its reader (readerEpoch unchanged). So N concurrent failures cause ONE rebuild — the first resets, the
|
|
720
|
-
// rest see the bumped epoch and just await the in-flight rebuild that lazy() shares — and a good rebuild
|
|
721
|
-
// is never thrown away by a straggler.
|
|
722
|
-
private async readWithReload<R>(fn: (reader: ResolvedReader) => Promise<R>): Promise<R> {
|
|
723
|
-
for (let attempt = 0; ; attempt++) {
|
|
724
|
-
const reader = await this.reader();
|
|
725
|
-
const epoch = this.readerEpoch; // epoch of the reader we're about to use
|
|
726
|
-
try {
|
|
727
|
-
return await fn(reader);
|
|
728
|
-
} catch (e) {
|
|
729
|
-
if (!(e instanceof MissingFileError) || attempt >= MAX_INDEX_RELOAD_ATTEMPTS) throw e;
|
|
730
|
-
if (this.readerEpoch === epoch) this.reloadReader();
|
|
731
|
-
// else: another reader already reloaded the index — loop and use its rebuild.
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Proactively reload the index if the on-disk file set changed under us (a merge in another tab/
|
|
737
|
-
// process), so reads pick up the new files even without first hitting a read error. Cheap — just lists
|
|
738
|
-
// the directory. Runs on a timer (fileSetPollIntervalMs).
|
|
739
|
-
private async pollFileSet(): Promise<void> {
|
|
740
|
-
if (!this.loadedFileSet) return; // reader not built yet → nothing loaded to compare against
|
|
741
|
-
let current: Set<string>;
|
|
742
|
-
try {
|
|
743
|
-
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
744
|
-
current = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
|
|
745
|
-
} catch { return; }
|
|
746
|
-
const prev = this.loadedFileSet;
|
|
747
|
-
if (!prev) return; // reloaded during the await
|
|
748
|
-
const changed = current.size !== prev.size || [...current].some(n => !prev.has(n));
|
|
749
|
-
if (changed) this.reloadReader();
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// ---- writes ----
|
|
753
|
-
|
|
353
|
+
// ── writes ───────────────────────────────────────────────────────────────────────────────────────
|
|
754
354
|
public async write(entry: T): Promise<void> {
|
|
755
355
|
return this.writeBatch([entry]);
|
|
756
356
|
}
|
|
@@ -759,24 +359,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
759
359
|
if (!entries.length) return;
|
|
760
360
|
void this.syncSetup();
|
|
761
361
|
const rows = entries as unknown as Record<string, unknown>[];
|
|
762
|
-
// Stamp each row with a unique timestamp now, so the same time is used on disk, in the overlay,
|
|
763
|
-
// and in the cross-tab broadcast.
|
|
764
362
|
const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
|
|
765
363
|
const framed = frameRows(stamped);
|
|
766
364
|
|
|
767
|
-
//
|
|
768
|
-
//
|
|
365
|
+
// Big batches skip the stream and become a bulk file directly — streaming thousands of rows
|
|
366
|
+
// one frame at a time would just churn.
|
|
769
367
|
if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
|
|
770
368
|
await this.writeBulkFile(rows);
|
|
771
369
|
return;
|
|
772
370
|
}
|
|
773
371
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
this.deps.batch(apply);
|
|
372
|
+
this.deps.batch(() => {
|
|
373
|
+
for (const { time, row } of stamped) this.reader.applyWrite(row.key as string, row, time);
|
|
374
|
+
});
|
|
778
375
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
779
|
-
await this.streamAppend(framed,
|
|
376
|
+
await this.streamAppend(framed, stamped.length);
|
|
780
377
|
void this.maybeMerge();
|
|
781
378
|
}
|
|
782
379
|
|
|
@@ -788,29 +385,27 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
788
385
|
if (!keys.length) return;
|
|
789
386
|
void this.syncSetup();
|
|
790
387
|
const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
|
|
791
|
-
|
|
792
|
-
|
|
388
|
+
this.deps.batch(() => {
|
|
389
|
+
for (const { time, key } of stamped) this.reader.applyDelete(key, time);
|
|
390
|
+
});
|
|
793
391
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
794
|
-
await this.streamAppend(frameDeletes(stamped),
|
|
392
|
+
await this.streamAppend(frameDeletes(stamped), stamped.length);
|
|
795
393
|
void this.maybeMerge();
|
|
796
394
|
}
|
|
797
395
|
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
this.pendingAppends.push({ framed, apply, rows });
|
|
396
|
+
// Coalesce stream appends on a ramping per-collection schedule (the browser rewrites the whole
|
|
397
|
+
// file per append). The first write after a lull flushes immediately so a single edit-then-close
|
|
398
|
+
// is saved at once; sustained writes ramp toward writeFlushMaxDelayMs.
|
|
399
|
+
private async streamAppend(framed: Buffer, rows: number): Promise<void> {
|
|
400
|
+
this.pendingAppends.push({ framed, rows });
|
|
804
401
|
const max = bulkDatabase2Timing.writeFlushMaxDelayMs;
|
|
805
402
|
const now = Date.now();
|
|
806
|
-
// Immediate when batching is off (Node / real append), or for the first write after a lull.
|
|
807
403
|
if (max <= 0 || this.currentFlushDelay <= 0 || now - this.lastWriteTime > max) {
|
|
808
404
|
this.lastWriteTime = now;
|
|
809
405
|
this.currentFlushDelay = max > 0 ? Math.min(max, WRITE_FLUSH_FIRST_STEP_MS) : 0;
|
|
810
406
|
await this.flushPending();
|
|
811
407
|
return;
|
|
812
408
|
}
|
|
813
|
-
// Active burst: coalesce into one scheduled flush and ramp the delay toward max.
|
|
814
409
|
this.lastWriteTime = now;
|
|
815
410
|
if (this.flushTimer === undefined) {
|
|
816
411
|
this.flushTimer = setTimeout(() => { this.flushTimer = undefined; void this.flushPending(); }, this.currentFlushDelay);
|
|
@@ -818,8 +413,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
818
413
|
this.currentFlushDelay = Math.min(max, this.currentFlushDelay * 2);
|
|
819
414
|
}
|
|
820
415
|
|
|
821
|
-
// Flushes all buffered stream writes to disk as one append. Serialized (so two flushes never write the
|
|
822
|
-
// same file concurrently) and best-effort: a failed append keeps the data buffered for the next try.
|
|
823
416
|
public async flush(): Promise<void> {
|
|
824
417
|
await this.flushPending();
|
|
825
418
|
}
|
|
@@ -838,21 +431,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
838
431
|
const combined = Buffer.concat(batch.map(p => p.framed));
|
|
839
432
|
const storage = await this.storage();
|
|
840
433
|
const fileName = this.getStreamFileName();
|
|
841
|
-
if (fileName !== this.currentStreamFileName) {
|
|
434
|
+
if (fileName !== this.currentStreamFileName) {
|
|
842
435
|
this.currentStreamFileName = fileName;
|
|
843
436
|
this.currentStreamFileBytes = 0;
|
|
844
437
|
}
|
|
845
|
-
//
|
|
438
|
+
// On failure the throw leaves pendingAppends intact so a later flush retries.
|
|
846
439
|
await storage.append(fileName, combined);
|
|
847
|
-
// New
|
|
440
|
+
// New entries appended during the await are after `batch` — removing the front is exactly the
|
|
441
|
+
// flushed set.
|
|
848
442
|
this.pendingAppends.splice(0, batch.length);
|
|
849
|
-
// Keep the stream-size estimate current so the fold trigger sees write-accumulation between reads.
|
|
850
443
|
this.streamBytesOnDisk += combined.length;
|
|
851
444
|
for (const p of batch) this.streamRowsOnDisk += p.rows;
|
|
852
445
|
this.currentStreamFileBytes += combined.length;
|
|
853
|
-
// Cap our own stream file: once it's grown past the limit, seal it (next write starts a fresh file)
|
|
854
|
-
// and fold this now-complete file into bulk in the background. New writes go to the new file, so the
|
|
855
|
-
// sealed one is stable and safe to fold+delete immediately — no 10h wait for our own files.
|
|
856
446
|
if (this.currentStreamFileBytes >= bulkDatabase2Timing.streamFileMaxBytes) {
|
|
857
447
|
this.streamFileName = undefined;
|
|
858
448
|
this.currentStreamFileName = undefined;
|
|
@@ -861,9 +451,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
861
451
|
}
|
|
862
452
|
}
|
|
863
453
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
454
|
+
private getStreamFileName(): string {
|
|
455
|
+
// Seal our current file once it ages past the seal threshold — no file is ever appended to past
|
|
456
|
+
// its seal age, which lets a consolidation safely fold it once aged.
|
|
457
|
+
if (this.streamFileName) {
|
|
458
|
+
const info = parseStreamFileName(this.streamFileName);
|
|
459
|
+
if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
|
|
460
|
+
}
|
|
461
|
+
if (!this.streamFileName) {
|
|
462
|
+
this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
463
|
+
}
|
|
464
|
+
return this.streamFileName;
|
|
465
|
+
}
|
|
466
|
+
|
|
867
467
|
private async foldOwnStream(fileName: string): Promise<void> {
|
|
868
468
|
const info = parseStreamFileName(fileName);
|
|
869
469
|
if (!info) return;
|
|
@@ -878,18 +478,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
878
478
|
return this.updateBatch([entry]);
|
|
879
479
|
}
|
|
880
480
|
|
|
881
|
-
// Like writeBatch, but each entry is a partial row — only the fields to change, plus the required
|
|
882
|
-
// key. Partial fields merge onto the existing row (unset columns fall through to the current value);
|
|
883
|
-
// an entry whose key isn't in the collection is skipped with a warning, since update never creates keys.
|
|
884
481
|
public async updateBatch(entries: (Partial<T> & { key: string })[]): Promise<void> {
|
|
885
482
|
if (!entries.length) return;
|
|
886
483
|
void this.syncSetup();
|
|
887
|
-
const
|
|
888
|
-
const diskKeys = new Set(reader.keys);
|
|
484
|
+
const index = await this.ensureIndex();
|
|
889
485
|
const present: T[] = [];
|
|
890
486
|
for (const entry of entries) {
|
|
891
|
-
const overlayEntry = this.overlay.get(entry.key);
|
|
892
|
-
const exists = overlayEntry ? overlayEntry.value !== DELETED :
|
|
487
|
+
const overlayEntry = this.reader.overlay.get(entry.key);
|
|
488
|
+
const exists = overlayEntry ? overlayEntry.value !== DELETED : index.keys.has(entry.key);
|
|
893
489
|
if (!exists) {
|
|
894
490
|
console.warn(`${this.name}.update: key ${JSON.stringify(entry.key)} is not in the collection, ignoring`);
|
|
895
491
|
continue;
|
|
@@ -899,9 +495,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
899
495
|
if (present.length) await this.writeBatch(present);
|
|
900
496
|
}
|
|
901
497
|
|
|
902
|
-
//
|
|
903
|
-
// database). Bulk newest-first, streams oldest-first. Duplicate data (from a crashed/raced merge)
|
|
904
|
-
// is harmless: reads resolve by write-time and a later merge with enough duplication removes it.
|
|
498
|
+
// ── file listings ────────────────────────────────────────────────────────────────────────────────
|
|
905
499
|
private async listFiles(): Promise<{ bulkFiles: BulkFileInfo[]; streamFiles: StreamFileInfo[] }> {
|
|
906
500
|
const storage = await this.storage();
|
|
907
501
|
const names = await storage.getKeys();
|
|
@@ -911,7 +505,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
911
505
|
if (n.endsWith(FILE_EXTENSION)) { const p = parseFileName(n); if (p) bulkFiles.push(p); }
|
|
912
506
|
else if (n.endsWith(STREAM_EXTENSION)) { const p = parseStreamFileName(n); if (p) streamFiles.push(p); }
|
|
913
507
|
}
|
|
914
|
-
// Newest-first by timestamp; ties broken by file name for determinism.
|
|
915
508
|
bulkFiles.sort((a, b) => {
|
|
916
509
|
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
917
510
|
return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
|
|
@@ -920,10 +513,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
920
513
|
return { bulkFiles, streamFiles };
|
|
921
514
|
}
|
|
922
515
|
|
|
923
|
-
// Writes `rows` directly as bulk file(s), stamped with the current time as their write-time (the rows
|
|
924
|
-
// are being written now). Used by the large-batch write path: no manifest, just new files on disk.
|
|
925
|
-
// The rows carry time=now, so the join orders them correctly against any older stream entry for the
|
|
926
|
-
// same key (newer time wins) — no clobber. A later testMerge consolidates them.
|
|
927
516
|
private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
|
|
928
517
|
const storage = await this.storage();
|
|
929
518
|
const timestamp = nextFileTime();
|
|
@@ -933,78 +522,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
933
522
|
const name = newFileName(timestamp);
|
|
934
523
|
await storage.set(name, encodeCompressedBlocks(built.buffer));
|
|
935
524
|
}
|
|
936
|
-
this.
|
|
525
|
+
await this.triggerRebuild();
|
|
937
526
|
void this.maybeMerge();
|
|
938
527
|
}
|
|
939
528
|
|
|
940
|
-
//
|
|
941
|
-
// unique timestamp + originating file) so callers can order writes globally across files, the
|
|
942
|
-
// prefix size we read per file (so a merge can verify nothing was appended before deleting it), and
|
|
943
|
-
// whether any listed file was missing (so a read can re-list and retry — a merge deleted it).
|
|
944
|
-
private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number; missing: boolean; sizes: Map<string, number> }> {
|
|
945
|
-
const sizes = new Map<string, number>();
|
|
946
|
-
if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
|
|
947
|
-
const storage = await this.storage();
|
|
948
|
-
let missing = false;
|
|
949
|
-
// Per-file parse, reusing streamCache when the file hasn't grown. Stream files are append-only, so
|
|
950
|
-
// size is the version: same size ⇒ same parsed entries; a larger size ⇒ parse only the appended
|
|
951
|
-
// suffix and tack it on. We read a bounded prefix [0, size) — a foreign writer may be appending, and
|
|
952
|
-
// storage.get() errors past the stat'd size; parseStream stops at the last complete frame, so a
|
|
953
|
-
// later read picks up the rest. A file removed out from under us (a merge) sets `missing`.
|
|
954
|
-
const perFile = await Promise.all(streamFiles.map(async (f): Promise<{ fileName: string; size: number; entries: StreamEntry[] } | undefined> => {
|
|
955
|
-
try {
|
|
956
|
-
const info = await storage.getInfo(f.fileName);
|
|
957
|
-
if (!info) { missing = true; return undefined; }
|
|
958
|
-
const size = info.size;
|
|
959
|
-
sizes.set(f.fileName, size);
|
|
960
|
-
const cached = this.streamReaderCache.get(f.fileName);
|
|
961
|
-
if (cached && cached.readSize === size) return { fileName: f.fileName, size, entries: cached.entries };
|
|
962
|
-
if (size === 0) { this.streamReaderCache.set(f.fileName, { readSize: 0, parsedPos: 0, entries: [] }); return { fileName: f.fileName, size: 0, entries: [] }; }
|
|
963
|
-
if (cached && size > cached.readSize) {
|
|
964
|
-
// Grew: parse only the appended bytes from where we last stopped (a frame boundary).
|
|
965
|
-
const suffix = await storage.getRange(f.fileName, { start: cached.parsedPos, end: size });
|
|
966
|
-
if (!suffix) { missing = true; return undefined; }
|
|
967
|
-
const parsed = parseStream(suffix);
|
|
968
|
-
if (parsed.badBytes > 0) console.warn(`${this.name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
969
|
-
const entries = parsed.entries.length ? cached.entries.concat(parsed.entries) : cached.entries;
|
|
970
|
-
const parsedPos = cached.parsedPos + (suffix.length - parsed.badBytes);
|
|
971
|
-
this.streamReaderCache.set(f.fileName, { readSize: size, parsedPos, entries });
|
|
972
|
-
return { fileName: f.fileName, size, entries };
|
|
973
|
-
}
|
|
974
|
-
// Cold (or the rare shrink/rewrite): full read from the start.
|
|
975
|
-
const buffer = await storage.getRange(f.fileName, { start: 0, end: size });
|
|
976
|
-
if (!buffer) { missing = true; return undefined; }
|
|
977
|
-
const parsed = parseStream(buffer);
|
|
978
|
-
if (parsed.badBytes > 0) console.warn(`${this.name} stream file ${f.fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
979
|
-
this.streamReaderCache.set(f.fileName, { readSize: size, parsedPos: size - parsed.badBytes, entries: parsed.entries });
|
|
980
|
-
return { fileName: f.fileName, size, entries: parsed.entries };
|
|
981
|
-
} catch {
|
|
982
|
-
missing = true;
|
|
983
|
-
return undefined;
|
|
984
|
-
}
|
|
985
|
-
}));
|
|
986
|
-
const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
|
|
987
|
-
let totalBytes = 0;
|
|
988
|
-
for (const pf of perFile) {
|
|
989
|
-
if (!pf) continue;
|
|
990
|
-
totalBytes += pf.size;
|
|
991
|
-
for (const entry of pf.entries) entries.push({ time: entry.time, fileName: pf.fileName, entry });
|
|
992
|
-
}
|
|
993
|
-
return { entries, totalBytes, missing, sizes };
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
|
|
997
|
-
private orderStreamEntries(entries: { time: number; fileName: string; entry: StreamEntry }[]): StreamEntry[] {
|
|
998
|
-
entries.sort((a, b) => {
|
|
999
|
-
if (a.time !== b.time) return a.time - b.time;
|
|
1000
|
-
return a.fileName < b.fileName && -1 || a.fileName > b.fileName && 1 || 0;
|
|
1001
|
-
});
|
|
1002
|
-
return entries.map(e => e.entry);
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
// Throttled, fire-and-forget after writes: run a background merge check at most once per interval — but
|
|
1006
|
-
// a tier-0 stream that's grown too big folds promptly, bypassing the throttle. Skipped entirely over
|
|
1007
|
-
// the network unless the app opted in (see automaticCompactionAllowed).
|
|
529
|
+
// ── merge policy ─────────────────────────────────────────────────────────────────────────────────
|
|
1008
530
|
private async maybeMerge(): Promise<void> {
|
|
1009
531
|
if (!await this.automaticCompactionAllowed()) return;
|
|
1010
532
|
const now = Date.now();
|
|
@@ -1017,9 +539,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1017
539
|
}
|
|
1018
540
|
}
|
|
1019
541
|
|
|
1020
|
-
// Runs one merge pass now (the same one maybeMerge runs on a timer). Returns whether it merged
|
|
1021
|
-
// anything, and whether it bailed because another tab/process holds the merge lock — so a caller
|
|
1022
|
-
// (e.g. a 30-minute scheduler) can tell "nothing to do" from "someone else is doing it".
|
|
1023
542
|
public async tryMergeNow(): Promise<{ merged: boolean; lockFailed: boolean }> {
|
|
1024
543
|
if (!tryAcquireMergeLock(this.name, writerId)) return { merged: false, lockFailed: true };
|
|
1025
544
|
try {
|
|
@@ -1029,29 +548,21 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1029
548
|
}
|
|
1030
549
|
}
|
|
1031
550
|
|
|
1032
|
-
// Full compaction: fold + dedup everything into key-sorted, ~256MB files. Reads the whole collection
|
|
1033
|
-
// into memory (the accepted soft bound), so it's an explicit, occasional call. Deletes consumed bulk
|
|
1034
|
-
// files and any stream file it's safe to (aged, or sealed-and-stable).
|
|
1035
551
|
public async compact(): Promise<void> {
|
|
1036
|
-
if (!tryAcquireMergeLock(this.name, writerId)) return;
|
|
552
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return;
|
|
1037
553
|
try {
|
|
1038
|
-
await this.flushPending();
|
|
554
|
+
await this.flushPending();
|
|
1039
555
|
syncBroadcastSeal(this.name);
|
|
1040
556
|
this.streamFileName = undefined;
|
|
1041
557
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
1042
|
-
// compact()
|
|
1043
|
-
//
|
|
558
|
+
// compact() folds every file → no older data survives outside it → surviving tombstones
|
|
559
|
+
// can be dropped (nothing left to suppress).
|
|
1044
560
|
if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles, true);
|
|
1045
561
|
} finally {
|
|
1046
562
|
releaseMergeLock(this.name, writerId);
|
|
1047
563
|
}
|
|
1048
564
|
}
|
|
1049
565
|
|
|
1050
|
-
// The unified merge entry point: rewrite everything overlapping [timeLo, timeHi] into fresh
|
|
1051
|
-
// key-sorted ~256MB bulk file(s). Selects bulk files by their header time range and stream files by
|
|
1052
|
-
// their (creation .. seal-age) window. If the range reaches the present, first asks peers to seal so
|
|
1053
|
-
// recent stream data is complete. Callers: testMerge (recent / key-group ranges); external callers
|
|
1054
|
-
// can pass any range — older data just produces older files.
|
|
1055
566
|
public async merge(timeLo: number, timeHi: number): Promise<void> {
|
|
1056
567
|
if (timeHi >= Date.now()) { syncBroadcastSeal(this.name); this.streamFileName = undefined; }
|
|
1057
568
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
@@ -1059,65 +570,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1059
570
|
const selBulk = bulkFiles.filter((f, i) => {
|
|
1060
571
|
const h = headers[i];
|
|
1061
572
|
if (!h) return false;
|
|
1062
|
-
// Old files (no recorded time range) only belong to a merge that reaches back to the start.
|
|
1063
573
|
if (!h.maxTime && !h.minTime) return timeLo <= 0;
|
|
1064
574
|
return h.minTime <= timeHi && h.maxTime >= timeLo;
|
|
1065
575
|
});
|
|
1066
576
|
const selStream = streamFiles.filter(f =>
|
|
1067
577
|
f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
|
|
1068
578
|
if (selBulk.length + selStream.length < 2) return;
|
|
1069
|
-
// timeLo <= 0 reaches the start of time: every older file overlaps [timeLo, timeHi] and is in the
|
|
1070
|
-
// merge, so no older set survives outside it and surviving tombstones can be dropped.
|
|
1071
579
|
await this.mergeFileSet(selBulk, selStream, timeLo <= 0);
|
|
1072
580
|
}
|
|
1073
581
|
|
|
1074
|
-
// Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
|
|
1075
|
-
// file a merge deleted (re-list and retry / skip) from a corrupt one (handle as unreadable).
|
|
1076
|
-
private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number }> {
|
|
1077
|
-
const storage = await this.storage();
|
|
1078
|
-
const info = await storage.getInfo(fileName);
|
|
1079
|
-
if (!info) throw new MissingFileError(`bulk file ${fileName} is missing`);
|
|
1080
|
-
const rawGetRange: GetRange = async (start, end) => {
|
|
1081
|
-
if (end <= start) return EMPTY_BUFFER;
|
|
1082
|
-
const buf = await storage.getRange(fileName, { start, end });
|
|
1083
|
-
if (!buf) throw new MissingFileError(`range [${start}, ${end}) of ${fileName} is missing`);
|
|
1084
|
-
return buf;
|
|
1085
|
-
};
|
|
1086
|
-
return { rawGetRange, size: info.size };
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
|
|
1090
|
-
// Bulk files are immutable, so a decoded sub-reader (keys + keyTimes + columns) is valid until the
|
|
1091
|
-
// file is gone — reuse it so an index reload doesn't re-decode unchanged files. (We skip the getInfo
|
|
1092
|
-
// existence check on a hit; if the file was merged away mid-build the deferred read fails and
|
|
1093
|
-
// readWithReload recovers — and buildReader won't ask for files that aren't currently listed.)
|
|
1094
|
-
const cached = this.bulkReaderCache.get(fileName);
|
|
1095
|
-
if (cached) return cached;
|
|
1096
|
-
const raw = await this.makeRawGetRange(fileName);
|
|
1097
|
-
const fileId = nullJoin(this.name, fileName);
|
|
1098
|
-
// Stored as compressed blocks; replace getRange with a block-cached, decompressing version (same
|
|
1099
|
-
// interface) and read the logical (uncompressed) size from its index. open() validates the file
|
|
1100
|
-
// size against the index and throws if it's truncated/corrupt.
|
|
1101
|
-
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
1102
|
-
const reader = await loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
1103
|
-
this.bulkReaderCache.set(fileName, reader);
|
|
1104
|
-
return reader;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// Drop cached sub-readers for files that no longer exist (a merge replaced them), so the caches track
|
|
1108
|
-
// the live file set instead of growing with churn. Called after each successful build.
|
|
1109
|
-
private pruneFileCaches(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]) {
|
|
1110
|
-
const liveBulk = new Set(bulkFiles.map(f => f.fileName));
|
|
1111
|
-
const liveStream = new Set(streamFiles.map(f => f.fileName));
|
|
1112
|
-
for (const name of this.bulkReaderCache.keys()) if (!liveBulk.has(name)) this.bulkReaderCache.delete(name);
|
|
1113
|
-
for (const name of this.streamReaderCache.keys()) if (!liveStream.has(name)) this.streamReaderCache.delete(name);
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
// Reads only a bulk file's header (row count, time range, key range) — no column data — for merge
|
|
1117
|
-
// planning. Returns undefined for a missing/corrupt file so the planner just leaves it out.
|
|
1118
582
|
private async readBulkHeader(fileName: string): Promise<BulkHeaderInfo | undefined> {
|
|
1119
583
|
try {
|
|
1120
|
-
const
|
|
584
|
+
const storage = await this.storage();
|
|
585
|
+
const raw = await makeRawGetRange(storage, fileName);
|
|
1121
586
|
const fileId = nullJoin(this.name, fileName);
|
|
1122
587
|
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
1123
588
|
return await loadBulkHeader(opened.getRange, opened.uncompressedSize);
|
|
@@ -1126,12 +591,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1126
591
|
}
|
|
1127
592
|
}
|
|
1128
593
|
|
|
1129
|
-
// Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
|
|
1130
|
-
// Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
|
|
1131
|
-
// missing or unreadable so the planner simply leaves it out of any merge.
|
|
1132
594
|
private async fileLogicalSize(fileName: string): Promise<number | undefined> {
|
|
1133
595
|
try {
|
|
1134
|
-
const
|
|
596
|
+
const storage = await this.storage();
|
|
597
|
+
const raw = await makeRawGetRange(storage, fileName);
|
|
1135
598
|
const fileId = nullJoin(this.name, fileName);
|
|
1136
599
|
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
1137
600
|
return opened.uncompressedSize;
|
|
@@ -1140,98 +603,28 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1140
603
|
}
|
|
1141
604
|
}
|
|
1142
605
|
|
|
1143
|
-
// A bulk file that won't load is either
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
// Deleting is safe — the write protocol always writes a new file before removing the files it
|
|
1147
|
-
// supersedes, so an abandoned partial file's data still lives in another (older) file.
|
|
606
|
+
// A bulk file that won't load is either an in-progress write (recent) or a crashed partial write
|
|
607
|
+
// (stale). Warn while recent, delete once clearly abandoned — deleting is safe because the write
|
|
608
|
+
// protocol always lands the replacement before removing the file it supersedes.
|
|
1148
609
|
private async handleUnreadableFile(file: BulkFileInfo, message: string): Promise<void> {
|
|
1149
|
-
|
|
610
|
+
const ageMs = Date.now() - file.timestamp;
|
|
1150
611
|
if (ageMs > STALE_DELETE_MS) {
|
|
1151
612
|
console.warn(`${this.name}: deleting stale unreadable bulk file ${file.fileName} (${Math.round(ageMs / 86400000)}d old): ${message}`);
|
|
1152
613
|
try {
|
|
1153
|
-
|
|
614
|
+
const storage = await this.storage();
|
|
1154
615
|
await storage.remove(file.fileName);
|
|
1155
616
|
} catch (removeError) {
|
|
1156
617
|
console.warn(`${this.name}: failed to delete ${file.fileName}: ${(removeError as Error).message}`);
|
|
1157
618
|
}
|
|
1158
619
|
return;
|
|
1159
620
|
}
|
|
1160
|
-
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// Resolves a set of readers (stream + bulk) by ACTUAL write-time into merged rows + per-row times,
|
|
1164
|
-
// plus the surviving tombstones (keys whose newest event is a delete). For each key/column, the
|
|
1165
|
-
// value with the newest write-time across readers wins (non-ABSENT); the row's time is the newest of
|
|
1166
|
-
// those. A key is deleted iff its newest delete is newer than its newest set. This is the same
|
|
1167
|
-
// time-resolution reads use, captured so a merge can write the result back as bulk + a carry stream.
|
|
1168
|
-
private async resolveReaders(readers: BaseBulkDatabaseReader[]): Promise<{ rows: Record<string, unknown>[]; times: number[]; deletes: Map<string, number> }> {
|
|
1169
|
-
const loaded = await Promise.all(readers.map(async reader => {
|
|
1170
|
-
const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
|
|
1171
|
-
for (const col of reader.columns) {
|
|
1172
|
-
if (col.column === KEY_COLUMN) continue;
|
|
1173
|
-
const entries = await reader.getColumn(col.column);
|
|
1174
|
-
cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
|
|
1175
|
-
}
|
|
1176
|
-
return { keyTimes: reader.keyTimes, deleteTimes: reader.deleteTimes, cols };
|
|
1177
|
-
}));
|
|
1178
|
-
|
|
1179
|
-
const deleteTime = new Map<string, number>();
|
|
1180
|
-
for (const l of loaded) {
|
|
1181
|
-
if (!l.deleteTimes) continue;
|
|
1182
|
-
for (const [k, t] of l.deleteTimes) deleteTime.set(k, Math.max(deleteTime.get(k) ?? -Infinity, t));
|
|
1183
|
-
}
|
|
1184
|
-
const keyTime = new Map<string, number>();
|
|
1185
|
-
for (const l of loaded) {
|
|
1186
|
-
for (const [k, t] of l.keyTimes) keyTime.set(k, Math.max(keyTime.get(k) ?? -Infinity, t));
|
|
1187
|
-
}
|
|
1188
|
-
const allCols = new Set<string>();
|
|
1189
|
-
for (const l of loaded) for (const c of l.cols.keys()) allCols.add(c);
|
|
1190
|
-
|
|
1191
|
-
const rows: Record<string, unknown>[] = [];
|
|
1192
|
-
const times: number[] = [];
|
|
1193
|
-
const deletes = new Map<string, number>();
|
|
1194
|
-
const allKeys = new Set<string>([...keyTime.keys(), ...deleteTime.keys()]);
|
|
1195
|
-
for (const key of allKeys) {
|
|
1196
|
-
const setT = keyTime.get(key) ?? -Infinity;
|
|
1197
|
-
const delT = deleteTime.get(key) ?? -Infinity;
|
|
1198
|
-
if (setT <= delT) {
|
|
1199
|
-
// The newest event for this key is a delete — carry the tombstone forward so it keeps
|
|
1200
|
-
// suppressing any older set living in a file outside this merge.
|
|
1201
|
-
if (delT > -Infinity) deletes.set(key, delT);
|
|
1202
|
-
continue;
|
|
1203
|
-
}
|
|
1204
|
-
const row: Record<string, unknown> = { [KEY_COLUMN]: key };
|
|
1205
|
-
let rowTime = setT;
|
|
1206
|
-
for (const col of allCols) {
|
|
1207
|
-
let bestTime = -Infinity;
|
|
1208
|
-
let bestVal: unknown;
|
|
1209
|
-
let found = false;
|
|
1210
|
-
for (const l of loaded) {
|
|
1211
|
-
const cell = l.cols.get(col)?.get(key);
|
|
1212
|
-
if (!cell || cell.value === ABSENT) continue;
|
|
1213
|
-
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
1214
|
-
}
|
|
1215
|
-
if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
|
|
1216
|
-
}
|
|
1217
|
-
rows.push(row);
|
|
1218
|
-
times.push(rowTime === -Infinity ? 0 : rowTime);
|
|
1219
|
-
}
|
|
1220
|
-
return { rows, times, deletes };
|
|
621
|
+
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be in-progress): ${message}`);
|
|
1221
622
|
}
|
|
1222
623
|
|
|
1223
|
-
// The one merge primitive. Reads
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
// merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
|
|
1228
|
-
// only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
|
|
1229
|
-
// didn't change while we read it. Returns whether it produced anything.
|
|
1230
|
-
// `includesOldest` means this merge consumes every file at or before its time range — there is no
|
|
1231
|
-
// file before it on disk. A surviving tombstone only exists to suppress an OLDER set in some file
|
|
1232
|
-
// outside the merge; if nothing older exists, that older set can't exist either, so the tombstone has
|
|
1233
|
-
// nothing left to suppress and we drop it instead of carrying it forward. (A full compact and a
|
|
1234
|
-
// merge that reaches time 0 are the cases where this holds.)
|
|
624
|
+
// The one merge primitive. Reads + plans + writes outputs before deleting any input, so a crash
|
|
625
|
+
// leaves duplicates (next merge dedupes) rather than a gap. After the file set changes on disk,
|
|
626
|
+
// we trigger an index rebuild + atomic swap; once swap completes, the consumed files' block-cache
|
|
627
|
+
// entries are evicted (no consumer can ask for them now).
|
|
1235
628
|
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[], includesOldest = false, forceDeleteStreams = false): Promise<boolean> {
|
|
1236
629
|
const storage = await this.storage();
|
|
1237
630
|
const timestamp = nextFileTime();
|
|
@@ -1241,21 +634,20 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1241
634
|
const bulkReaders: BaseBulkDatabaseReader[] = [];
|
|
1242
635
|
await Promise.all(bulkFiles.map(async f => {
|
|
1243
636
|
try {
|
|
1244
|
-
const r = await this.
|
|
637
|
+
const r = await loadFileReader(this.name, storage, f, this.subCaches.bulk);
|
|
1245
638
|
bulkReaders.push(r);
|
|
1246
|
-
consumedBulk.push(f);
|
|
639
|
+
consumedBulk.push(f);
|
|
1247
640
|
} catch { /* missing or corrupt — skip; its data lives in another file */ }
|
|
1248
641
|
}));
|
|
1249
642
|
|
|
1250
|
-
const streamData = await this.
|
|
1251
|
-
const ordered =
|
|
643
|
+
const streamData = await loadStreamEntries(this.name, storage, streamFiles, this.subCaches.stream);
|
|
644
|
+
const ordered = orderStreamEntries(streamData.entries);
|
|
1252
645
|
const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
|
|
1253
646
|
|
|
1254
647
|
const readers = streamReader ? [streamReader, ...bulkReaders] : bulkReaders;
|
|
648
|
+
const readerNames = streamReader ? ["(streams)", ...consumedBulk.map(f => f.fileName)] : consumedBulk.map(f => f.fileName);
|
|
1255
649
|
if (!readers.length) return false;
|
|
1256
650
|
|
|
1257
|
-
// Log the inputs of a REAL merge (files + on-disk sizes). Only here, never for the planning checks
|
|
1258
|
-
// in testMerge/findDuplicateGroups, so the log marks actual rewrites and their before/after I/O.
|
|
1259
651
|
const inputs = [
|
|
1260
652
|
...await Promise.all(consumedBulk.map(async f => ({ name: f.fileName, size: (await storage.getInfo(f.fileName).catch(() => undefined))?.size ?? 0 }))),
|
|
1261
653
|
...streamFiles.map(f => ({ name: f.fileName, size: streamData.sizes.get(f.fileName) ?? 0 })),
|
|
@@ -1265,37 +657,28 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1265
657
|
console.log(`${blue(this.name)} merge: reading ${inputs.length} files (${fmtBytes(inTotal)}) at ${new Date(mergeStartMs).toISOString()}`);
|
|
1266
658
|
for (const f of inputs) console.log(` in ${f.name} ${fmtBytes(f.size)}`);
|
|
1267
659
|
|
|
1268
|
-
const { rows, times, deletes } = await this.resolveReaders(readers);
|
|
1269
|
-
|
|
1270
|
-
// Write all outputs BEFORE deleting any input, so a throw mid-write just leaves duplicates.
|
|
1271
660
|
const newNames: string[] = [];
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
// Carry surviving tombstones forward only if older files exist outside this merge that they still
|
|
1289
|
-
// need to suppress; when this merge includes the oldest data there's nothing older to suppress.
|
|
1290
|
-
const carriedDeletes = includesOldest ? 0 : deletes.size;
|
|
661
|
+
const mergeResult = await runPlannedMerge({
|
|
662
|
+
sources: readers,
|
|
663
|
+
sourceNames: readerNames,
|
|
664
|
+
collectionName: this.name,
|
|
665
|
+
writeFile: async (data) => {
|
|
666
|
+
const fname = newFileName(timestamp);
|
|
667
|
+
await storage.set(fname, encodeCompressedBlocks(data));
|
|
668
|
+
newNames.push(fname);
|
|
669
|
+
const size = (await storage.getInfo(fname).catch(() => undefined))?.size ?? 0;
|
|
670
|
+
return { name: fname, size };
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const carriedDeletes = includesOldest ? 0 : mergeResult.carriedDeletes.size;
|
|
1291
675
|
const outNames = [...newNames];
|
|
1292
676
|
if (carriedDeletes) {
|
|
1293
677
|
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
1294
|
-
await storage.set(carryName, frameDeletes([...
|
|
678
|
+
await storage.set(carryName, frameDeletes([...mergeResult.carriedDeletes].map(([key, time]) => ({ time, key }))));
|
|
1295
679
|
outNames.push(carryName);
|
|
1296
680
|
}
|
|
1297
681
|
|
|
1298
|
-
// Log the result (files + on-disk sizes), so the before→after of the merge is visible.
|
|
1299
682
|
const outputs = await Promise.all(outNames.map(async n => ({ name: n, size: (await storage.getInfo(n).catch(() => undefined))?.size ?? 0 })));
|
|
1300
683
|
const outTotal = outputs.reduce((a, f) => a + f.size, 0);
|
|
1301
684
|
console.log(`${blue(this.name)} merge: wrote ${outputs.length} files (${fmtBytes(outTotal)}, from ${fmtBytes(inTotal)})${carriedDeletes ? `, ${carriedDeletes} tombstones carried` : ""} at ${new Date().toISOString()} (took ${formatTime(Date.now() - mergeStartMs)})`);
|
|
@@ -1307,21 +690,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1307
690
|
if (await this.canDeleteStream(f, now, streamData.sizes, forceDeleteStreams)) await remove(f.fileName);
|
|
1308
691
|
}
|
|
1309
692
|
|
|
1310
|
-
|
|
693
|
+
// File set changed — rebuild + swap. After the swap, consumed files' block-cache entries are
|
|
694
|
+
// evicted (no reader will request them now).
|
|
695
|
+
await this.triggerRebuild();
|
|
1311
696
|
return newNames.length > 0 || carriedDeletes > 0;
|
|
1312
697
|
}
|
|
1313
698
|
|
|
1314
|
-
// A stream
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
// and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
|
|
1319
|
-
// data, but the aged check also rules out the rare sparse-offset append race.)
|
|
699
|
+
// A stream is safe to delete iff no writer will append to it again: it's aged past the seal age
|
|
700
|
+
// (writer has provably switched files) OR cross-tab sync is on AND its size didn't change while
|
|
701
|
+
// we read it. Else leave it — the data is also in the new bulk file; a later merge deletes it
|
|
702
|
+
// once aged.
|
|
1320
703
|
private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>, force = false): Promise<boolean> {
|
|
1321
704
|
if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
|
|
1322
|
-
// Without cross-tab sync we normally can't tell a writer is done before the seal age — UNLESS the
|
|
1323
|
-
// caller forces it (the hard stream limit). Even then we only delete a stream whose size didn't
|
|
1324
|
-
// change while we read it, so a writer mid-append never loses data (it's re-folded next pass).
|
|
1325
705
|
if (!isSyncSupported() && !force) return false;
|
|
1326
706
|
const readSize = sizes.get(f.fileName);
|
|
1327
707
|
if (readSize === undefined) return false;
|
|
@@ -1330,49 +710,35 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1330
710
|
return !!info && info.size === readSize;
|
|
1331
711
|
}
|
|
1332
712
|
|
|
1333
|
-
// Waits mergeSpacingMs between merges so a burst of work doesn't rewrite every index at once (keeps
|
|
1334
|
-
// peak lag low for the browser). Heartbeats the merge lock through the wait; returns false if another
|
|
1335
|
-
// tab took the lock over, so the caller stops doing further merges.
|
|
1336
713
|
private async mergeSpacingDelay(): Promise<boolean> {
|
|
1337
714
|
const total = bulkDatabase2Timing.mergeSpacingMs;
|
|
1338
715
|
if (total <= 0) return tryAcquireMergeLock(this.name, writerId);
|
|
1339
|
-
const step = 15 * 1000;
|
|
716
|
+
const step = 15 * 1000;
|
|
1340
717
|
let waited = 0;
|
|
1341
718
|
while (waited < total) {
|
|
1342
719
|
await new Promise<void>(r => setTimeout(r, Math.min(step, total - waited)));
|
|
1343
720
|
waited += step;
|
|
1344
|
-
if (!tryAcquireMergeLock(this.name, writerId)) return false;
|
|
721
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return false;
|
|
1345
722
|
}
|
|
1346
723
|
return true;
|
|
1347
724
|
}
|
|
1348
725
|
|
|
1349
|
-
// The merge policy
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
// seal) only aged streams are folded, so we never re-fold the same un-deletable stream forever.
|
|
1355
|
-
// 2) Key-stratify (possibly several merges): sort all keys, walk them in ~KEY_GROUP_BYTES groups, and
|
|
1356
|
-
// rewrite EVERY group whose fraction of duplicate (multi-file) keys exceeds DUP_THRESHOLD —
|
|
1357
|
-
// highest-duplication first — merging the bulk files overlapping that key range. Groups have
|
|
1358
|
-
// disjoint key ranges, so one group's merge doesn't change another's duplication; we re-select
|
|
1359
|
-
// each group's files at merge time (the file set shifts as we go). Over time this sorts the data
|
|
1360
|
-
// into key-disjoint files. Returns whether any merge happened.
|
|
726
|
+
// The merge policy (two passes, spaced by mergeSpacingMs):
|
|
727
|
+
// 1) Consolidate recent fragmentation: take newest files up to ~FIRST_MERGE_BYTES; merge them
|
|
728
|
+
// into one when they fragment or span too wide a time range.
|
|
729
|
+
// 2) Key-stratify: walk all keys in ~KEY_GROUP_BYTES groups; rewrite groups whose duplicate-key
|
|
730
|
+
// fraction passes DUP_THRESHOLD, highest first.
|
|
1361
731
|
private async testMerge(): Promise<boolean> {
|
|
1362
732
|
let merged = false;
|
|
1363
|
-
await this.flushPending();
|
|
1364
|
-
// Run a merge, pausing first if we've already merged this pass (so the pause is BETWEEN merges,
|
|
1365
|
-
// never before the first or after the last). Returns false if we lost the lock — stop entirely.
|
|
733
|
+
await this.flushPending();
|
|
1366
734
|
const runMerge = async (bulk: BulkFileInfo[], stream: StreamFileInfo[]): Promise<boolean> => {
|
|
1367
735
|
if (merged && !await this.mergeSpacingDelay()) return false;
|
|
1368
736
|
if (await this.mergeFileSet(bulk, stream)) merged = true;
|
|
1369
737
|
return true;
|
|
1370
738
|
};
|
|
1371
739
|
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
// is essentially unreadable. Force-delete the folded streams (canDeleteStream still only deletes
|
|
1375
|
-
// size-stable ones, so an active writer never loses data; it's just re-folded next pass). ──
|
|
740
|
+
// Hard stream limit: a stream this big makes every read pull a huge file → fold ALL of it now,
|
|
741
|
+
// force-delete (canDeleteStream still requires size-stable, so an active writer never loses data).
|
|
1376
742
|
{
|
|
1377
743
|
const { streamFiles } = await this.listFiles();
|
|
1378
744
|
if (streamFiles.length) {
|
|
@@ -1386,14 +752,12 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1386
752
|
}
|
|
1387
753
|
}
|
|
1388
754
|
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
|
|
1392
|
-
// no benefit, since canDeleteStream there only deletes aged files anyway.
|
|
1393
|
-
const foldRecentStreams = isSyncSupported(); // see canDeleteStream: else we'd re-fold forever
|
|
755
|
+
// Pass 1: consolidate recent. Only seal when cross-tab sync can fold recent streams — in Node
|
|
756
|
+
// canDeleteStream needs them aged anyway, so sealing would just fragment streams every pass.
|
|
757
|
+
const foldRecentStreams = isSyncSupported();
|
|
1394
758
|
if (foldRecentStreams) {
|
|
1395
759
|
syncBroadcastSeal(this.name);
|
|
1396
|
-
this.streamFileName = undefined;
|
|
760
|
+
this.streamFileName = undefined;
|
|
1397
761
|
}
|
|
1398
762
|
{
|
|
1399
763
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
@@ -1418,11 +782,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1418
782
|
if (recentBytes >= FIRST_MERGE_BYTES) break;
|
|
1419
783
|
}
|
|
1420
784
|
const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
|
|
1421
|
-
// Fold when there's enough fragmentation to consolidate, OR when the foldable stream data here
|
|
1422
|
-
// has grown past the byte threshold (stream data can't be read per-cell, so a big stream is
|
|
1423
|
-
// costly to pull whole). The stream size is derived from THIS fresh listing — not a cached
|
|
1424
|
-
// counter — so it's correct even for an instance that never built a reader (e.g. the host's
|
|
1425
|
-
// autocompactor) and for streams that grew in place since the index was last loaded.
|
|
1426
785
|
const recentStreamBytes = recent.reduce((a, it) => a + (it.kind === "stream" ? it.bytes : 0), 0);
|
|
1427
786
|
const heavyStream = recentStreamBytes > bulkDatabase2Timing.streamFoldTriggerBytes;
|
|
1428
787
|
const triggered =
|
|
@@ -1435,18 +794,15 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1435
794
|
}
|
|
1436
795
|
}
|
|
1437
796
|
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
// are disjoint, so they stay valid as we merge each in turn.
|
|
797
|
+
// Pass 2: key-stratified deduplication. Disjoint key ranges → one group's merge doesn't
|
|
798
|
+
// change another's duplication; re-select each group's files at merge time (set shifts).
|
|
1441
799
|
const groups = await this.findDuplicateGroups();
|
|
1442
800
|
for (const g of groups) {
|
|
1443
|
-
// Re-select the files overlapping this group's key range now (earlier merges shifted the set).
|
|
1444
801
|
const { bulkFiles } = await this.listFiles();
|
|
1445
802
|
const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
|
|
1446
803
|
const groupFiles = bulkFiles.filter((f, i) => {
|
|
1447
804
|
const h = headers[i];
|
|
1448
805
|
if (!h) return false;
|
|
1449
|
-
// Old files without a key range are treated as spanning all keys (so always overlap).
|
|
1450
806
|
if (h.minKey === undefined || h.maxKey === undefined) return true;
|
|
1451
807
|
return h.minKey <= g.hi && h.maxKey >= g.lo;
|
|
1452
808
|
});
|
|
@@ -1456,15 +812,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1456
812
|
return merged;
|
|
1457
813
|
}
|
|
1458
814
|
|
|
1459
|
-
// Finds key-range groups worth deduping: sorts all bulk keys, walks them in ~KEY_GROUP_BYTES groups,
|
|
1460
|
-
// and returns the [lo, hi] of every group whose duplicate (multi-file) key fraction exceeds
|
|
1461
|
-
// DUP_THRESHOLD, highest-duplication first (most benefit). Empty when nothing is worth it.
|
|
1462
815
|
private async findDuplicateGroups(): Promise<{ lo: string; hi: string; dup: number }[]> {
|
|
1463
816
|
const { bulkFiles } = await this.listFiles();
|
|
1464
817
|
if (bulkFiles.length < 2) return [];
|
|
818
|
+
const storage = await this.storage();
|
|
1465
819
|
const infos = await Promise.all(bulkFiles.map(async f => {
|
|
1466
820
|
try {
|
|
1467
|
-
const reader = await this.
|
|
821
|
+
const reader = await loadFileReader(this.name, storage, f, this.subCaches.bulk);
|
|
1468
822
|
return { keys: reader.keys, bytes: reader.totalBytes };
|
|
1469
823
|
} catch {
|
|
1470
824
|
return { keys: [] as string[], bytes: 0 };
|
|
@@ -1482,7 +836,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1482
836
|
const groups: { lo: string; hi: string; dup: number }[] = [];
|
|
1483
837
|
let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
|
|
1484
838
|
for (let i = 0; i < sortedKeys.length; i++) {
|
|
1485
|
-
const c = keyCount.get(sortedKeys[i])
|
|
839
|
+
const c = keyCount.get(sortedKeys[i]) ?? 0;
|
|
1486
840
|
gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
|
|
1487
841
|
if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
|
|
1488
842
|
const dup = (gSlots - gUnique) / gSlots;
|
|
@@ -1494,290 +848,81 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1494
848
|
return groups;
|
|
1495
849
|
}
|
|
1496
850
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
// Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
|
|
1502
|
-
// overlay entry that doesn't include this column leaves the base (disk) value+time in place — a
|
|
1503
|
-
// partial write/update only overrides the columns it set; everything else falls through. An overlay
|
|
1504
|
-
// override carries the overlay write's time (when that pending write happened).
|
|
1505
|
-
private patchColumn(base: { key: string; value: unknown; time: number }[], column: string): { key: string; value: unknown; time: number }[] {
|
|
1506
|
-
if (this.overlay.size === 0) return base;
|
|
1507
|
-
const map = new Map(base.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
1508
|
-
for (const [key, entry] of this.overlay) {
|
|
1509
|
-
if (entry.value === DELETED) { map.delete(key); continue; }
|
|
1510
|
-
if (column in entry.value) map.set(key, { value: entry.value[column], time: entry.time });
|
|
1511
|
-
else if (!map.has(key)) map.set(key, { value: undefined, time: entry.time });
|
|
1512
|
-
}
|
|
1513
|
-
return [...map].map(([key, v]) => ({ key, value: v.value, time: v.time }));
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// ---- async reads (overlay-aware) ----
|
|
1517
|
-
|
|
1518
|
-
public async getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined> {
|
|
1519
|
-
return (await this.getSingleFieldObj(key, column))?.value;
|
|
851
|
+
// ── reads — forwarded to BulkDatabaseReader, with rebuild-on-missing retry ───────────────────────
|
|
852
|
+
public async getSingleField<C extends keyof T>(key: string, column: C): Promise<T[C] | undefined> {
|
|
853
|
+
void this.syncSetup();
|
|
854
|
+
return this.readWithRetry(() => this.reader.getSingleField(key, column));
|
|
1520
855
|
}
|
|
1521
856
|
|
|
1522
|
-
|
|
1523
|
-
// time is roughly when that value last changed (the resolved write-time; for a row-merged value it's
|
|
1524
|
-
// the newest contributing write). Returns undefined only when the key isn't present/live.
|
|
1525
|
-
public async getSingleFieldObj<Column extends keyof T>(key: string, column: Column): Promise<{ key: string; value: T[Column]; time: number } | undefined> {
|
|
857
|
+
public async getSingleFieldObj<C extends keyof T>(key: string, column: C): Promise<{ key: string; value: T[C]; time: number } | undefined> {
|
|
1526
858
|
void this.syncSetup();
|
|
1527
|
-
|
|
1528
|
-
const entry = this.overlay.get(key);
|
|
1529
|
-
if (entry !== undefined) {
|
|
1530
|
-
if (entry.value === DELETED) return undefined;
|
|
1531
|
-
if (col in entry.value) return { key, value: entry.value[col] as T[Column], time: entry.time };
|
|
1532
|
-
// column not set in the overlay entry — fall through to disk for this column
|
|
1533
|
-
}
|
|
1534
|
-
let time = Date.now();
|
|
1535
|
-
const r = await this.readWithReload(reader => reader.getSingleField(key, col));
|
|
1536
|
-
time = Date.now() - time;
|
|
1537
|
-
if (time > 50) {
|
|
1538
|
-
console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
|
|
1539
|
-
}
|
|
1540
|
-
if (r === undefined) {
|
|
1541
|
-
// Not live on disk; but if the overlay holds the key (a partial write of a not-yet-on-disk
|
|
1542
|
-
// key) it's live with this column unset.
|
|
1543
|
-
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
1544
|
-
return undefined;
|
|
1545
|
-
}
|
|
1546
|
-
return { key, value: r.value as T[Column], time: r.time };
|
|
859
|
+
return this.readWithRetry(() => this.reader.getSingleFieldObj(key, column));
|
|
1547
860
|
}
|
|
1548
861
|
|
|
1549
|
-
public async getColumn<
|
|
862
|
+
public async getColumn<C extends keyof T>(column: C): Promise<{ key: string; value: T[C]; time: number }[]> {
|
|
1550
863
|
void this.syncSetup();
|
|
1551
|
-
|
|
1552
|
-
const cached = this.columnCache.get(col);
|
|
1553
|
-
if (cached) return cached as { key: string; value: T[Column]; time: number }[];
|
|
1554
|
-
const gen = this.dataGen;
|
|
1555
|
-
let time = Date.now();
|
|
1556
|
-
let base = await this.readWithReload(reader => reader.getColumn(col));
|
|
1557
|
-
let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
|
|
1558
|
-
time = Date.now() - time;
|
|
1559
|
-
if (time > 50) {
|
|
1560
|
-
console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
|
|
1561
|
-
}
|
|
1562
|
-
Object.freeze(result);
|
|
1563
|
-
// Only cache if no write/reset happened during the awaits above (else this result may be stale).
|
|
1564
|
-
if (this.dataGen === gen) this.columnCache.set(col, result);
|
|
1565
|
-
return result;
|
|
864
|
+
return this.readWithRetry(() => this.reader.getColumn(column));
|
|
1566
865
|
}
|
|
1567
866
|
|
|
1568
867
|
public async getKeys(): Promise<string[]> {
|
|
1569
868
|
void this.syncSetup();
|
|
1570
|
-
|
|
1571
|
-
if (this.overlay.size === 0) return reader.keys;
|
|
1572
|
-
let set = new Set(reader.keys);
|
|
1573
|
-
for (const [key, entry] of this.overlay) {
|
|
1574
|
-
if (entry.value === DELETED) set.delete(key);
|
|
1575
|
-
else set.add(key);
|
|
1576
|
-
}
|
|
1577
|
-
return [...set];
|
|
869
|
+
return this.readWithRetry(() => this.reader.getKeys());
|
|
1578
870
|
}
|
|
1579
871
|
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
// loaded once and cached; the overlay is layered on top (we can't async-cache the combined result
|
|
1584
|
-
// because the overlay mutates).
|
|
1585
|
-
|
|
1586
|
-
private baseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
1587
|
-
private baseColumnsLoading = new Set<string>();
|
|
1588
|
-
// The disk-resolved field: { value, time } when the key is live on disk, or undefined when it isn't
|
|
1589
|
-
// (Map.has distinguishes "loaded" from "not loaded yet").
|
|
1590
|
-
private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
1591
|
-
private baseFieldsLoading = new Set<string>();
|
|
1592
|
-
// Last-known sync base values, kept across a reader reset so a reload/compact serves the previous data
|
|
1593
|
-
// instead of flashing empty while the fresh value reloads in the background. Each entry is dropped once
|
|
1594
|
-
// ensureBase* has the corresponding fresh value (so reads transition old → new, never old → empty → new).
|
|
1595
|
-
private staleBaseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
1596
|
-
private staleBaseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
1597
|
-
|
|
1598
|
-
private ensureBaseColumn(column: string) {
|
|
1599
|
-
if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
|
|
1600
|
-
this.baseColumnsLoading.add(column);
|
|
1601
|
-
void (async () => {
|
|
1602
|
-
try {
|
|
1603
|
-
const base = await this.readWithReload(reader => reader.getColumn(column));
|
|
1604
|
-
this.deps.batch(() => {
|
|
1605
|
-
this.baseColumns.set(column, base);
|
|
1606
|
-
this.staleBaseColumns.delete(column); // fresh value in hand; stop serving the stale one
|
|
1607
|
-
this.baseColumnsLoading.delete(column);
|
|
1608
|
-
this.invalidateSignal(LOAD_SIGNAL);
|
|
1609
|
-
});
|
|
1610
|
-
} catch (e) {
|
|
1611
|
-
// The load failed (e.g. a file vanished and the reload retry also failed). Clear the loading
|
|
1612
|
-
// flag so a later read retries, rather than leaving the column wedged as "loading" forever.
|
|
1613
|
-
this.baseColumnsLoading.delete(column);
|
|
1614
|
-
console.warn(`${this.name}.getColumnSync(${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
|
|
1615
|
-
}
|
|
1616
|
-
})();
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
private ensureBaseField(key: string, column: string) {
|
|
1620
|
-
let cacheKey = nullJoin(column, key);
|
|
1621
|
-
if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
|
|
1622
|
-
this.baseFieldsLoading.add(cacheKey);
|
|
1623
|
-
void (async () => {
|
|
1624
|
-
try {
|
|
1625
|
-
const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
|
|
1626
|
-
this.deps.batch(() => {
|
|
1627
|
-
this.baseFields.set(cacheKey, resolved);
|
|
1628
|
-
this.staleBaseFields.delete(cacheKey); // fresh value in hand; stop serving the stale one
|
|
1629
|
-
this.baseFieldsLoading.delete(cacheKey);
|
|
1630
|
-
this.invalidateSignal(LOAD_SIGNAL);
|
|
1631
|
-
});
|
|
1632
|
-
} catch (e) {
|
|
1633
|
-
this.baseFieldsLoading.delete(cacheKey);
|
|
1634
|
-
console.warn(`${this.name}.getSingleFieldSync(${JSON.stringify(key)}, ${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
|
|
1635
|
-
}
|
|
1636
|
-
})();
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
public getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined {
|
|
1640
|
-
return this.getSingleFieldObjSync(key, column)?.value;
|
|
872
|
+
public getSingleFieldSync<C extends keyof T>(key: string, column: C): T[C] | undefined {
|
|
873
|
+
void this.syncSetup();
|
|
874
|
+
return this.reader.getSingleFieldSync(key, column);
|
|
1641
875
|
}
|
|
1642
876
|
|
|
1643
|
-
|
|
1644
|
-
// loading or when the key isn't present/live. time is roughly when the value last changed.
|
|
1645
|
-
public getSingleFieldObjSync<Column extends keyof T>(key: string, column: Column): { key: string; value: T[Column]; time: number } | undefined {
|
|
877
|
+
public getSingleFieldObjSync<C extends keyof T>(key: string, column: C): { key: string; value: T[C]; time: number } | undefined {
|
|
1646
878
|
void this.syncSetup();
|
|
1647
|
-
this.
|
|
1648
|
-
this.deps.observe(key);
|
|
1649
|
-
let col = String(column);
|
|
1650
|
-
let entry = this.overlay.get(key);
|
|
1651
|
-
if (entry !== undefined) {
|
|
1652
|
-
if (entry.value === DELETED) return undefined;
|
|
1653
|
-
if (col in entry.value) {
|
|
1654
|
-
// Warm the disk-backed base in the background even though the overlay serves this now — so
|
|
1655
|
-
// when the overlay is later cleared (e.g. compaction persists it), there's a value to serve
|
|
1656
|
-
// and the read doesn't flash empty.
|
|
1657
|
-
this.ensureBaseField(key, col);
|
|
1658
|
-
return { key, value: entry.value[col] as T[Column], time: entry.time };
|
|
1659
|
-
}
|
|
1660
|
-
// column not set in the overlay entry — fall through to the base field cache for this column
|
|
1661
|
-
}
|
|
1662
|
-
let cacheKey = nullJoin(col, key);
|
|
1663
|
-
// Use the fresh value if loaded; mid-reload fall back to the last-known one so we don't flash empty.
|
|
1664
|
-
let src: Map<string, { value: unknown; time: number } | undefined> | undefined;
|
|
1665
|
-
if (this.baseFields.has(cacheKey)) {
|
|
1666
|
-
src = this.baseFields;
|
|
1667
|
-
} else {
|
|
1668
|
-
this.ensureBaseField(key, col);
|
|
1669
|
-
src = this.staleBaseFields.has(cacheKey) ? this.staleBaseFields : undefined;
|
|
1670
|
-
}
|
|
1671
|
-
if (!src) {
|
|
1672
|
-
// Genuine first load (nothing known); but an overlay entry makes the key live with this column unset.
|
|
1673
|
-
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
1674
|
-
return undefined;
|
|
1675
|
-
}
|
|
1676
|
-
const base = src.get(cacheKey);
|
|
1677
|
-
if (base === undefined) {
|
|
1678
|
-
// Not live on disk; but an overlay entry for the key (partial write) makes it live, column unset.
|
|
1679
|
-
if (entry !== undefined && entry.value !== DELETED) return { key, value: undefined as T[Column], time: entry.time };
|
|
1680
|
-
return undefined;
|
|
1681
|
-
}
|
|
1682
|
-
return { key, value: base.value as T[Column], time: base.time };
|
|
879
|
+
return this.reader.getSingleFieldObjSync(key, column);
|
|
1683
880
|
}
|
|
1684
881
|
|
|
1685
|
-
public getColumnSync<
|
|
882
|
+
public getColumnSync<C extends keyof T>(column: C): { key: string; value: T[C]; time: number }[] | undefined {
|
|
1686
883
|
void this.syncSetup();
|
|
1687
|
-
this.
|
|
1688
|
-
// Observe the overlay-wide signal so we recompute once the base arrives or the overlay changes.
|
|
1689
|
-
this.deps.observe(OVERLAY_SIGNAL);
|
|
1690
|
-
let col = String(column);
|
|
1691
|
-
const cached = this.columnCache.get(col);
|
|
1692
|
-
if (cached) return cached as { key: string; value: T[Column]; time: number }[];
|
|
1693
|
-
let base = this.baseColumns.get(col);
|
|
1694
|
-
if (!base) {
|
|
1695
|
-
this.ensureBaseColumn(col);
|
|
1696
|
-
// Mid-reload: serve the last-known value (patched with the current overlay) so we don't flash
|
|
1697
|
-
// empty. Don't cache it — it's stale until ensureBaseColumn swaps in the fresh value.
|
|
1698
|
-
const stale = this.staleBaseColumns.get(col);
|
|
1699
|
-
if (stale) return this.patchColumn(stale, col) as { key: string; value: T[Column]; time: number }[];
|
|
1700
|
-
return undefined; // genuine first load — nothing known yet
|
|
1701
|
-
}
|
|
1702
|
-
// Synchronous (no awaits) → reads the current overlay and caches it atomically; an observer re-runs
|
|
1703
|
-
// (and the cache is cleared) on any later change, so the frozen result is safe to share.
|
|
1704
|
-
let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
|
|
1705
|
-
Object.freeze(result);
|
|
1706
|
-
this.columnCache.set(col, result);
|
|
1707
|
-
return result;
|
|
884
|
+
return this.reader.getColumnSync(column);
|
|
1708
885
|
}
|
|
1709
886
|
|
|
1710
|
-
|
|
1711
|
-
// know the answer, whether that's a value, absent, or deleted; false while it's still loading from disk.
|
|
1712
|
-
// Pairs with getSingleFieldObjSync, which returns undefined for BOTH "loading" and "absent" — use this
|
|
1713
|
-
// to tell them apart (e.g. show a spinner only when this is false). Triggers the load if not started,
|
|
1714
|
-
// and (like the sync reads) counts the last-known value served during a reload as loaded.
|
|
1715
|
-
public isFieldLoadedSync<Column extends keyof T>(key: string, column: Column): boolean {
|
|
887
|
+
public isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean {
|
|
1716
888
|
void this.syncSetup();
|
|
1717
|
-
this.
|
|
1718
|
-
this.deps.observe(key);
|
|
1719
|
-
const entry = this.overlay.get(key);
|
|
1720
|
-
if (entry !== undefined) {
|
|
1721
|
-
if (entry.value === DELETED) return true; // known: deleted
|
|
1722
|
-
if (String(column) in entry.value) return true; // known: overlay holds this column
|
|
1723
|
-
// else: this column falls through to disk — check the base caches below
|
|
1724
|
-
}
|
|
1725
|
-
const cacheKey = nullJoin(String(column), key);
|
|
1726
|
-
if (this.baseFields.has(cacheKey) || this.staleBaseFields.has(cacheKey)) return true;
|
|
1727
|
-
this.ensureBaseField(key, String(column));
|
|
1728
|
-
return false;
|
|
889
|
+
return this.reader.isFieldLoadedSync(key, column);
|
|
1729
890
|
}
|
|
1730
891
|
|
|
1731
|
-
|
|
1732
|
-
public isColumnLoadedSync<Column extends keyof T>(column: Column): boolean {
|
|
892
|
+
public isColumnLoadedSync<C extends keyof T>(column: C): boolean {
|
|
1733
893
|
void this.syncSetup();
|
|
1734
|
-
this.
|
|
1735
|
-
this.deps.observe(OVERLAY_SIGNAL);
|
|
1736
|
-
const col = String(column);
|
|
1737
|
-
if (this.columnCache.has(col) || this.baseColumns.has(col) || this.staleBaseColumns.has(col)) return true;
|
|
1738
|
-
this.ensureBaseColumn(col);
|
|
1739
|
-
return false;
|
|
894
|
+
return this.reader.isColumnLoadedSync(column);
|
|
1740
895
|
}
|
|
1741
896
|
|
|
1742
897
|
public async getColumnInfo() {
|
|
1743
|
-
|
|
1744
|
-
return reader.columns;
|
|
898
|
+
const index = await this.ensureIndex();
|
|
899
|
+
return index.reader.columns;
|
|
1745
900
|
}
|
|
1746
901
|
|
|
1747
|
-
// Raw vs. resolved key counts: how much duplicate/stale key data is sitting on disk that a compact()
|
|
1748
|
-
// would collapse. rawKeys counts every key-slot across all loaded files — each set and each delete
|
|
1749
|
-
// tombstone (a key written into N files counts N times); finalKeys is the number of live resolved
|
|
1750
|
-
// keys (after newest-write-wins and tombstones). Both come straight from the already-loaded reader, so
|
|
1751
|
-
// this is ~free. wastedKeys = rawKeys - finalKeys; duplication = rawKeys / finalKeys (well above 1 ⇒
|
|
1752
|
-
// fragmented, compaction would shrink it).
|
|
1753
902
|
public async getKeyStats(): Promise<{ rawKeys: number; finalKeys: number; wastedKeys: number; duplication: number; readers: number }> {
|
|
1754
|
-
const
|
|
1755
|
-
const rawKeys = reader.rawKeyCount;
|
|
1756
|
-
const finalKeys = reader.keys.length;
|
|
903
|
+
const index = await this.ensureIndex();
|
|
904
|
+
const rawKeys = index.reader.rawKeyCount;
|
|
905
|
+
const finalKeys = index.reader.keys.length;
|
|
1757
906
|
return {
|
|
1758
907
|
rawKeys,
|
|
1759
908
|
finalKeys,
|
|
1760
909
|
wastedKeys: rawKeys - finalKeys,
|
|
1761
910
|
duplication: finalKeys ? rawKeys / finalKeys : 0,
|
|
1762
|
-
readers: reader.readerCount,
|
|
911
|
+
readers: index.reader.readerCount,
|
|
1763
912
|
};
|
|
1764
913
|
}
|
|
1765
914
|
|
|
1766
915
|
public async getReaderInfo() {
|
|
1767
|
-
|
|
916
|
+
const index = await this.ensureIndex();
|
|
1768
917
|
return {
|
|
1769
|
-
rowCount: reader.rowCount,
|
|
1770
|
-
totalBytes: reader.totalBytes,
|
|
1771
|
-
keyCount: reader.keys.length,
|
|
1772
|
-
sampleKey: reader.keys[0] as string | undefined,
|
|
1773
|
-
columns: reader.columns,
|
|
918
|
+
rowCount: index.reader.rowCount,
|
|
919
|
+
totalBytes: index.reader.totalBytes,
|
|
920
|
+
keyCount: index.reader.keys.length,
|
|
921
|
+
sampleKey: index.reader.keys[0] as string | undefined,
|
|
922
|
+
columns: index.reader.columns,
|
|
1774
923
|
};
|
|
1775
924
|
}
|
|
1776
925
|
|
|
1777
|
-
// Per-file breakdown of the collection's on-disk files, read FRESH from disk each call (so it
|
|
1778
|
-
// reflects the latest sizes, including stream files still being appended). `bytes` is the actual
|
|
1779
|
-
// on-disk (compressed, for bulk) size. Useful for showing collection size / fragmentation, and to
|
|
1780
|
-
// decide whether to call tryMergeNow()/compact().
|
|
1781
926
|
public async getFileInfo(): Promise<{ files: { name: string; type: "bulk" | "stream"; bytes: number }[]; count: number; totalBytes: number }> {
|
|
1782
927
|
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
1783
928
|
const storage = await this.storage();
|
|
@@ -1790,101 +935,3 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
1790
935
|
}
|
|
1791
936
|
}
|
|
1792
937
|
|
|
1793
|
-
// The merged, time-resolved view over all readers. getColumn/getSingleField return the resolved value
|
|
1794
|
-
// AND its write-time (so reads can expose roughly when a value last changed); the base layers the
|
|
1795
|
-
// overlay on top of these. getSingleField returns undefined only when the key isn't live (deleted or
|
|
1796
|
-
// absent) — a live key whose column is merely unset returns { value: undefined, time: rowTime }.
|
|
1797
|
-
type ResolvedReader = {
|
|
1798
|
-
rowCount: number;
|
|
1799
|
-
totalBytes: number;
|
|
1800
|
-
keys: string[];
|
|
1801
|
-
// Total key-slots across every loaded reader — every set AND every delete tombstone (a key stored in
|
|
1802
|
-
// N files counts N times) — and how many readers there are. Already in memory after the join, so
|
|
1803
|
-
// getKeyStats is ~free; rawKeyCount vs keys.length is how much duplication a compaction would collapse.
|
|
1804
|
-
rawKeyCount: number;
|
|
1805
|
-
readerCount: number;
|
|
1806
|
-
columns: { column: string; byteSize: number }[];
|
|
1807
|
-
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
1808
|
-
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | undefined>;
|
|
1809
|
-
};
|
|
1810
|
-
|
|
1811
|
-
// Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
|
|
1812
|
-
// - a column resolves to the value with the newest write-time among readers that set it (non-ABSENT);
|
|
1813
|
-
// a reader that never set the column for that key falls through to an older reader.
|
|
1814
|
-
// - a key is live iff its newest write is newer than its newest delete; per column, the value is
|
|
1815
|
-
// suppressed if a delete is newer than that column's newest set.
|
|
1816
|
-
// No reliance on file order or partitioning — time is the only thing that decides.
|
|
1817
|
-
async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<ResolvedReader> {
|
|
1818
|
-
const deleteTime = new Map<string, number>();
|
|
1819
|
-
for (const db of databases) {
|
|
1820
|
-
if (!db.deleteTimes) continue;
|
|
1821
|
-
for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
|
|
1822
|
-
}
|
|
1823
|
-
const keyTime = new Map<string, number>();
|
|
1824
|
-
for (const db of databases) {
|
|
1825
|
-
for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
|
|
1826
|
-
}
|
|
1827
|
-
const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
|
|
1828
|
-
// Live keys: newest write strictly newer than newest delete.
|
|
1829
|
-
const keys: string[] = [];
|
|
1830
|
-
for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
|
|
1831
|
-
|
|
1832
|
-
const columns: { column: string; byteSize: number }[] = [];
|
|
1833
|
-
const columnByName = new Map<string, { column: string; byteSize: number }>();
|
|
1834
|
-
for (const db of databases) {
|
|
1835
|
-
for (const col of db.columns) {
|
|
1836
|
-
let existing = columnByName.get(col.column);
|
|
1837
|
-
if (!existing) {
|
|
1838
|
-
existing = { column: col.column, byteSize: 0 };
|
|
1839
|
-
columnByName.set(col.column, existing);
|
|
1840
|
-
columns.push(existing);
|
|
1841
|
-
}
|
|
1842
|
-
existing.byteSize += col.byteSize;
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
return {
|
|
1847
|
-
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
1848
|
-
rowCount: keys.length,
|
|
1849
|
-
keys,
|
|
1850
|
-
rawKeyCount: databases.reduce((acc, db) => acc + db.keyTimes.size + (db.deleteTimes?.size ?? 0), 0),
|
|
1851
|
-
readerCount: databases.length,
|
|
1852
|
-
columns,
|
|
1853
|
-
async getColumn(column) {
|
|
1854
|
-
const perReader = await Promise.all(databases.map(async db => {
|
|
1855
|
-
if (!db.columns.some(c => c.column === column)) return undefined;
|
|
1856
|
-
const entries = await db.getColumn(column);
|
|
1857
|
-
return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
1858
|
-
}));
|
|
1859
|
-
return keys.map(key => {
|
|
1860
|
-
let bestTime = -Infinity;
|
|
1861
|
-
let bestVal: unknown;
|
|
1862
|
-
let found = false;
|
|
1863
|
-
for (const m of perReader) {
|
|
1864
|
-
const cell = m && m.get(key);
|
|
1865
|
-
if (!cell || cell.value === ABSENT) continue;
|
|
1866
|
-
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
1867
|
-
}
|
|
1868
|
-
// time = the column value's write-time when this column has one, else the row's last
|
|
1869
|
-
// write-time (the key is live but never set this column).
|
|
1870
|
-
const live = found && bestTime > delOf(key);
|
|
1871
|
-
return { key, value: live ? bestVal : undefined, time: live ? bestTime : (keyTime.get(key) ?? 0) };
|
|
1872
|
-
});
|
|
1873
|
-
},
|
|
1874
|
-
async getSingleField(key, column) {
|
|
1875
|
-
const kt = keyTime.get(key);
|
|
1876
|
-
if (kt === undefined || kt <= delOf(key)) return undefined; // key not live
|
|
1877
|
-
let bestTime = -Infinity;
|
|
1878
|
-
let bestVal: unknown;
|
|
1879
|
-
let found = false;
|
|
1880
|
-
for (const db of databases) {
|
|
1881
|
-
if (!db.columns.some(c => c.column === column)) continue;
|
|
1882
|
-
const r = await db.getSingleField(key, column);
|
|
1883
|
-
if (r === ABSENT) continue;
|
|
1884
|
-
if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
|
|
1885
|
-
}
|
|
1886
|
-
const live = found && bestTime > delOf(key);
|
|
1887
|
-
return { value: live ? bestVal : undefined, time: live ? bestTime : kt };
|
|
1888
|
-
},
|
|
1889
|
-
};
|
|
1890
|
-
}
|