sliftutils 1.2.39 → 1.3.1
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/.claude/settings.local.json +5 -1
- package/index.d.ts +374 -0
- package/misc/getSecret.d.ts +1 -0
- package/misc/getSecret.ts +7 -0
- package/package.json +2 -2
- package/render-utils/mobxTyped.d.ts +1 -0
- package/render-utils/mobxTyped.ts +4 -0
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +77 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +103 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +103 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +1063 -0
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +32 -0
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +331 -0
- package/storage/BulkDatabase2/blockCache.d.ts +17 -0
- package/storage/BulkDatabase2/blockCache.ts +173 -0
- package/storage/BulkDatabase2/manifest.d.ts +17 -0
- package/storage/BulkDatabase2/manifest.ts +59 -0
- package/storage/BulkDatabase2/mergeLock.d.ts +2 -0
- package/storage/BulkDatabase2/mergeLock.ts +47 -0
- package/storage/BulkDatabase2/streamLog.d.ts +25 -0
- package/storage/BulkDatabase2/streamLog.ts +128 -0
- package/storage/BulkDatabase2/syncClient.d.ts +9 -0
- package/storage/BulkDatabase2/syncClient.ts +81 -0
- package/storage/DiskCollection.d.ts +2 -0
- package/storage/DiskCollection.ts +17 -1
- package/storage/FileFolderAPI.d.ts +56 -0
- package/storage/FileFolderAPI.tsx +93 -19
- package/yarn.lock +2213 -2213
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
import { sort } from "socket-function/src/misc";
|
|
2
|
+
import { getTimeUnique } from "socket-function/src/bits";
|
|
3
|
+
import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase } from "./BulkDatabaseFormat";
|
|
4
|
+
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, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
10
|
+
import { Manifest, chooseManifest, isManifestName, manifestFileName, parseManifestStartTime } from "./manifest";
|
|
11
|
+
import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
|
|
12
|
+
import type { FileStorage } from "../FileFolderAPI";
|
|
13
|
+
|
|
14
|
+
// BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
|
|
15
|
+
// folder rather than sharing bulkDatabases/.
|
|
16
|
+
const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
17
|
+
const FILE_EXTENSION = ".bulk";
|
|
18
|
+
// Once this many bulk files pile up we run a merge pass to consolidate small ones (bounded by the
|
|
19
|
+
// byte caps below), so reads don't fan out across an unbounded number of files.
|
|
20
|
+
const MERGE_FILE_COUNT = 8;
|
|
21
|
+
|
|
22
|
+
// Memory ceiling for a single merge. Files are merged in contiguous (newest-first) runs whose combined
|
|
23
|
+
// LOGICAL (uncompressed) size stays under MERGE_MAX_BYTES, so a merge never reads more than this into
|
|
24
|
+
// memory at once. A file at or above MERGE_MIN_BYTES is "sealed": big enough already, never read back
|
|
25
|
+
// in to be merged again. Sizes are measured uncompressed (from each file's index) because that — not
|
|
26
|
+
// the smaller on-disk compressed size — is what actually lands in memory during a merge.
|
|
27
|
+
const MERGE_MIN_BYTES = 400 * 1024 * 1024;
|
|
28
|
+
const MERGE_MAX_BYTES = 800 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
// A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
|
|
31
|
+
// into a bulk file (streaming thousands of rows one frame at a time would be pointless).
|
|
32
|
+
const ROLLOVER_ROWS = 5000;
|
|
33
|
+
const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
// An unreadable file might be a write that is still in progress (another thread), so we can't delete
|
|
36
|
+
// it on sight. Once it has been unreadable for longer than this (by its filename timestamp), no
|
|
37
|
+
// writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
38
|
+
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// All the time thresholds, mutable so tests can shrink them from hours to milliseconds. The ordering
|
|
41
|
+
// invariant relies on: streamSealAgeMs < foldDataAgeMs (a file is sealed well before it's foldable),
|
|
42
|
+
// and foldTriggerAgeMs >= foldDataAgeMs.
|
|
43
|
+
export const bulkDatabase2Timing = {
|
|
44
|
+
// A writer stops appending to its current stream file once it's this old (starts a fresh one), so no
|
|
45
|
+
// file is ever appended to past this age.
|
|
46
|
+
streamSealAgeMs: 10 * 60 * 60 * 1000,
|
|
47
|
+
// A fold reads every stream file CREATED longer ago than this (all sealed, with margin), and moves
|
|
48
|
+
// only the entries whose WRITE-TIME is older than this into bulk; newer entries are re-streamed.
|
|
49
|
+
// This single cutoff is what keeps every bulk write strictly older than every stream write.
|
|
50
|
+
foldDataAgeMs: 12 * 60 * 60 * 1000,
|
|
51
|
+
// We don't bother folding until some stream file is older than this (fold in big infrequent batches).
|
|
52
|
+
foldTriggerAgeMs: 36 * 60 * 60 * 1000,
|
|
53
|
+
// Per-instance throttle on how often we scan to see whether a fold is due.
|
|
54
|
+
foldCheckIntervalMs: 5 * 1000,
|
|
55
|
+
// How long a superseded/orphaned/old-manifest file must sit before cleanup deletes it (by its name
|
|
56
|
+
// timestamp) — long enough that any reader still on an older manifest has finished.
|
|
57
|
+
cleanupAgeMs: 60 * 1000,
|
|
58
|
+
// Per-instance throttle on cleanup scans.
|
|
59
|
+
cleanupIntervalMs: 10 * 1000,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Marks a key as deleted in the in-memory overlay.
|
|
63
|
+
const DELETED = Symbol("deleted");
|
|
64
|
+
// Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
|
|
65
|
+
// remote write only overrides a key if it's newer than what we already have.
|
|
66
|
+
type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
|
|
67
|
+
|
|
68
|
+
// Composite cache/signal keys join two arbitrary strings with a NUL separator (which can't occur in
|
|
69
|
+
// the inputs). NUL is built from a char code so an actual NUL byte never appears in this source file
|
|
70
|
+
// (which would otherwise make tools treat it as binary).
|
|
71
|
+
const NULL = String.fromCharCode(0);
|
|
72
|
+
function nullJoin(a: string, b: string): string {
|
|
73
|
+
return a + NULL + b;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A tiny reactivity seam so this file has zero dependency on mobx (or any specific UI framework). The
|
|
77
|
+
// reactive in-memory state (the overlay map + the load/reset lifecycle) is plain; whenever it's read
|
|
78
|
+
// we "observe" a signal, and whenever it changes we "invalidate" that signal. A consumer that wants
|
|
79
|
+
// reactivity (e.g. the mobx subclass) supplies a ReactiveDeps that maps each signal string onto its
|
|
80
|
+
// own framework's dependency tracking; a consumer that doesn't can pass noopReactiveDeps.
|
|
81
|
+
export interface ReactiveDeps {
|
|
82
|
+
// Register `signal` as a dependency of whatever reactive context is currently reading.
|
|
83
|
+
observe(signal: string): void;
|
|
84
|
+
// Notify any context that observed `signal` that it changed.
|
|
85
|
+
invalidate(signal: string): void;
|
|
86
|
+
// Run a group of mutations + invalidations as one batch, so observers re-run at most once.
|
|
87
|
+
batch(fn: () => void): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A non-reactive ReactiveDeps: sync reads still return current values, they just never trigger
|
|
91
|
+
// re-renders. Use this when you don't need a UI to react to writes.
|
|
92
|
+
export const noopReactiveDeps: ReactiveDeps = {
|
|
93
|
+
observe() { },
|
|
94
|
+
invalidate() { },
|
|
95
|
+
batch(fn) { fn(); },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Provides the FileStorage for a given path (the caller decides where data physically lives, so this
|
|
99
|
+
// file needn't know about getFileStorageNested2 / the browser-vs-node storage details).
|
|
100
|
+
export type StorageFactory = (path: string) => Promise<FileStorage>;
|
|
101
|
+
|
|
102
|
+
// The load/reset lifecycle shares one signal; every sync read observes it so it re-renders when the
|
|
103
|
+
// reader resets or a base column/field finishes loading. The overlay's per-key signal is the key
|
|
104
|
+
// itself (a point read observes just its key), plus one overlay-wide signal that whole-column reads
|
|
105
|
+
// observe so they recompute on any overlay change. The NUL prefix keeps the two special signals
|
|
106
|
+
// from ever colliding with a real data key.
|
|
107
|
+
const LOAD_SIGNAL = NULL + "load";
|
|
108
|
+
const OVERLAY_SIGNAL = NULL + "overlay";
|
|
109
|
+
|
|
110
|
+
let fileNameCounter = 0;
|
|
111
|
+
// Random per-process id baked into file names so two processes (tabs) writing the same collection
|
|
112
|
+
// never collide on a name when they pick the same timestamp/counter in the same millisecond.
|
|
113
|
+
const writerId = Math.random().toString(36).slice(2, 10);
|
|
114
|
+
function nextCounter(): number {
|
|
115
|
+
return ++fileNameCounter;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
119
|
+
|
|
120
|
+
let lastFileTime = 0;
|
|
121
|
+
// A strictly-increasing integer timestamp for newly written files, so the newest-first order is
|
|
122
|
+
// unambiguous even when several writes land in the same millisecond. (getTimeUnique isn't used here
|
|
123
|
+
// because it may return a fractional value, which wouldn't round-trip through the integer file name.)
|
|
124
|
+
function nextFileTime(): number {
|
|
125
|
+
lastFileTime = Math.max(Date.now(), lastFileTime + 1);
|
|
126
|
+
return lastFileTime;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
|
|
130
|
+
// of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
|
|
131
|
+
// field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
|
|
132
|
+
function newFileName(timestamp: number): string {
|
|
133
|
+
return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type StreamFileInfo = { fileName: string; timestamp: number };
|
|
137
|
+
|
|
138
|
+
function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
|
|
139
|
+
if (!fileName.endsWith(STREAM_EXTENSION)) return undefined;
|
|
140
|
+
const parts = fileName.slice(0, -STREAM_EXTENSION.length).split("_");
|
|
141
|
+
// stream_<timestamp>_<random>
|
|
142
|
+
if (parts.length !== 3 || parts[0] !== "stream") return undefined;
|
|
143
|
+
const timestamp = parseInt(parts[1], 10);
|
|
144
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
145
|
+
return { fileName, timestamp };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
149
|
+
if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
|
|
150
|
+
const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
|
|
151
|
+
// Accept both the old 3-part (level_timestamp_counter) and new 4-part
|
|
152
|
+
// (level_timestamp_writerId_counter) shapes; level + timestamp are always the first two fields.
|
|
153
|
+
if (parts.length < 3) return undefined;
|
|
154
|
+
const level = parseInt(parts[0], 10);
|
|
155
|
+
const timestamp = parseInt(parts[1], 10);
|
|
156
|
+
if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
|
|
157
|
+
return { fileName, level, timestamp };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
|
|
161
|
+
// Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
|
|
162
|
+
export class BulkDatabaseBase<T extends { key: string }> {
|
|
163
|
+
constructor(
|
|
164
|
+
public readonly name: string,
|
|
165
|
+
protected deps: ReactiveDeps,
|
|
166
|
+
private storageFactory: StorageFactory,
|
|
167
|
+
) { }
|
|
168
|
+
|
|
169
|
+
// Block range cache is global and immutable-file-safe; clear it to simulate a cold page load
|
|
170
|
+
// (e.g. between an untimed prep step and the timed benchmark).
|
|
171
|
+
public static clearCache() {
|
|
172
|
+
blockCache.clear();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
public storage = lazy(async () => this.storageFactory(`${BULK_ROOT_FOLDER}/${this.name}`));
|
|
176
|
+
|
|
177
|
+
// In-memory overlay of pending writes/deletes. It takes priority over the loaded readers, so writes
|
|
178
|
+
// are reflected in reads without reloading. Reads observe the relevant signal; mutations invalidate it.
|
|
179
|
+
//
|
|
180
|
+
// NOTE: we never bound or clear this in-memory state during normal operation (only on a structural
|
|
181
|
+
// rollover/reset, where the data has been persisted into bulk files). The whole database must be
|
|
182
|
+
// resident in memory anyway — file merging reads every row — so a database large enough to blow
|
|
183
|
+
// the in-memory cache would already fail at merge time. There is no partial-load mode.
|
|
184
|
+
private overlay = new Map<string, OverlayEntry>();
|
|
185
|
+
// Latest stream-on-disk timestamp per key (from the loaded stream files). Used together with the
|
|
186
|
+
// overlay to decide whether an incoming remote write is actually newer than what we have.
|
|
187
|
+
private streamTimes = new Map<string, number>();
|
|
188
|
+
|
|
189
|
+
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
190
|
+
// so concurrent writers never touch the same file.
|
|
191
|
+
private streamFileName: string | undefined;
|
|
192
|
+
private lastCleanup = 0;
|
|
193
|
+
private lastFoldCheck = 0;
|
|
194
|
+
private getStreamFileName(): string {
|
|
195
|
+
// Seal (stop appending to) our current file once it's old enough, so no file is ever appended
|
|
196
|
+
// to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
|
|
197
|
+
if (this.streamFileName) {
|
|
198
|
+
const info = parseStreamFileName(this.streamFileName);
|
|
199
|
+
if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
|
|
200
|
+
}
|
|
201
|
+
if (!this.streamFileName) {
|
|
202
|
+
this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
203
|
+
}
|
|
204
|
+
return this.streamFileName;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private invalidateOverlay(key: string) {
|
|
208
|
+
this.deps.invalidate(key);
|
|
209
|
+
this.deps.invalidate(OVERLAY_SIGNAL);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Merges a (possibly partial) row onto the key's current overlay value, so a partial write/update
|
|
213
|
+
// only changes the columns it includes — the rest fall through to disk on read. A prior delete is
|
|
214
|
+
// reset (the key is being re-created).
|
|
215
|
+
private setOverlayRow(key: string, row: Record<string, unknown>, time: number) {
|
|
216
|
+
const existing = this.overlay.get(key);
|
|
217
|
+
const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
|
|
218
|
+
this.overlay.set(key, { time, value });
|
|
219
|
+
this.invalidateOverlay(key);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private setOverlayDeleted(key: string, time: number) {
|
|
223
|
+
this.overlay.set(key, { time, value: DELETED });
|
|
224
|
+
this.invalidateOverlay(key);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private reader = lazy(async (): Promise<ResolvedReader> => {
|
|
228
|
+
let start = Date.now();
|
|
229
|
+
const { bulkFiles, streamFiles } = await this.getValidFiles();
|
|
230
|
+
// Load everything in parallel: each bulk file's columnar reader, plus all streamed entries.
|
|
231
|
+
// A corrupt/truncated bulk file is skipped with a warning rather than breaking the load or
|
|
232
|
+
// returning bad values: the write protocol always writes a new file before removing the old
|
|
233
|
+
// ones it supersedes, so a partially-written file's data still exists in another file.
|
|
234
|
+
const [bulkReadersRaw, streamData] = await Promise.all([
|
|
235
|
+
Promise.all(bulkFiles.map(async f => {
|
|
236
|
+
try {
|
|
237
|
+
return await this.loadFileReader(f.fileName);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
await this.handleUnreadableFile(f, (e as Error).message);
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
})),
|
|
243
|
+
this.loadStreamEntries(streamFiles),
|
|
244
|
+
]);
|
|
245
|
+
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
246
|
+
// The join resolves purely by write-time, so reader order doesn't matter.
|
|
247
|
+
const readers: BaseBulkDatabaseReader[] = [];
|
|
248
|
+
const ordered = this.orderStreamEntries(streamData.entries);
|
|
249
|
+
if (ordered.length) {
|
|
250
|
+
const stream = streamReaderFromEntries(ordered, streamData.totalBytes);
|
|
251
|
+
readers.push(stream.reader);
|
|
252
|
+
this.streamTimes = stream.times;
|
|
253
|
+
} else {
|
|
254
|
+
this.streamTimes = new Map();
|
|
255
|
+
}
|
|
256
|
+
readers.push(...bulkReaders);
|
|
257
|
+
const joined = await joinBulkDatabases(readers);
|
|
258
|
+
|
|
259
|
+
let time = Date.now() - start;
|
|
260
|
+
if (time > 50) {
|
|
261
|
+
console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
|
|
262
|
+
}
|
|
263
|
+
void this.cleanup(); // opportunistic, throttled, fire-and-forget — reads help GC orphans too
|
|
264
|
+
return joined;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Connects to the cross-tab BroadcastChannel (browser only) so writes in other tabs of this
|
|
268
|
+
// collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
|
|
269
|
+
// We wait for the reader (and thus streamTimes) first so conflict resolution can see disk
|
|
270
|
+
// timestamps, then peers reply to our hello with recent writes that may not be on disk yet (applied
|
|
271
|
+
// through the same applyRemote callback).
|
|
272
|
+
private syncSetup = lazy(async () => {
|
|
273
|
+
if (!isSyncSupported()) return;
|
|
274
|
+
await this.reader();
|
|
275
|
+
let recent = await syncConnect(this.name, write => this.applyRemote(write));
|
|
276
|
+
for (let write of recent) this.applyRemote(write);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// The timestamp of the value we currently hold for a key (overlay first, then disk stream).
|
|
280
|
+
private localTime(key: string): number {
|
|
281
|
+
let entry = this.overlay.get(key);
|
|
282
|
+
if (entry) return entry.time;
|
|
283
|
+
let streamTime = this.streamTimes.get(key);
|
|
284
|
+
if (streamTime !== undefined) return streamTime;
|
|
285
|
+
return -Infinity;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Applies a write received from another tab. Only takes effect if it's newer than what we have,
|
|
289
|
+
// so it never clobbers our own (or disk's) more recent write for the same key.
|
|
290
|
+
private applyRemote(write: RemoteWrite) {
|
|
291
|
+
if (write.time <= this.localTime(write.key)) return;
|
|
292
|
+
this.deps.batch(() => {
|
|
293
|
+
if (write.deleted) this.setOverlayDeleted(write.key, write.time);
|
|
294
|
+
else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
|
|
299
|
+
// changes (large direct-bulk write, rollover, compact) after the data has been persisted.
|
|
300
|
+
private resetReader() {
|
|
301
|
+
this.deps.batch(() => {
|
|
302
|
+
this.reader.reset();
|
|
303
|
+
this.baseColumns.clear();
|
|
304
|
+
this.baseColumnsLoading.clear();
|
|
305
|
+
this.baseFields.clear();
|
|
306
|
+
this.baseFieldsLoading.clear();
|
|
307
|
+
this.overlay.clear();
|
|
308
|
+
this.deps.invalidate(LOAD_SIGNAL);
|
|
309
|
+
this.deps.invalidate(OVERLAY_SIGNAL);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- writes ----
|
|
314
|
+
|
|
315
|
+
public async write(entry: T): Promise<void> {
|
|
316
|
+
return this.writeBatch([entry]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
public async writeBatch(entries: T[]): Promise<void> {
|
|
320
|
+
if (!entries.length) return;
|
|
321
|
+
void this.syncSetup();
|
|
322
|
+
const rows = entries as unknown as Record<string, unknown>[];
|
|
323
|
+
// Stamp each row with a unique timestamp now, so the same time is used on disk, in the overlay,
|
|
324
|
+
// and in the cross-tab broadcast.
|
|
325
|
+
const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
|
|
326
|
+
const framed = frameRows(stamped);
|
|
327
|
+
|
|
328
|
+
// A batch that already exceeds the limits skips the tier-0 stream and writes a bulk file directly
|
|
329
|
+
// (streaming thousands of rows one frame at a time would be pointless).
|
|
330
|
+
if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
|
|
331
|
+
await this.writeBulkFile(rows);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Otherwise append to this thread's stream file (one cheap append) and reflect it in the
|
|
336
|
+
// overlay immediately — no reader reset.
|
|
337
|
+
const storage = await this.storage();
|
|
338
|
+
await storage.append(this.getStreamFileName(), framed);
|
|
339
|
+
this.deps.batch(() => {
|
|
340
|
+
for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
|
|
341
|
+
});
|
|
342
|
+
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
343
|
+
await this.maybeRolloverStream();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public async delete(key: string): Promise<void> {
|
|
347
|
+
return this.deleteBatch([key]);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
public async deleteBatch(keys: string[]): Promise<void> {
|
|
351
|
+
if (!keys.length) return;
|
|
352
|
+
void this.syncSetup();
|
|
353
|
+
const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
|
|
354
|
+
const storage = await this.storage();
|
|
355
|
+
await storage.append(this.getStreamFileName(), frameDeletes(stamped));
|
|
356
|
+
this.deps.batch(() => {
|
|
357
|
+
for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
|
|
358
|
+
});
|
|
359
|
+
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
360
|
+
await this.maybeRolloverStream();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
public async update(entry: Partial<T> & { key: string }): Promise<void> {
|
|
364
|
+
return this.updateBatch([entry]);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Like writeBatch, but each entry is a partial row — only the fields to change, plus the required
|
|
368
|
+
// key. Partial fields merge onto the existing row (unset columns fall through to the current value);
|
|
369
|
+
// an entry whose key isn't in the collection is skipped with a warning, since update never creates keys.
|
|
370
|
+
public async updateBatch(entries: (Partial<T> & { key: string })[]): Promise<void> {
|
|
371
|
+
if (!entries.length) return;
|
|
372
|
+
void this.syncSetup();
|
|
373
|
+
const reader = await this.reader();
|
|
374
|
+
const diskKeys = new Set(reader.keys);
|
|
375
|
+
const present: T[] = [];
|
|
376
|
+
for (const entry of entries) {
|
|
377
|
+
const overlayEntry = this.overlay.get(entry.key);
|
|
378
|
+
const exists = overlayEntry ? overlayEntry.value !== DELETED : diskKeys.has(entry.key);
|
|
379
|
+
if (!exists) {
|
|
380
|
+
console.warn(`${this.name}.update: key ${JSON.stringify(entry.key)} is not in the collection, ignoring`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
present.push(entry as unknown as T);
|
|
384
|
+
}
|
|
385
|
+
if (present.length) await this.writeBatch(present);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Resolves the authoritative on-disk state via manifests (see manifest.ts): valid bulk files
|
|
389
|
+
// (newest-first) + valid stream files, plus the chosen manifest and the raw name lists the
|
|
390
|
+
// commit/cleanup paths need. No manifest at all => every bulk file is valid (back-compat). Stream
|
|
391
|
+
// files are valid unless the chosen manifest lists them as already folded into a bulk file.
|
|
392
|
+
private async getValidFiles(): Promise<{
|
|
393
|
+
bulkFiles: BulkFileInfo[];
|
|
394
|
+
streamFiles: StreamFileInfo[];
|
|
395
|
+
manifest: Manifest | undefined;
|
|
396
|
+
manifestName: string | undefined;
|
|
397
|
+
allBulkNames: string[];
|
|
398
|
+
allStreamNames: string[];
|
|
399
|
+
manifestNames: string[];
|
|
400
|
+
}> {
|
|
401
|
+
const storage = await this.storage();
|
|
402
|
+
const names = await storage.getKeys();
|
|
403
|
+
const manifestNames: string[] = [];
|
|
404
|
+
const allBulkNames: string[] = [];
|
|
405
|
+
const allStreamNames: string[] = [];
|
|
406
|
+
for (const n of names) {
|
|
407
|
+
if (isManifestName(n)) manifestNames.push(n);
|
|
408
|
+
else if (n.endsWith(FILE_EXTENSION)) allBulkNames.push(n);
|
|
409
|
+
else if (n.endsWith(STREAM_EXTENSION)) allStreamNames.push(n);
|
|
410
|
+
}
|
|
411
|
+
const parsed = (await Promise.all(manifestNames.map(async name => {
|
|
412
|
+
try {
|
|
413
|
+
const buf = await storage.get(name);
|
|
414
|
+
if (!buf) return undefined;
|
|
415
|
+
return { name, manifest: JSON.parse(buf.toString("utf8")) as Manifest };
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined; // torn/corrupt/half-written manifest — ignore it
|
|
418
|
+
}
|
|
419
|
+
}))).filter((m): m is { name: string; manifest: Manifest } => !!m);
|
|
420
|
+
const chosen = chooseManifest(parsed);
|
|
421
|
+
|
|
422
|
+
let validBulkNames: string[];
|
|
423
|
+
if (!chosen) {
|
|
424
|
+
validBulkNames = allBulkNames;
|
|
425
|
+
} else {
|
|
426
|
+
const valid = new Set(chosen.manifest.validBulkFiles);
|
|
427
|
+
validBulkNames = allBulkNames.filter(n => valid.has(n));
|
|
428
|
+
}
|
|
429
|
+
const ignored = new Set(chosen?.manifest.ignoredStreamFiles || []);
|
|
430
|
+
const validStreamNames = allStreamNames.filter(n => !ignored.has(n));
|
|
431
|
+
|
|
432
|
+
const bulkFiles = validBulkNames.flatMap(n => { const p = parseFileName(n); return p ? [p] : []; });
|
|
433
|
+
// Newest-first by timestamp; ties broken by file name for determinism.
|
|
434
|
+
bulkFiles.sort((a, b) => {
|
|
435
|
+
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
436
|
+
return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
|
|
437
|
+
});
|
|
438
|
+
const streamFiles = validStreamNames.flatMap(n => { const p = parseStreamFileName(n); return p ? [p] : []; });
|
|
439
|
+
sort(streamFiles, f => f.timestamp);
|
|
440
|
+
|
|
441
|
+
return { bulkFiles, streamFiles, manifest: chosen?.manifest, manifestName: chosen?.name, allBulkNames, allStreamNames, manifestNames };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Writes a brand-new manifest (never clobbering an existing one) capturing the full valid state.
|
|
445
|
+
private async commitManifest(startTime: number, readFiles: string[], validBulkFiles: string[], ignoredStreamFiles: string[]): Promise<void> {
|
|
446
|
+
const storage = await this.storage();
|
|
447
|
+
const manifest: Manifest = { startTime, validBulkFiles, ignoredStreamFiles, readFiles };
|
|
448
|
+
await storage.set(manifestFileName(startTime, writerId, nextCounter()), Buffer.from(JSON.stringify(manifest), "utf8"));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Writes `rows` directly as bulk file(s), stamped with the current time as their data range (the rows
|
|
452
|
+
// are being written now). Used by the large-batch write path. Commits a new manifest adding them to
|
|
453
|
+
// the valid set. The rows carry time=now, so the join orders them correctly against any older stream
|
|
454
|
+
// entry for the same key (newer time wins) — no clobber.
|
|
455
|
+
private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
|
|
456
|
+
const storage = await this.storage();
|
|
457
|
+
const startTime = nextFileTime();
|
|
458
|
+
const view = await this.getValidFiles();
|
|
459
|
+
const now = Date.now();
|
|
460
|
+
const times = rows.map(() => now);
|
|
461
|
+
const newBulkNames: string[] = [];
|
|
462
|
+
for (const buffer of buildFileBuffer(rows, times)) {
|
|
463
|
+
const name = newFileName(startTime);
|
|
464
|
+
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
465
|
+
newBulkNames.push(name);
|
|
466
|
+
}
|
|
467
|
+
const validBulkFiles = view.bulkFiles.map(f => f.fileName).concat(newBulkNames);
|
|
468
|
+
const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
|
|
469
|
+
await this.commitManifest(startTime, readFiles, validBulkFiles, view.manifest?.ignoredStreamFiles || []);
|
|
470
|
+
this.resetReader();
|
|
471
|
+
if (validBulkFiles.length >= MERGE_FILE_COUNT) {
|
|
472
|
+
while (await this.mergeFiles() > 0) { /* consolidate accumulated bulk files */ }
|
|
473
|
+
}
|
|
474
|
+
await this.cleanup();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Fold. Reads every stream file CREATED before the cutoff (all sealed, since seal age < fold age),
|
|
478
|
+
// resolves them into one bulk file carrying each key's latest write-time per row, and re-persists
|
|
479
|
+
// surviving tombstones to a fresh stream file (so deletes of keys living in older bulk files keep
|
|
480
|
+
// suppressing them). Because reads resolve by write-time, the folded data needn't be older than the
|
|
481
|
+
// stream — the join sorts it out — so we just fold whole files, no cutoff split. Folded files are
|
|
482
|
+
// marked ignored (cleanup deletes them later); the new manifest is the atomic swap.
|
|
483
|
+
private async consolidate(): Promise<void> {
|
|
484
|
+
const storage = await this.storage();
|
|
485
|
+
const startTime = nextFileTime();
|
|
486
|
+
const view = await this.getValidFiles();
|
|
487
|
+
const cutoff = Date.now() - bulkDatabase2Timing.foldDataAgeMs;
|
|
488
|
+
const selected = view.streamFiles.filter(f => f.timestamp < cutoff);
|
|
489
|
+
if (!selected.length) return;
|
|
490
|
+
const { entries } = await this.loadStreamEntries(selected);
|
|
491
|
+
const ordered = this.orderStreamEntries(entries);
|
|
492
|
+
|
|
493
|
+
const byKey = new Map<string, Record<string, unknown>>();
|
|
494
|
+
const byKeyTime = new Map<string, number>();
|
|
495
|
+
const deleted = new Map<string, number>();
|
|
496
|
+
for (const e of ordered) {
|
|
497
|
+
if (e.deletedKey !== undefined) {
|
|
498
|
+
byKey.delete(e.deletedKey);
|
|
499
|
+
byKeyTime.delete(e.deletedKey);
|
|
500
|
+
deleted.set(e.deletedKey, e.time);
|
|
501
|
+
} else if (e.row) {
|
|
502
|
+
const key = e.row.key as string;
|
|
503
|
+
byKey.set(key, { ...byKey.get(key), ...e.row });
|
|
504
|
+
byKeyTime.set(key, e.time); // ordered ascending, so this ends up the latest write
|
|
505
|
+
deleted.delete(key);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const newBulkNames: string[] = [];
|
|
510
|
+
if (byKey.size) {
|
|
511
|
+
const rows = [...byKey.values()];
|
|
512
|
+
const times = rows.map(r => byKeyTime.get(r.key as string)!);
|
|
513
|
+
for (const buffer of buildFileBuffer(rows, times)) {
|
|
514
|
+
const name = newFileName(startTime);
|
|
515
|
+
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
516
|
+
newBulkNames.push(name);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Surviving tombstones -> a fresh stream file (always valid). Written BEFORE the manifest so the
|
|
521
|
+
// folded sources are never ignored while their deletes are missing.
|
|
522
|
+
if (deleted.size) {
|
|
523
|
+
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
524
|
+
await storage.set(carryName, frameDeletes([...deleted].map(([key, time]) => ({ time, key }))));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const validBulkFiles = view.bulkFiles.map(f => f.fileName).concat(newBulkNames);
|
|
528
|
+
const ignoredStreamFiles = [...new Set([...(view.manifest?.ignoredStreamFiles || []), ...selected.map(f => f.fileName)])];
|
|
529
|
+
const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
|
|
530
|
+
await this.commitManifest(startTime, readFiles, validBulkFiles, ignoredStreamFiles);
|
|
531
|
+
this.resetReader();
|
|
532
|
+
|
|
533
|
+
if (validBulkFiles.length >= MERGE_FILE_COUNT) {
|
|
534
|
+
while (await this.mergeFiles() > 0) { /* consolidate accumulated bulk files */ }
|
|
535
|
+
}
|
|
536
|
+
await this.cleanup();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
|
|
540
|
+
// unique timestamp + originating file) so callers can order writes globally across files.
|
|
541
|
+
private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number }> {
|
|
542
|
+
if (!streamFiles.length) return { entries: [], totalBytes: 0 };
|
|
543
|
+
const storage = await this.storage();
|
|
544
|
+
// Read a bounded prefix [0, size) rather than the whole file: a foreign writer may be appending
|
|
545
|
+
// concurrently, and storage.get() errors when the file grows past the size it stat'd. Reading a
|
|
546
|
+
// prefix is tolerant — parseStream stops at the last complete frame, the file stays valid, and a
|
|
547
|
+
// later read picks up the rest. A file removed out from under us (cleanup) just yields undefined.
|
|
548
|
+
const buffers = await Promise.all(streamFiles.map(async f => {
|
|
549
|
+
try {
|
|
550
|
+
const info = await storage.getInfo(f.fileName);
|
|
551
|
+
if (!info || info.size === 0) return undefined;
|
|
552
|
+
return await storage.getRange(f.fileName, { start: 0, end: info.size });
|
|
553
|
+
} catch {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
}));
|
|
557
|
+
const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
|
|
558
|
+
let totalBytes = 0;
|
|
559
|
+
for (let i = 0; i < streamFiles.length; i++) {
|
|
560
|
+
const buffer = buffers[i];
|
|
561
|
+
if (!buffer) continue;
|
|
562
|
+
totalBytes += buffer.length;
|
|
563
|
+
const parsed = parseStream(buffer);
|
|
564
|
+
if (parsed.badBytes > 0) {
|
|
565
|
+
console.warn(`${this.name} stream file ${streamFiles[i].fileName} had ${parsed.badBytes} trailing bad/incomplete bytes (stopped reading there)`);
|
|
566
|
+
}
|
|
567
|
+
for (const entry of parsed.entries) {
|
|
568
|
+
entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { entries, totalBytes };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
|
|
575
|
+
private orderStreamEntries(entries: { time: number; fileName: string; entry: StreamEntry }[]): StreamEntry[] {
|
|
576
|
+
entries.sort((a, b) => {
|
|
577
|
+
if (a.time !== b.time) return a.time - b.time;
|
|
578
|
+
return a.fileName < b.fileName && -1 || a.fileName > b.fileName && 1 || 0;
|
|
579
|
+
});
|
|
580
|
+
return entries.map(e => e.entry);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Folding is purely age-driven and done in big infrequent batches: once some stream file is older
|
|
584
|
+
// than the trigger age, fold everything past the data cutoff. Throttled so we don't scan on every write.
|
|
585
|
+
private async maybeRolloverStream(): Promise<void> {
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
if (now - this.lastFoldCheck < bulkDatabase2Timing.foldCheckIntervalMs) return;
|
|
588
|
+
this.lastFoldCheck = now;
|
|
589
|
+
const { streamFiles } = await this.getValidFiles();
|
|
590
|
+
if (streamFiles.some(f => now - f.timestamp >= bulkDatabase2Timing.foldTriggerAgeMs)) {
|
|
591
|
+
await this.consolidate();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
|
|
596
|
+
// more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
|
|
597
|
+
// so it never loads the whole collection into memory — a multi-GB collection settles into several
|
|
598
|
+
// capped files (sealed files are left as-is) rather than one giant one.
|
|
599
|
+
public async compact(): Promise<void> {
|
|
600
|
+
let merged = false;
|
|
601
|
+
while (await this.mergeFiles() > 0) merged = true;
|
|
602
|
+
if (merged) this.resetReader();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
|
|
606
|
+
const storage = await this.storage();
|
|
607
|
+
const info = await storage.getInfo(fileName);
|
|
608
|
+
if (!info) return undefined;
|
|
609
|
+
const rawGetRange: GetRange = async (start, end) => {
|
|
610
|
+
if (end <= start) return EMPTY_BUFFER;
|
|
611
|
+
const buf = await storage.getRange(fileName, { start, end });
|
|
612
|
+
if (!buf) {
|
|
613
|
+
throw new Error(`Expected range [${start}, ${end}) of ${fileName}, file was missing`);
|
|
614
|
+
}
|
|
615
|
+
return buf;
|
|
616
|
+
};
|
|
617
|
+
return { rawGetRange, size: info.size };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
|
|
621
|
+
const raw = await this.makeRawGetRange(fileName);
|
|
622
|
+
if (!raw) {
|
|
623
|
+
throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
|
|
624
|
+
}
|
|
625
|
+
const fileId = nullJoin(this.name, fileName);
|
|
626
|
+
// Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
|
|
627
|
+
// decompressing version (same interface) and read the logical (uncompressed) size from its
|
|
628
|
+
// index. open() validates the file size against the index and throws if it's truncated/corrupt.
|
|
629
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
630
|
+
return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
|
|
634
|
+
// Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
|
|
635
|
+
// missing or unreadable so the planner simply leaves it out of any merge.
|
|
636
|
+
private async fileLogicalSize(fileName: string): Promise<number | undefined> {
|
|
637
|
+
try {
|
|
638
|
+
const raw = await this.makeRawGetRange(fileName);
|
|
639
|
+
if (!raw) return undefined;
|
|
640
|
+
const fileId = nullJoin(this.name, fileName);
|
|
641
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
642
|
+
return opened.uncompressedSize;
|
|
643
|
+
} catch {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// A bulk file that won't load is either a write still in progress (recent) or a stale partial write
|
|
649
|
+
// left by a crash. We can't tell which from the bytes, so we go by age: warn while it's young
|
|
650
|
+
// enough that a writer could still be finishing it, and delete it once it's clearly abandoned.
|
|
651
|
+
// Deleting is safe — the write protocol always writes a new file before removing the files it
|
|
652
|
+
// supersedes, so an abandoned partial file's data still lives in another (older) file.
|
|
653
|
+
private async handleUnreadableFile(file: BulkFileInfo, message: string): Promise<void> {
|
|
654
|
+
let ageMs = Date.now() - file.timestamp;
|
|
655
|
+
if (ageMs > STALE_DELETE_MS) {
|
|
656
|
+
console.warn(`${this.name}: deleting stale unreadable bulk file ${file.fileName} (${Math.round(ageMs / 86400000)}d old): ${message}`);
|
|
657
|
+
try {
|
|
658
|
+
let storage = await this.storage();
|
|
659
|
+
await storage.remove(file.fileName);
|
|
660
|
+
} catch (removeError) {
|
|
661
|
+
console.warn(`${this.name}: failed to delete ${file.fileName}: ${(removeError as Error).message}`);
|
|
662
|
+
}
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Merges exactly the files it's given, returning the new file name(s). It does NOT delete the inputs
|
|
669
|
+
// or write a manifest — the caller (mergeFiles) commits one manifest for the whole pass. The merge is
|
|
670
|
+
// per-COLUMN by write-TIME: for each key, each column takes the value with the newest write-time
|
|
671
|
+
// across the merged files (non-ABSENT), and the output row's time is the newest of those — so the
|
|
672
|
+
// merge preserves correct time-resolution (only collapsing per-column times within a single output
|
|
673
|
+
// row, the accepted per-row corner). The caller keeps the input under MERGE_MAX_BYTES.
|
|
674
|
+
private async mergeFilesBase(files: BulkFileInfo[], timestamp: number): Promise<string[]> {
|
|
675
|
+
const storage = await this.storage();
|
|
676
|
+
const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
|
|
677
|
+
|
|
678
|
+
const loaded = await Promise.all(readers.map(async reader => {
|
|
679
|
+
const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
|
|
680
|
+
for (const col of reader.columns) {
|
|
681
|
+
if (col.column === KEY_COLUMN) continue;
|
|
682
|
+
const entries = await reader.getColumn(col.column);
|
|
683
|
+
cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
|
|
684
|
+
}
|
|
685
|
+
return { keyTimes: reader.keyTimes, cols };
|
|
686
|
+
}));
|
|
687
|
+
|
|
688
|
+
const allKeys = new Set<string>();
|
|
689
|
+
const allCols = new Set<string>();
|
|
690
|
+
for (const l of loaded) {
|
|
691
|
+
for (const k of l.keyTimes.keys()) allKeys.add(k);
|
|
692
|
+
for (const c of l.cols.keys()) allCols.add(c);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const mergedRows: Record<string, unknown>[] = [];
|
|
696
|
+
const mergedTimes: number[] = [];
|
|
697
|
+
for (const key of allKeys) {
|
|
698
|
+
const row: Record<string, unknown> = { [KEY_COLUMN]: key };
|
|
699
|
+
let rowTime = -Infinity;
|
|
700
|
+
for (const col of allCols) {
|
|
701
|
+
let bestTime = -Infinity;
|
|
702
|
+
let bestVal: unknown;
|
|
703
|
+
let found = false;
|
|
704
|
+
for (const l of loaded) {
|
|
705
|
+
const cell = l.cols.get(col)?.get(key);
|
|
706
|
+
if (!cell || cell.value === ABSENT) continue;
|
|
707
|
+
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
708
|
+
}
|
|
709
|
+
if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
|
|
710
|
+
}
|
|
711
|
+
for (const l of loaded) { const t = l.keyTimes.get(key); if (t !== undefined && t > rowTime) rowTime = t; }
|
|
712
|
+
mergedRows.push(row);
|
|
713
|
+
mergedTimes.push(rowTime === -Infinity ? 0 : rowTime);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const names: string[] = [];
|
|
717
|
+
for (const buffer of buildFileBuffer(mergedRows, mergedTimes)) {
|
|
718
|
+
const name = newFileName(timestamp);
|
|
719
|
+
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
720
|
+
names.push(name);
|
|
721
|
+
}
|
|
722
|
+
return names;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// The cap-aware merge planner. Walks the valid files newest-first and merges contiguous runs of
|
|
726
|
+
// non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
|
|
727
|
+
// loads more than that into memory. A file at/over MERGE_MIN_BYTES is sealed (left untouched) and
|
|
728
|
+
// breaks the run, as does an unreadable file. The whole pass is committed as ONE new manifest:
|
|
729
|
+
// valid bulk = (old valid - consumed) + merged outputs; the consumed files are left on disk for
|
|
730
|
+
// cleanup. Returns the number of runs merged (0 means nothing left to consolidate).
|
|
731
|
+
private async mergeFiles(): Promise<number> {
|
|
732
|
+
// Best-effort cross-tab lock: if another tab is already merging, skip — the manifest backstop
|
|
733
|
+
// keeps us correct, and we'd only be racing to orphan each other's output. No-op in Node.
|
|
734
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return 0;
|
|
735
|
+
try {
|
|
736
|
+
return await this.mergeFilesLocked();
|
|
737
|
+
} finally {
|
|
738
|
+
releaseMergeLock(this.name, writerId);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private async mergeFilesLocked(): Promise<number> {
|
|
743
|
+
const startTime = nextFileTime();
|
|
744
|
+
const view = await this.getValidFiles();
|
|
745
|
+
const files = view.bulkFiles;
|
|
746
|
+
const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
|
|
747
|
+
|
|
748
|
+
const batches: BulkFileInfo[][] = [];
|
|
749
|
+
let batch: BulkFileInfo[] = [];
|
|
750
|
+
let batchBytes = 0;
|
|
751
|
+
const flush = () => {
|
|
752
|
+
// A run of one file is pointless to "merge" — only consolidate when there are at least two.
|
|
753
|
+
if (batch.length >= 2) batches.push(batch);
|
|
754
|
+
batch = [];
|
|
755
|
+
batchBytes = 0;
|
|
756
|
+
};
|
|
757
|
+
for (let i = 0; i < files.length; i++) {
|
|
758
|
+
const size = sizes[i];
|
|
759
|
+
if (size === undefined || size >= MERGE_MIN_BYTES) {
|
|
760
|
+
flush();
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
|
|
764
|
+
batch.push(files[i]);
|
|
765
|
+
batchBytes += size;
|
|
766
|
+
}
|
|
767
|
+
flush();
|
|
768
|
+
if (!batches.length) return 0;
|
|
769
|
+
|
|
770
|
+
const removed = new Set<string>();
|
|
771
|
+
const newBulkNames: string[] = [];
|
|
772
|
+
for (const runFiles of batches) {
|
|
773
|
+
// batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
|
|
774
|
+
const produced = await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
|
|
775
|
+
for (const f of runFiles) removed.add(f.fileName);
|
|
776
|
+
newBulkNames.push(...produced);
|
|
777
|
+
}
|
|
778
|
+
const validBulkFiles = files.map(f => f.fileName).filter(n => !removed.has(n)).concat(newBulkNames);
|
|
779
|
+
const ignoredStreamFiles = view.manifest?.ignoredStreamFiles || [];
|
|
780
|
+
const readFiles = [...view.allBulkNames, ...view.allStreamNames, ...view.manifestNames];
|
|
781
|
+
await this.commitManifest(startTime, readFiles, validBulkFiles, ignoredStreamFiles);
|
|
782
|
+
this.resetReader();
|
|
783
|
+
await this.cleanup();
|
|
784
|
+
return batches.length;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Deletes files no longer referenced by the authoritative manifest that have sat long enough that
|
|
788
|
+
// no reader still resolving an older manifest needs them: superseded/orphaned bulk files,
|
|
789
|
+
// folded-away (ignored) stream files, and every manifest but the newest. Age-gated by each file's
|
|
790
|
+
// own name timestamp and throttled per instance. Best-effort — a failed remove (another writer beat
|
|
791
|
+
// us to it) is ignored. We never delete a file whose name we can't parse for an age.
|
|
792
|
+
private async cleanup(): Promise<void> {
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
if (now - this.lastCleanup < bulkDatabase2Timing.cleanupIntervalMs) return;
|
|
795
|
+
this.lastCleanup = now;
|
|
796
|
+
// Best-effort and must never throw: it runs fire-and-forget from reads, and the directory could
|
|
797
|
+
// even be removed out from under us (e.g. the collection is being deleted) mid-scan.
|
|
798
|
+
try {
|
|
799
|
+
const storage = await this.storage();
|
|
800
|
+
const view = await this.getValidFiles();
|
|
801
|
+
const validBulk = new Set(view.bulkFiles.map(f => f.fileName));
|
|
802
|
+
const validStream = new Set(view.streamFiles.map(f => f.fileName));
|
|
803
|
+
const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
|
|
804
|
+
|
|
805
|
+
for (const name of view.allBulkNames) {
|
|
806
|
+
if (validBulk.has(name)) continue;
|
|
807
|
+
const info = parseFileName(name);
|
|
808
|
+
if (!info || now - info.timestamp < bulkDatabase2Timing.cleanupAgeMs) continue;
|
|
809
|
+
await remove(name);
|
|
810
|
+
}
|
|
811
|
+
for (const name of view.allStreamNames) {
|
|
812
|
+
if (validStream.has(name)) continue;
|
|
813
|
+
const info = parseStreamFileName(name);
|
|
814
|
+
if (!info || now - info.timestamp < bulkDatabase2Timing.cleanupAgeMs) continue;
|
|
815
|
+
await remove(name);
|
|
816
|
+
}
|
|
817
|
+
for (const name of view.manifestNames) {
|
|
818
|
+
if (name === view.manifestName) continue;
|
|
819
|
+
const startTime = parseManifestStartTime(name);
|
|
820
|
+
if (startTime === undefined || now - startTime < bulkDatabase2Timing.cleanupAgeMs) continue;
|
|
821
|
+
await remove(name);
|
|
822
|
+
}
|
|
823
|
+
} catch {
|
|
824
|
+
// ignore — cleanup is opportunistic; the next pass will catch up
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private formatInfo(reader: ResolvedReader): string {
|
|
829
|
+
return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
|
|
833
|
+
// overlay entry that doesn't include this column leaves the base (disk) value in place — a partial
|
|
834
|
+
// write/update only overrides the columns it set; everything else falls through.
|
|
835
|
+
private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
|
|
836
|
+
if (this.overlay.size === 0) return base;
|
|
837
|
+
const map = new Map(base.map(e => [e.key, e.value]));
|
|
838
|
+
for (const [key, entry] of this.overlay) {
|
|
839
|
+
if (entry.value === DELETED) { map.delete(key); continue; }
|
|
840
|
+
if (column in entry.value) map.set(key, entry.value[column]);
|
|
841
|
+
else if (!map.has(key)) map.set(key, undefined);
|
|
842
|
+
}
|
|
843
|
+
return [...map].map(([key, value]) => ({ key, value }));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ---- async reads (overlay-aware) ----
|
|
847
|
+
|
|
848
|
+
public async getSingleField<Column extends keyof T>(key: string, column: Column): Promise<T[Column] | undefined> {
|
|
849
|
+
void this.syncSetup();
|
|
850
|
+
const entry = this.overlay.get(key);
|
|
851
|
+
if (entry !== undefined) {
|
|
852
|
+
if (entry.value === DELETED) return undefined;
|
|
853
|
+
if (String(column) in entry.value) return entry.value[String(column)] as T[Column];
|
|
854
|
+
// column not set in the overlay entry — fall through to disk
|
|
855
|
+
}
|
|
856
|
+
let time = Date.now();
|
|
857
|
+
let reader = await this.reader();
|
|
858
|
+
let result = await reader.getSingleField(key, String(column)) as T[Column] | undefined;
|
|
859
|
+
time = Date.now() - time;
|
|
860
|
+
if (time > 50) {
|
|
861
|
+
console.log(`${blue(`${this.name}.getSingleField(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
|
|
862
|
+
}
|
|
863
|
+
return result;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
public async getColumn<Column extends keyof T>(column: Column): Promise<{ key: string; value: T[Column] }[]> {
|
|
867
|
+
void this.syncSetup();
|
|
868
|
+
let time = Date.now();
|
|
869
|
+
let reader = await this.reader();
|
|
870
|
+
let base = await reader.getColumn(String(column)) as { key: string; value: T[Column] }[];
|
|
871
|
+
let result = this.patchColumn(base, String(column)) as { key: string; value: T[Column] }[];
|
|
872
|
+
time = Date.now() - time;
|
|
873
|
+
if (time > 50) {
|
|
874
|
+
console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
|
|
875
|
+
}
|
|
876
|
+
return result;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
public async getKeys(): Promise<string[]> {
|
|
880
|
+
void this.syncSetup();
|
|
881
|
+
let reader = await this.reader();
|
|
882
|
+
if (this.overlay.size === 0) return reader.keys;
|
|
883
|
+
let set = new Set(reader.keys);
|
|
884
|
+
for (const [key, entry] of this.overlay) {
|
|
885
|
+
if (entry.value === DELETED) set.delete(key);
|
|
886
|
+
else set.add(key);
|
|
887
|
+
}
|
|
888
|
+
return [...set];
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ---- sync (reactive) reads ----
|
|
892
|
+
// These observe the overlay + load signals, so a reactive context that reads them re-runs when a
|
|
893
|
+
// write/delete happens or when a base value finishes loading. The immutable base column/field is
|
|
894
|
+
// loaded once and cached; the overlay is layered on top (we can't async-cache the combined result
|
|
895
|
+
// because the overlay mutates).
|
|
896
|
+
|
|
897
|
+
private baseColumns = new Map<string, { key: string; value: unknown }[]>();
|
|
898
|
+
private baseColumnsLoading = new Set<string>();
|
|
899
|
+
private baseFields = new Map<string, unknown>();
|
|
900
|
+
private baseFieldsLoading = new Set<string>();
|
|
901
|
+
|
|
902
|
+
private ensureBaseColumn(column: string) {
|
|
903
|
+
if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
|
|
904
|
+
this.baseColumnsLoading.add(column);
|
|
905
|
+
void (async () => {
|
|
906
|
+
let reader = await this.reader();
|
|
907
|
+
let base = await reader.getColumn(column);
|
|
908
|
+
this.deps.batch(() => {
|
|
909
|
+
this.baseColumns.set(column, base);
|
|
910
|
+
this.baseColumnsLoading.delete(column);
|
|
911
|
+
this.deps.invalidate(LOAD_SIGNAL);
|
|
912
|
+
});
|
|
913
|
+
})();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private ensureBaseField(key: string, column: string) {
|
|
917
|
+
let cacheKey = nullJoin(column, key);
|
|
918
|
+
if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
|
|
919
|
+
this.baseFieldsLoading.add(cacheKey);
|
|
920
|
+
void (async () => {
|
|
921
|
+
let reader = await this.reader();
|
|
922
|
+
let value = await reader.getSingleField(key, column);
|
|
923
|
+
this.deps.batch(() => {
|
|
924
|
+
this.baseFields.set(cacheKey, value);
|
|
925
|
+
this.baseFieldsLoading.delete(cacheKey);
|
|
926
|
+
this.deps.invalidate(LOAD_SIGNAL);
|
|
927
|
+
});
|
|
928
|
+
})();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
public getSingleFieldSync<Column extends keyof T>(key: string, column: Column): T[Column] | undefined {
|
|
932
|
+
void this.syncSetup();
|
|
933
|
+
this.deps.observe(LOAD_SIGNAL);
|
|
934
|
+
this.deps.observe(key);
|
|
935
|
+
let col = String(column);
|
|
936
|
+
let entry = this.overlay.get(key);
|
|
937
|
+
if (entry !== undefined) {
|
|
938
|
+
if (entry.value === DELETED) return undefined;
|
|
939
|
+
if (col in entry.value) return entry.value[col] as T[Column];
|
|
940
|
+
// column not set in the overlay entry — fall through to the base field cache
|
|
941
|
+
}
|
|
942
|
+
let cacheKey = nullJoin(col, key);
|
|
943
|
+
if (!this.baseFields.has(cacheKey)) {
|
|
944
|
+
this.ensureBaseField(key, col);
|
|
945
|
+
return undefined;
|
|
946
|
+
}
|
|
947
|
+
return this.baseFields.get(cacheKey) as T[Column] | undefined;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
public getColumnSync<Column extends keyof T>(column: Column): { key: string; value: T[Column] }[] | undefined {
|
|
951
|
+
void this.syncSetup();
|
|
952
|
+
this.deps.observe(LOAD_SIGNAL);
|
|
953
|
+
// Observe the overlay-wide signal so we recompute once the base arrives or the overlay changes.
|
|
954
|
+
this.deps.observe(OVERLAY_SIGNAL);
|
|
955
|
+
let col = String(column);
|
|
956
|
+
let base = this.baseColumns.get(col);
|
|
957
|
+
if (!base) {
|
|
958
|
+
this.ensureBaseColumn(col);
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
return this.patchColumn(base, col) as { key: string; value: T[Column] }[];
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
public async getColumnInfo() {
|
|
965
|
+
let reader = await this.reader();
|
|
966
|
+
return reader.columns;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
public async getReaderInfo() {
|
|
970
|
+
let reader = await this.reader();
|
|
971
|
+
return {
|
|
972
|
+
rowCount: reader.rowCount,
|
|
973
|
+
totalBytes: reader.totalBytes,
|
|
974
|
+
keyCount: reader.keys.length,
|
|
975
|
+
sampleKey: reader.keys[0] as string | undefined,
|
|
976
|
+
columns: reader.columns,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// The merged, time-resolved view over all readers. getColumn/getSingleField return plain resolved
|
|
982
|
+
// values (not {value,time}); the base layers the overlay on top of these.
|
|
983
|
+
type ResolvedReader = {
|
|
984
|
+
rowCount: number;
|
|
985
|
+
totalBytes: number;
|
|
986
|
+
keys: string[];
|
|
987
|
+
columns: { column: string; byteSize: number }[];
|
|
988
|
+
getColumn: (column: string) => Promise<{ key: string; value: unknown }[]>;
|
|
989
|
+
getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
|
|
993
|
+
// - a column resolves to the value with the newest write-time among readers that set it (non-ABSENT);
|
|
994
|
+
// a reader that never set the column for that key falls through to an older reader.
|
|
995
|
+
// - a key is live iff its newest write is newer than its newest delete; per column, the value is
|
|
996
|
+
// suppressed if a delete is newer than that column's newest set.
|
|
997
|
+
// No reliance on file order or partitioning — time is the only thing that decides.
|
|
998
|
+
async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<ResolvedReader> {
|
|
999
|
+
const deleteTime = new Map<string, number>();
|
|
1000
|
+
for (const db of databases) {
|
|
1001
|
+
if (!db.deleteTimes) continue;
|
|
1002
|
+
for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
|
|
1003
|
+
}
|
|
1004
|
+
const keyTime = new Map<string, number>();
|
|
1005
|
+
for (const db of databases) {
|
|
1006
|
+
for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
|
|
1007
|
+
}
|
|
1008
|
+
const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
|
|
1009
|
+
// Live keys: newest write strictly newer than newest delete.
|
|
1010
|
+
const keys: string[] = [];
|
|
1011
|
+
for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
|
|
1012
|
+
|
|
1013
|
+
const columns: { column: string; byteSize: number }[] = [];
|
|
1014
|
+
const columnByName = new Map<string, { column: string; byteSize: number }>();
|
|
1015
|
+
for (const db of databases) {
|
|
1016
|
+
for (const col of db.columns) {
|
|
1017
|
+
let existing = columnByName.get(col.column);
|
|
1018
|
+
if (!existing) {
|
|
1019
|
+
existing = { column: col.column, byteSize: 0 };
|
|
1020
|
+
columnByName.set(col.column, existing);
|
|
1021
|
+
columns.push(existing);
|
|
1022
|
+
}
|
|
1023
|
+
existing.byteSize += col.byteSize;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
1029
|
+
rowCount: keys.length,
|
|
1030
|
+
keys,
|
|
1031
|
+
columns,
|
|
1032
|
+
async getColumn(column) {
|
|
1033
|
+
const perReader = await Promise.all(databases.map(async db => {
|
|
1034
|
+
if (!db.columns.some(c => c.column === column)) return undefined;
|
|
1035
|
+
const entries = await db.getColumn(column);
|
|
1036
|
+
return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
1037
|
+
}));
|
|
1038
|
+
return keys.map(key => {
|
|
1039
|
+
let bestTime = -Infinity;
|
|
1040
|
+
let bestVal: unknown;
|
|
1041
|
+
let found = false;
|
|
1042
|
+
for (const m of perReader) {
|
|
1043
|
+
const cell = m && m.get(key);
|
|
1044
|
+
if (!cell || cell.value === ABSENT) continue;
|
|
1045
|
+
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
1046
|
+
}
|
|
1047
|
+
return { key, value: (found && bestTime > delOf(key)) ? bestVal : undefined };
|
|
1048
|
+
});
|
|
1049
|
+
},
|
|
1050
|
+
async getSingleField(key, column) {
|
|
1051
|
+
let bestTime = -Infinity;
|
|
1052
|
+
let bestVal: unknown;
|
|
1053
|
+
let found = false;
|
|
1054
|
+
for (const db of databases) {
|
|
1055
|
+
if (!db.columns.some(c => c.column === column)) continue;
|
|
1056
|
+
const r = await db.getSingleField(key, column);
|
|
1057
|
+
if (r === ABSENT) continue;
|
|
1058
|
+
if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
|
|
1059
|
+
}
|
|
1060
|
+
return (found && bestTime > delOf(key)) ? bestVal : undefined;
|
|
1061
|
+
},
|
|
1062
|
+
};
|
|
1063
|
+
}
|