sliftutils 1.3.0 → 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/index.d.ts +133 -10
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +71 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +64 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +24 -5
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +443 -177
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +11 -3
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +66 -20
- 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.ts +21 -9
- package/yarn.lock +2213 -2213
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { sort } from "socket-function/src/misc";
|
|
2
2
|
import { getTimeUnique } from "socket-function/src/bits";
|
|
3
|
-
import { BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, loadBulkDatabase } from "./BulkDatabaseFormat";
|
|
3
|
+
import { ABSENT, BaseBulkDatabaseReader, buildFileBuffer, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase } from "./BulkDatabaseFormat";
|
|
4
4
|
import { lazy } from "socket-function/src/caching";
|
|
5
5
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
6
|
import { blue, red } from "socket-function/src/formatting/logColors";
|
|
7
7
|
import { blockCache, encodeCompressedBlocks, GetRange } from "./blockCache";
|
|
8
8
|
import { STREAM_EXTENSION, StreamEntry, frameRows, frameDeletes, parseStream, streamReaderFromEntries } from "./streamLog";
|
|
9
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";
|
|
10
12
|
import type { FileStorage } from "../FileFolderAPI";
|
|
11
13
|
|
|
12
14
|
// BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
|
|
@@ -25,18 +27,38 @@ const MERGE_FILE_COUNT = 8;
|
|
|
25
27
|
const MERGE_MIN_BYTES = 400 * 1024 * 1024;
|
|
26
28
|
const MERGE_MAX_BYTES = 800 * 1024 * 1024;
|
|
27
29
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// already exceeds the row/byte limits skips streaming and writes a bulk file directly.
|
|
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).
|
|
31
32
|
const ROLLOVER_ROWS = 5000;
|
|
32
33
|
const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
33
|
-
const ROLLOVER_FILES = 100;
|
|
34
34
|
|
|
35
35
|
// An unreadable file might be a write that is still in progress (another thread), so we can't delete
|
|
36
36
|
// it on sight. Once it has been unreadable for longer than this (by its filename timestamp), no
|
|
37
37
|
// writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
38
38
|
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
39
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
|
+
|
|
40
62
|
// Marks a key as deleted in the in-memory overlay.
|
|
41
63
|
const DELETED = Symbol("deleted");
|
|
42
64
|
// Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
|
|
@@ -86,6 +108,12 @@ const LOAD_SIGNAL = NULL + "load";
|
|
|
86
108
|
const OVERLAY_SIGNAL = NULL + "overlay";
|
|
87
109
|
|
|
88
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
|
+
}
|
|
89
117
|
|
|
90
118
|
type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
91
119
|
|
|
@@ -100,10 +128,9 @@ function nextFileTime(): number {
|
|
|
100
128
|
|
|
101
129
|
// Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
|
|
102
130
|
// of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
|
|
103
|
-
// field kept so the name stays in the historical
|
|
131
|
+
// field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
|
|
104
132
|
function newFileName(timestamp: number): string {
|
|
105
|
-
|
|
106
|
-
return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
|
|
133
|
+
return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
type StreamFileInfo = { fileName: string; timestamp: number };
|
|
@@ -121,7 +148,9 @@ function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
|
|
|
121
148
|
function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
122
149
|
if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
|
|
123
150
|
const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
|
|
124
|
-
|
|
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;
|
|
125
154
|
const level = parseInt(parts[0], 10);
|
|
126
155
|
const timestamp = parseInt(parts[1], 10);
|
|
127
156
|
if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
|
|
@@ -160,24 +189,44 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
160
189
|
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
161
190
|
// so concurrent writers never touch the same file.
|
|
162
191
|
private streamFileName: string | undefined;
|
|
163
|
-
private
|
|
192
|
+
private lastCleanup = 0;
|
|
193
|
+
private lastFoldCheck = 0;
|
|
164
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
|
+
}
|
|
165
201
|
if (!this.streamFileName) {
|
|
166
202
|
this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
167
203
|
}
|
|
168
204
|
return this.streamFileName;
|
|
169
205
|
}
|
|
170
206
|
|
|
171
|
-
|
|
172
|
-
private setOverlay(key: string, entry: OverlayEntry) {
|
|
173
|
-
this.overlay.set(key, entry);
|
|
207
|
+
private invalidateOverlay(key: string) {
|
|
174
208
|
this.deps.invalidate(key);
|
|
175
209
|
this.deps.invalidate(OVERLAY_SIGNAL);
|
|
176
210
|
}
|
|
177
211
|
|
|
178
|
-
|
|
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> => {
|
|
179
228
|
let start = Date.now();
|
|
180
|
-
const
|
|
229
|
+
const { bulkFiles, streamFiles } = await this.getValidFiles();
|
|
181
230
|
// Load everything in parallel: each bulk file's columnar reader, plus all streamed entries.
|
|
182
231
|
// A corrupt/truncated bulk file is skipped with a warning rather than breaking the load or
|
|
183
232
|
// returning bad values: the write protocol always writes a new file before removing the old
|
|
@@ -194,8 +243,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
194
243
|
this.loadStreamEntries(streamFiles),
|
|
195
244
|
]);
|
|
196
245
|
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
197
|
-
//
|
|
198
|
-
// and lets the stream's deletes tombstone keys in the older bulk readers).
|
|
246
|
+
// The join resolves purely by write-time, so reader order doesn't matter.
|
|
199
247
|
const readers: BaseBulkDatabaseReader[] = [];
|
|
200
248
|
const ordered = this.orderStreamEntries(streamData.entries);
|
|
201
249
|
if (ordered.length) {
|
|
@@ -206,13 +254,13 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
206
254
|
this.streamTimes = new Map();
|
|
207
255
|
}
|
|
208
256
|
readers.push(...bulkReaders);
|
|
209
|
-
const joined = joinBulkDatabases(readers);
|
|
257
|
+
const joined = await joinBulkDatabases(readers);
|
|
210
258
|
|
|
211
259
|
let time = Date.now() - start;
|
|
212
260
|
if (time > 50) {
|
|
213
|
-
|
|
214
|
-
console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files, ${blue(formatNumber(totalKeysSize))} keys size)`);
|
|
261
|
+
console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
|
|
215
262
|
}
|
|
263
|
+
void this.cleanup(); // opportunistic, throttled, fire-and-forget — reads help GC orphans too
|
|
216
264
|
return joined;
|
|
217
265
|
});
|
|
218
266
|
|
|
@@ -242,8 +290,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
242
290
|
private applyRemote(write: RemoteWrite) {
|
|
243
291
|
if (write.time <= this.localTime(write.key)) return;
|
|
244
292
|
this.deps.batch(() => {
|
|
245
|
-
if (write.deleted) this.
|
|
246
|
-
else this.
|
|
293
|
+
if (write.deleted) this.setOverlayDeleted(write.key, write.time);
|
|
294
|
+
else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
|
|
247
295
|
});
|
|
248
296
|
}
|
|
249
297
|
|
|
@@ -277,10 +325,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
277
325
|
const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
|
|
278
326
|
const framed = frameRows(stamped);
|
|
279
327
|
|
|
280
|
-
// A batch that already exceeds the
|
|
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).
|
|
281
330
|
if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
|
|
282
331
|
await this.writeBulkFile(rows);
|
|
283
|
-
this.resetReader();
|
|
284
332
|
return;
|
|
285
333
|
}
|
|
286
334
|
|
|
@@ -288,9 +336,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
288
336
|
// overlay immediately — no reader reset.
|
|
289
337
|
const storage = await this.storage();
|
|
290
338
|
await storage.append(this.getStreamFileName(), framed);
|
|
291
|
-
this.streamRowsWritten += entries.length;
|
|
292
339
|
this.deps.batch(() => {
|
|
293
|
-
for (const { time, row } of stamped) this.
|
|
340
|
+
for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
|
|
294
341
|
});
|
|
295
342
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
296
343
|
await this.maybeRolloverStream();
|
|
@@ -306,38 +353,187 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
306
353
|
const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
|
|
307
354
|
const storage = await this.storage();
|
|
308
355
|
await storage.append(this.getStreamFileName(), frameDeletes(stamped));
|
|
309
|
-
this.streamRowsWritten += keys.length;
|
|
310
356
|
this.deps.batch(() => {
|
|
311
|
-
for (const { time, key } of stamped) this.
|
|
357
|
+
for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
|
|
312
358
|
});
|
|
313
359
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
314
360
|
await this.maybeRolloverStream();
|
|
315
361
|
}
|
|
316
362
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
321
455
|
private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
|
|
322
456
|
const storage = await this.storage();
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
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);
|
|
326
466
|
}
|
|
327
|
-
|
|
328
|
-
|
|
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 */ }
|
|
329
473
|
}
|
|
474
|
+
await this.cleanup();
|
|
330
475
|
}
|
|
331
476
|
|
|
332
|
-
|
|
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> {
|
|
333
484
|
const storage = await this.storage();
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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();
|
|
341
537
|
}
|
|
342
538
|
|
|
343
539
|
// Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
|
|
@@ -345,7 +541,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
345
541
|
private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number }> {
|
|
346
542
|
if (!streamFiles.length) return { entries: [], totalBytes: 0 };
|
|
347
543
|
const storage = await this.storage();
|
|
348
|
-
|
|
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
|
+
}));
|
|
349
557
|
const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
|
|
350
558
|
let totalBytes = 0;
|
|
351
559
|
for (let i = 0; i < streamFiles.length; i++) {
|
|
@@ -372,51 +580,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
372
580
|
return entries.map(e => e.entry);
|
|
373
581
|
}
|
|
374
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.
|
|
375
585
|
private async maybeRolloverStream(): Promise<void> {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
if (streamFiles.length > ROLLOVER_FILES || totalBytes > ROLLOVER_BYTES || this.streamRowsWritten > ROLLOVER_ROWS) {
|
|
384
|
-
await this.rolloverStream(streamFiles);
|
|
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();
|
|
385
592
|
}
|
|
386
593
|
}
|
|
387
594
|
|
|
388
|
-
// Combine all tier-0 stream files into a single columnar bulk file (newest-wins per key, deletes
|
|
389
|
-
// applied), delete the consumed stream files, and re-persist surviving tombstones to a fresh
|
|
390
|
-
// stream file so deletes of keys that live in older bulk files are not lost.
|
|
391
|
-
private async rolloverStream(streamFiles: StreamFileInfo[]): Promise<void> {
|
|
392
|
-
const { entries } = await this.loadStreamEntries(streamFiles);
|
|
393
|
-
const ordered = this.orderStreamEntries(entries);
|
|
394
|
-
const byKey = new Map<string, Record<string, unknown>>();
|
|
395
|
-
const deleted = new Map<string, number>();
|
|
396
|
-
for (const e of ordered) {
|
|
397
|
-
if (e.deletedKey !== undefined) {
|
|
398
|
-
byKey.delete(e.deletedKey);
|
|
399
|
-
deleted.set(e.deletedKey, e.time);
|
|
400
|
-
} else if (e.row) {
|
|
401
|
-
let key = e.row.key as string;
|
|
402
|
-
byKey.set(key, e.row);
|
|
403
|
-
deleted.delete(key);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (byKey.size) await this.writeBulkFile([...byKey.values()]);
|
|
407
|
-
const storage = await this.storage();
|
|
408
|
-
// Persist surviving tombstones (keeping their original timestamps) to a FRESH stream file
|
|
409
|
-
// before removing the consumed files, so a crash in between can't drop the deletes. The window
|
|
410
|
-
// is at worst redundant (deletes present in both old and new files), never missing.
|
|
411
|
-
this.streamFileName = undefined;
|
|
412
|
-
this.streamRowsWritten = 0;
|
|
413
|
-
if (deleted.size) {
|
|
414
|
-
await storage.append(this.getStreamFileName(), frameDeletes([...deleted].map(([key, time]) => ({ time, key }))));
|
|
415
|
-
}
|
|
416
|
-
for (const f of streamFiles) await storage.remove(f.fileName);
|
|
417
|
-
this.resetReader();
|
|
418
|
-
}
|
|
419
|
-
|
|
420
595
|
// Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
|
|
421
596
|
// more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
|
|
422
597
|
// so it never loads the whole collection into memory — a multi-GB collection settles into several
|
|
@@ -427,23 +602,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
427
602
|
if (merged) this.resetReader();
|
|
428
603
|
}
|
|
429
604
|
|
|
430
|
-
private async listFiles(): Promise<BulkFileInfo[]> {
|
|
431
|
-
const storage = await this.storage();
|
|
432
|
-
const names = await storage.getKeys();
|
|
433
|
-
const files = names.flatMap(n => {
|
|
434
|
-
const parsed = parseFileName(n);
|
|
435
|
-
return parsed && [parsed] || [];
|
|
436
|
-
});
|
|
437
|
-
// Newest-first by timestamp; ties broken by file name (descending) for a deterministic order.
|
|
438
|
-
// A merged file inherits the newest timestamp of the run it replaced, so it lands exactly where
|
|
439
|
-
// that run was — keeping newest-wins correct without any level bookkeeping.
|
|
440
|
-
files.sort((a, b) => {
|
|
441
|
-
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
442
|
-
return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
|
|
443
|
-
});
|
|
444
|
-
return files;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
605
|
private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
|
|
448
606
|
const storage = await this.storage();
|
|
449
607
|
const info = await storage.getInfo(fileName);
|
|
@@ -507,51 +665,84 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
507
665
|
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
|
|
508
666
|
}
|
|
509
667
|
|
|
510
|
-
// Merges exactly the files it's given (
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
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[]> {
|
|
515
675
|
const storage = await this.storage();
|
|
516
676
|
const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
|
|
517
677
|
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
for (const reader of readers) {
|
|
521
|
-
const colData: Record<string, unknown[]> = {};
|
|
678
|
+
const loaded = await Promise.all(readers.map(async reader => {
|
|
679
|
+
const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
|
|
522
680
|
for (const col of reader.columns) {
|
|
523
|
-
|
|
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 }])));
|
|
524
684
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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; }
|
|
532
708
|
}
|
|
533
|
-
|
|
709
|
+
if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
|
|
534
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);
|
|
535
714
|
}
|
|
536
715
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
await storage.set(
|
|
541
|
-
|
|
542
|
-
for (const f of files) {
|
|
543
|
-
await storage.remove(f.fileName);
|
|
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);
|
|
544
721
|
}
|
|
722
|
+
return names;
|
|
545
723
|
}
|
|
546
724
|
|
|
547
|
-
// The cap-aware merge planner. Walks the files newest-first and merges contiguous runs of
|
|
725
|
+
// The cap-aware merge planner. Walks the valid files newest-first and merges contiguous runs of
|
|
548
726
|
// non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
|
|
549
727
|
// loads more than that into memory. A file at/over MERGE_MIN_BYTES is sealed (left untouched) and
|
|
550
|
-
// breaks the run, as does an unreadable file.
|
|
551
|
-
//
|
|
552
|
-
//
|
|
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).
|
|
553
731
|
private async mergeFiles(): Promise<number> {
|
|
554
|
-
|
|
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;
|
|
555
746
|
const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
|
|
556
747
|
|
|
557
748
|
const batches: BulkFileInfo[][] = [];
|
|
@@ -574,25 +765,80 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
574
765
|
batchBytes += size;
|
|
575
766
|
}
|
|
576
767
|
flush();
|
|
768
|
+
if (!batches.length) return 0;
|
|
577
769
|
|
|
578
|
-
|
|
770
|
+
const removed = new Set<string>();
|
|
771
|
+
const newBulkNames: string[] = [];
|
|
579
772
|
for (const runFiles of batches) {
|
|
580
|
-
|
|
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);
|
|
581
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();
|
|
582
784
|
return batches.length;
|
|
583
785
|
}
|
|
584
786
|
|
|
585
|
-
|
|
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 {
|
|
586
829
|
return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
|
|
587
830
|
}
|
|
588
831
|
|
|
589
|
-
// Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty.
|
|
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.
|
|
590
835
|
private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
|
|
591
836
|
if (this.overlay.size === 0) return base;
|
|
592
837
|
const map = new Map(base.map(e => [e.key, e.value]));
|
|
593
838
|
for (const [key, entry] of this.overlay) {
|
|
594
|
-
if (entry.value === DELETED) map.delete(key);
|
|
595
|
-
|
|
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);
|
|
596
842
|
}
|
|
597
843
|
return [...map].map(([key, value]) => ({ key, value }));
|
|
598
844
|
}
|
|
@@ -604,7 +850,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
604
850
|
const entry = this.overlay.get(key);
|
|
605
851
|
if (entry !== undefined) {
|
|
606
852
|
if (entry.value === DELETED) return undefined;
|
|
607
|
-
return entry.value[String(column)] as T[Column];
|
|
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
|
|
608
855
|
}
|
|
609
856
|
let time = Date.now();
|
|
610
857
|
let reader = await this.reader();
|
|
@@ -689,7 +936,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
689
936
|
let entry = this.overlay.get(key);
|
|
690
937
|
if (entry !== undefined) {
|
|
691
938
|
if (entry.value === DELETED) return undefined;
|
|
692
|
-
return entry.value[col] as T[Column];
|
|
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
|
|
693
941
|
}
|
|
694
942
|
let cacheKey = nullJoin(col, key);
|
|
695
943
|
if (!this.baseFields.has(cacheKey)) {
|
|
@@ -730,24 +978,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
730
978
|
}
|
|
731
979
|
}
|
|
732
980
|
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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>();
|
|
738
1000
|
for (const db of databases) {
|
|
739
|
-
if (db.
|
|
1001
|
+
if (!db.deleteTimes) continue;
|
|
1002
|
+
for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
|
|
740
1003
|
}
|
|
741
|
-
|
|
742
|
-
const keys: string[] = [];
|
|
743
|
-
const keySeen = new Set<string>();
|
|
1004
|
+
const keyTime = new Map<string, number>();
|
|
744
1005
|
for (const db of databases) {
|
|
745
|
-
for (const key of db.
|
|
746
|
-
if (keySeen.has(key) || deleted.has(key)) continue;
|
|
747
|
-
keySeen.add(key);
|
|
748
|
-
keys.push(key);
|
|
749
|
-
}
|
|
1006
|
+
for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
|
|
750
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
|
+
|
|
751
1013
|
const columns: { column: string; byteSize: number }[] = [];
|
|
752
1014
|
const columnByName = new Map<string, { column: string; byteSize: number }>();
|
|
753
1015
|
for (const db of databases) {
|
|
@@ -761,37 +1023,41 @@ function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): BaseBulkDatabas
|
|
|
761
1023
|
existing.byteSize += col.byteSize;
|
|
762
1024
|
}
|
|
763
1025
|
}
|
|
1026
|
+
|
|
764
1027
|
return {
|
|
765
1028
|
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
766
1029
|
rowCount: keys.length,
|
|
767
1030
|
keys,
|
|
768
1031
|
columns,
|
|
769
1032
|
async getColumn(column) {
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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; }
|
|
777
1046
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
if (taken.has(key) || deleted.has(key)) continue;
|
|
781
|
-
taken.add(key);
|
|
782
|
-
result.push({ key, value: values && values[i] });
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
return result;
|
|
1047
|
+
return { key, value: (found && bestTime > delOf(key)) ? bestVal : undefined };
|
|
1048
|
+
});
|
|
786
1049
|
},
|
|
787
1050
|
async getSingleField(key, column) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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; }
|
|
793
1059
|
}
|
|
794
|
-
return undefined;
|
|
1060
|
+
return (found && bestTime > delOf(key)) ? bestVal : undefined;
|
|
795
1061
|
},
|
|
796
1062
|
};
|
|
797
1063
|
}
|