sliftutils 1.3.0 → 1.4.0
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 +147 -14
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +84 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +76 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +29 -8
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +565 -246
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +26 -3
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +124 -33
- package/storage/BulkDatabase2/mergeLock.d.ts +2 -0
- package/storage/BulkDatabase2/mergeLock.ts +48 -0
- package/storage/BulkDatabase2/streamLog.ts +21 -9
- package/storage/BulkDatabase2/syncClient.d.ts +2 -1
- package/storage/BulkDatabase2/syncClient.ts +19 -2
- package/storage/FileFolderAPI.tsx +6 -1
- package/yarn.lock +2213 -2213
|
@@ -1,42 +1,71 @@
|
|
|
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, BulkHeaderInfo, EMPTY_BUFFER, KEY_COLUMN, loadBulkDatabase, loadBulkHeader, TARGET_FILE_BYTES } 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
|
-
import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
9
|
+
import { connect as syncConnect, broadcast as syncBroadcast, broadcastSeal as syncBroadcastSeal, isSyncSupported, RemoteWrite } from "./syncClient";
|
|
10
|
+
import { tryAcquireMergeLock, releaseMergeLock } from "./mergeLock";
|
|
10
11
|
import type { FileStorage } from "../FileFolderAPI";
|
|
11
12
|
|
|
13
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// KNOWN BUGS (accepted, documented):
|
|
15
|
+
//
|
|
16
|
+
// • Inconsistent directory listing under concurrent merges. A read lists the directory, then loads
|
|
17
|
+
// each listed file. If a file is missing when we go to read it (a merge deleted it), we re-list and
|
|
18
|
+
// retry — the deleting merge wrote the replacement first, so the data is never gone, just moved.
|
|
19
|
+
// But a directory listing is not guaranteed atomic on every filesystem: a listing taken while
|
|
20
|
+
// another writer is mid-swap can in principle return a set of files that never simultaneously
|
|
21
|
+
// existed (e.g. the replacement but not a sibling it depends on). That yields a momentarily
|
|
22
|
+
// INCONSISTENT view — some keys may read stale or missing. It does NOT lose data on disk: a reload
|
|
23
|
+
// (refresh the page) re-lists and resolves correctly. We accept this rather than reintroduce a
|
|
24
|
+
// manifest; it's rare and OS/filesystem-dependent.
|
|
25
|
+
// ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
12
27
|
// BulkDatabase2's compressed-block format is not compatible with BulkDatabase, so it uses its own
|
|
13
28
|
// folder rather than sharing bulkDatabases/.
|
|
14
29
|
const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
15
30
|
const FILE_EXTENSION = ".bulk";
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
const MERGE_FILE_COUNT = 8;
|
|
19
|
-
|
|
20
|
-
// Memory ceiling for a single merge. Files are merged in contiguous (newest-first) runs whose combined
|
|
21
|
-
// LOGICAL (uncompressed) size stays under MERGE_MAX_BYTES, so a merge never reads more than this into
|
|
22
|
-
// memory at once. A file at or above MERGE_MIN_BYTES is "sealed": big enough already, never read back
|
|
23
|
-
// in to be merged again. Sizes are measured uncompressed (from each file's index) because that — not
|
|
24
|
-
// the smaller on-disk compressed size — is what actually lands in memory during a merge.
|
|
25
|
-
const MERGE_MIN_BYTES = 400 * 1024 * 1024;
|
|
26
|
-
const MERGE_MAX_BYTES = 800 * 1024 * 1024;
|
|
27
|
-
|
|
28
|
-
// Tier-0 streaming rolls over into a columnar bulk file once it gets big enough — by row count,
|
|
29
|
-
// byte size, or file count (many threads each stream to their own file). A single writeBatch that
|
|
30
|
-
// already exceeds the row/byte limits skips streaming and writes a bulk file directly.
|
|
31
|
+
// A single writeBatch that already exceeds these limits skips the tier-0 stream and folds straight
|
|
32
|
+
// into a bulk file (streaming thousands of rows one frame at a time would be pointless).
|
|
31
33
|
const ROLLOVER_ROWS = 5000;
|
|
32
34
|
const ROLLOVER_BYTES = 5 * 1024 * 1024;
|
|
33
|
-
const ROLLOVER_FILES = 100;
|
|
34
35
|
|
|
35
|
-
// An unreadable file might be a write
|
|
36
|
-
// it on sight. Once it
|
|
37
|
-
// writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
36
|
+
// An unreadable (corrupt/torn, not merely missing) file might be a write still in progress, so we
|
|
37
|
+
// can't delete it on sight. Once it's been unreadable for longer than this (by its name timestamp),
|
|
38
|
+
// no writer is plausibly still working on it, so we delete it. Until then we just warn.
|
|
38
39
|
const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
|
|
39
40
|
|
|
41
|
+
// A read lists the directory then loads each file; if a file vanished (a merge deleted it) we re-list
|
|
42
|
+
// and retry, since the merge wrote the replacement first. Bounded so a pathological merge storm can't
|
|
43
|
+
// loop forever — after this many tries we load whatever's currently there (the documented inconsistent
|
|
44
|
+
// -view bug), which a later reload resolves.
|
|
45
|
+
const MAX_READ_ATTEMPTS = 8;
|
|
46
|
+
|
|
47
|
+
// The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
|
|
48
|
+
// file (half the target, so it has room to grow before it needs splitting). The key-stratified second
|
|
49
|
+
// merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
|
|
50
|
+
// (multi-file) keys exceeds DUP_THRESHOLD — i.e. only when deduping actually buys enough.
|
|
51
|
+
const FIRST_MERGE_BYTES = TARGET_FILE_BYTES / 2;
|
|
52
|
+
const KEY_GROUP_BYTES = 800 * 1024 * 1024;
|
|
53
|
+
const DUP_THRESHOLD = 0.4;
|
|
54
|
+
|
|
55
|
+
// Time thresholds, mutable so tests can shrink them from hours to milliseconds.
|
|
56
|
+
export const bulkDatabase2Timing = {
|
|
57
|
+
// A writer stops appending to its current stream file once it's this old (starts a fresh one). A
|
|
58
|
+
// stream file older than this is therefore safe for a merge to delete: its writer has provably moved
|
|
59
|
+
// on to a new file and will never append to it again.
|
|
60
|
+
streamSealAgeMs: 10 * 60 * 60 * 1000,
|
|
61
|
+
// Per-instance throttle: a write triggers at most one background testMerge scan per this interval.
|
|
62
|
+
mergeCheckIntervalMs: 30 * 60 * 1000,
|
|
63
|
+
// The first merge fires when the recent (up to FIRST_MERGE_BYTES) files number more than this...
|
|
64
|
+
firstMergeTriggerFiles: 20,
|
|
65
|
+
// ...or span a wider write-time range than this (data trickling in slowly still gets consolidated).
|
|
66
|
+
firstMergeTriggerRangeMs: 3 * 24 * 60 * 60 * 1000,
|
|
67
|
+
};
|
|
68
|
+
|
|
40
69
|
// Marks a key as deleted in the in-memory overlay.
|
|
41
70
|
const DELETED = Symbol("deleted");
|
|
42
71
|
// Each overlay entry carries the write's unique timestamp so cross-tab writes can be ordered: a
|
|
@@ -86,6 +115,12 @@ const LOAD_SIGNAL = NULL + "load";
|
|
|
86
115
|
const OVERLAY_SIGNAL = NULL + "overlay";
|
|
87
116
|
|
|
88
117
|
let fileNameCounter = 0;
|
|
118
|
+
// Random per-process id baked into file names so two processes (tabs) writing the same collection
|
|
119
|
+
// never collide on a name when they pick the same timestamp/counter in the same millisecond.
|
|
120
|
+
const writerId = Math.random().toString(36).slice(2, 10);
|
|
121
|
+
function nextCounter(): number {
|
|
122
|
+
return ++fileNameCounter;
|
|
123
|
+
}
|
|
89
124
|
|
|
90
125
|
type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
91
126
|
|
|
@@ -100,10 +135,9 @@ function nextFileTime(): number {
|
|
|
100
135
|
|
|
101
136
|
// Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
|
|
102
137
|
// 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
|
|
138
|
+
// field kept so the name stays in the historical level_timestamp_..._counter shape parseFileName reads.
|
|
104
139
|
function newFileName(timestamp: number): string {
|
|
105
|
-
|
|
106
|
-
return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
|
|
140
|
+
return `0_${timestamp}_${writerId}_${nextCounter()}${FILE_EXTENSION}`;
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
type StreamFileInfo = { fileName: string; timestamp: number };
|
|
@@ -121,13 +155,21 @@ function parseStreamFileName(fileName: string): StreamFileInfo | undefined {
|
|
|
121
155
|
function parseFileName(fileName: string): BulkFileInfo | undefined {
|
|
122
156
|
if (!fileName.endsWith(FILE_EXTENSION)) return undefined;
|
|
123
157
|
const parts = fileName.slice(0, -FILE_EXTENSION.length).split("_");
|
|
124
|
-
|
|
158
|
+
// Accept both the old 3-part (level_timestamp_counter) and new 4-part
|
|
159
|
+
// (level_timestamp_writerId_counter) shapes; level + timestamp are always the first two fields.
|
|
160
|
+
if (parts.length < 3) return undefined;
|
|
125
161
|
const level = parseInt(parts[0], 10);
|
|
126
162
|
const timestamp = parseInt(parts[1], 10);
|
|
127
163
|
if (!Number.isFinite(level) || !Number.isFinite(timestamp)) return undefined;
|
|
128
164
|
return { fileName, level, timestamp };
|
|
129
165
|
}
|
|
130
166
|
|
|
167
|
+
// A file we listed is gone now (a concurrent merge deleted it after writing its replacement). Distinct
|
|
168
|
+
// from a corrupt/torn file: missing => the data moved, so re-list and retry; corrupt => skip the file.
|
|
169
|
+
class MissingFileError extends Error { }
|
|
170
|
+
// Thrown out of a read build when a listed file went missing mid-load, so the build re-lists and retries.
|
|
171
|
+
class FilesChangedError extends Error { }
|
|
172
|
+
|
|
131
173
|
// All of BulkDatabase2's behavior, with no dependency on mobx or on a particular storage backend.
|
|
132
174
|
// Reactivity is delegated to the injected ReactiveDeps and storage to the injected StorageFactory.
|
|
133
175
|
export class BulkDatabaseBase<T extends { key: string }> {
|
|
@@ -160,42 +202,82 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
160
202
|
// This instance's tier-0 stream file. Each instance (≈ each thread/tab) streams to its own file
|
|
161
203
|
// so concurrent writers never touch the same file.
|
|
162
204
|
private streamFileName: string | undefined;
|
|
163
|
-
|
|
205
|
+
// Seeded to construction time (not 0) so a fresh instance doesn't immediately seal+merge on its very
|
|
206
|
+
// first write — the first background merge check waits a full interval after construction.
|
|
207
|
+
private lastMergeCheck = Date.now();
|
|
164
208
|
private getStreamFileName(): string {
|
|
209
|
+
// Seal (stop appending to) our current file once it's old enough, so no file is ever appended
|
|
210
|
+
// to past the seal age — that's what lets a consolidation safely fold it once it's aged out.
|
|
211
|
+
if (this.streamFileName) {
|
|
212
|
+
const info = parseStreamFileName(this.streamFileName);
|
|
213
|
+
if (info && Date.now() - info.timestamp >= bulkDatabase2Timing.streamSealAgeMs) this.streamFileName = undefined;
|
|
214
|
+
}
|
|
165
215
|
if (!this.streamFileName) {
|
|
166
216
|
this.streamFileName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
167
217
|
}
|
|
168
218
|
return this.streamFileName;
|
|
169
219
|
}
|
|
170
220
|
|
|
171
|
-
|
|
172
|
-
private setOverlay(key: string, entry: OverlayEntry) {
|
|
173
|
-
this.overlay.set(key, entry);
|
|
221
|
+
private invalidateOverlay(key: string) {
|
|
174
222
|
this.deps.invalidate(key);
|
|
175
223
|
this.deps.invalidate(OVERLAY_SIGNAL);
|
|
176
224
|
}
|
|
177
225
|
|
|
178
|
-
|
|
226
|
+
// Merges a (possibly partial) row onto the key's current overlay value, so a partial write/update
|
|
227
|
+
// only changes the columns it includes — the rest fall through to disk on read. A prior delete is
|
|
228
|
+
// reset (the key is being re-created).
|
|
229
|
+
private setOverlayRow(key: string, row: Record<string, unknown>, time: number) {
|
|
230
|
+
const existing = this.overlay.get(key);
|
|
231
|
+
const value = existing && existing.value !== DELETED ? { ...existing.value, ...row } : { ...row };
|
|
232
|
+
this.overlay.set(key, { time, value });
|
|
233
|
+
this.invalidateOverlay(key);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private setOverlayDeleted(key: string, time: number) {
|
|
237
|
+
this.overlay.set(key, { time, value: DELETED });
|
|
238
|
+
this.invalidateOverlay(key);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private reader = lazy(async (): Promise<ResolvedReader> => {
|
|
242
|
+
// A merge can delete a file between our directory listing and our read of it. The merge wrote the
|
|
243
|
+
// replacement first, so the data isn't gone — it just moved to a file our stale listing didn't
|
|
244
|
+
// include. So on a missing file we re-list and rebuild. Bounded; the last attempt tolerates a
|
|
245
|
+
// missing file (loads whatever is there — the documented inconsistent-view bug, fixed by reload).
|
|
179
246
|
let start = Date.now();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
247
|
+
for (let attempt = 0; ; attempt++) {
|
|
248
|
+
try {
|
|
249
|
+
return await this.buildReader(start, attempt >= MAX_READ_ATTEMPTS);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e instanceof FilesChangedError && attempt < MAX_READ_ATTEMPTS) continue;
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// One read build over a directory listing. Loads every bulk file's columnar reader plus all streamed
|
|
258
|
+
// entries, then joins them by write-time. A corrupt/torn bulk file is skipped with a warning (its
|
|
259
|
+
// data lives in another file). A *missing* file (deleted by a concurrent merge) throws
|
|
260
|
+
// FilesChangedError so the caller re-lists — unless tolerateMissing, when we proceed without it.
|
|
261
|
+
private async buildReader(start: number, tolerateMissing: boolean): Promise<ResolvedReader> {
|
|
262
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
263
|
+
let filesChanged = false;
|
|
185
264
|
const [bulkReadersRaw, streamData] = await Promise.all([
|
|
186
265
|
Promise.all(bulkFiles.map(async f => {
|
|
187
266
|
try {
|
|
188
267
|
return await this.loadFileReader(f.fileName);
|
|
189
268
|
} catch (e) {
|
|
269
|
+
if (e instanceof MissingFileError) { filesChanged = true; return undefined; }
|
|
190
270
|
await this.handleUnreadableFile(f, (e as Error).message);
|
|
191
271
|
return undefined;
|
|
192
272
|
}
|
|
193
273
|
})),
|
|
194
274
|
this.loadStreamEntries(streamFiles),
|
|
195
275
|
]);
|
|
276
|
+
if (streamData.missing) filesChanged = true;
|
|
277
|
+
if (filesChanged && !tolerateMissing) throw new FilesChangedError();
|
|
278
|
+
|
|
196
279
|
const bulkReaders = bulkReadersRaw.filter((r): r is BaseBulkDatabaseReader => !!r);
|
|
197
|
-
//
|
|
198
|
-
// and lets the stream's deletes tombstone keys in the older bulk readers).
|
|
280
|
+
// The join resolves purely by write-time, so reader order doesn't matter.
|
|
199
281
|
const readers: BaseBulkDatabaseReader[] = [];
|
|
200
282
|
const ordered = this.orderStreamEntries(streamData.entries);
|
|
201
283
|
if (ordered.length) {
|
|
@@ -206,15 +288,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
206
288
|
this.streamTimes = new Map();
|
|
207
289
|
}
|
|
208
290
|
readers.push(...bulkReaders);
|
|
209
|
-
const joined = joinBulkDatabases(readers);
|
|
291
|
+
const joined = await joinBulkDatabases(readers);
|
|
210
292
|
|
|
211
293
|
let time = Date.now() - start;
|
|
212
294
|
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)`);
|
|
295
|
+
console.log(`${blue(`${this.name} loaded`)} in ${red(formatTime(time))} (${blue(formatNumber(joined.rowCount))} rows, ${bulkFiles.length} bulk + ${streamFiles.length} stream files)`);
|
|
215
296
|
}
|
|
216
297
|
return joined;
|
|
217
|
-
}
|
|
298
|
+
}
|
|
218
299
|
|
|
219
300
|
// Connects to the cross-tab BroadcastChannel (browser only) so writes in other tabs of this
|
|
220
301
|
// collection update our overlay. Runs once; no-op in Node / where BroadcastChannel is unavailable.
|
|
@@ -224,7 +305,9 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
224
305
|
private syncSetup = lazy(async () => {
|
|
225
306
|
if (!isSyncSupported()) return;
|
|
226
307
|
await this.reader();
|
|
227
|
-
|
|
308
|
+
// onSeal: a peer is about to fold recent data; drop our current stream file so we stop appending
|
|
309
|
+
// to it (our next write starts a fresh one), letting the merge fold it whole.
|
|
310
|
+
let recent = await syncConnect(this.name, write => this.applyRemote(write), () => { this.streamFileName = undefined; });
|
|
228
311
|
for (let write of recent) this.applyRemote(write);
|
|
229
312
|
});
|
|
230
313
|
|
|
@@ -242,8 +325,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
242
325
|
private applyRemote(write: RemoteWrite) {
|
|
243
326
|
if (write.time <= this.localTime(write.key)) return;
|
|
244
327
|
this.deps.batch(() => {
|
|
245
|
-
if (write.deleted) this.
|
|
246
|
-
else this.
|
|
328
|
+
if (write.deleted) this.setOverlayDeleted(write.key, write.time);
|
|
329
|
+
else this.setOverlayRow(write.key, write.value as Record<string, unknown>, write.time);
|
|
247
330
|
});
|
|
248
331
|
}
|
|
249
332
|
|
|
@@ -277,10 +360,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
277
360
|
const stamped = rows.map(row => ({ time: getTimeUnique(), row }));
|
|
278
361
|
const framed = frameRows(stamped);
|
|
279
362
|
|
|
280
|
-
// A batch that already exceeds the
|
|
363
|
+
// A batch that already exceeds the limits skips the tier-0 stream and writes a bulk file directly
|
|
364
|
+
// (streaming thousands of rows one frame at a time would be pointless).
|
|
281
365
|
if (entries.length >= ROLLOVER_ROWS || framed.length >= ROLLOVER_BYTES) {
|
|
282
366
|
await this.writeBulkFile(rows);
|
|
283
|
-
this.resetReader();
|
|
284
367
|
return;
|
|
285
368
|
}
|
|
286
369
|
|
|
@@ -288,12 +371,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
288
371
|
// overlay immediately — no reader reset.
|
|
289
372
|
const storage = await this.storage();
|
|
290
373
|
await storage.append(this.getStreamFileName(), framed);
|
|
291
|
-
this.streamRowsWritten += entries.length;
|
|
292
374
|
this.deps.batch(() => {
|
|
293
|
-
for (const { time, row } of stamped) this.
|
|
375
|
+
for (const { time, row } of stamped) this.setOverlayRow(row.key as string, row, time);
|
|
294
376
|
});
|
|
295
377
|
for (const { time, row } of stamped) syncBroadcast(this.name, { key: row.key as string, time, value: row });
|
|
296
|
-
|
|
378
|
+
void this.maybeMerge();
|
|
297
379
|
}
|
|
298
380
|
|
|
299
381
|
public async delete(key: string): Promise<void> {
|
|
@@ -306,46 +388,101 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
306
388
|
const stamped = keys.map(key => ({ time: getTimeUnique(), key }));
|
|
307
389
|
const storage = await this.storage();
|
|
308
390
|
await storage.append(this.getStreamFileName(), frameDeletes(stamped));
|
|
309
|
-
this.streamRowsWritten += keys.length;
|
|
310
391
|
this.deps.batch(() => {
|
|
311
|
-
for (const { time, key } of stamped) this.
|
|
392
|
+
for (const { time, key } of stamped) this.setOverlayDeleted(key, time);
|
|
312
393
|
});
|
|
313
394
|
for (const { time, key } of stamped) syncBroadcast(this.name, { key, time, deleted: true });
|
|
314
|
-
|
|
395
|
+
void this.maybeMerge();
|
|
315
396
|
}
|
|
316
397
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
398
|
+
public async update(entry: Partial<T> & { key: string }): Promise<void> {
|
|
399
|
+
return this.updateBatch([entry]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Like writeBatch, but each entry is a partial row — only the fields to change, plus the required
|
|
403
|
+
// key. Partial fields merge onto the existing row (unset columns fall through to the current value);
|
|
404
|
+
// an entry whose key isn't in the collection is skipped with a warning, since update never creates keys.
|
|
405
|
+
public async updateBatch(entries: (Partial<T> & { key: string })[]): Promise<void> {
|
|
406
|
+
if (!entries.length) return;
|
|
407
|
+
void this.syncSetup();
|
|
408
|
+
const reader = await this.reader();
|
|
409
|
+
const diskKeys = new Set(reader.keys);
|
|
410
|
+
const present: T[] = [];
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
const overlayEntry = this.overlay.get(entry.key);
|
|
413
|
+
const exists = overlayEntry ? overlayEntry.value !== DELETED : diskKeys.has(entry.key);
|
|
414
|
+
if (!exists) {
|
|
415
|
+
console.warn(`${this.name}.update: key ${JSON.stringify(entry.key)} is not in the collection, ignoring`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
present.push(entry as unknown as T);
|
|
329
419
|
}
|
|
420
|
+
if (present.length) await this.writeBatch(present);
|
|
330
421
|
}
|
|
331
422
|
|
|
332
|
-
|
|
423
|
+
// Lists every bulk + stream file currently on disk (no manifest — every file is part of the
|
|
424
|
+
// database). Bulk newest-first, streams oldest-first. Duplicate data (from a crashed/raced merge)
|
|
425
|
+
// is harmless: reads resolve by write-time and a later merge with enough duplication removes it.
|
|
426
|
+
private async listFiles(): Promise<{ bulkFiles: BulkFileInfo[]; streamFiles: StreamFileInfo[] }> {
|
|
333
427
|
const storage = await this.storage();
|
|
334
428
|
const names = await storage.getKeys();
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
429
|
+
const bulkFiles: BulkFileInfo[] = [];
|
|
430
|
+
const streamFiles: StreamFileInfo[] = [];
|
|
431
|
+
for (const n of names) {
|
|
432
|
+
if (n.endsWith(FILE_EXTENSION)) { const p = parseFileName(n); if (p) bulkFiles.push(p); }
|
|
433
|
+
else if (n.endsWith(STREAM_EXTENSION)) { const p = parseStreamFileName(n); if (p) streamFiles.push(p); }
|
|
434
|
+
}
|
|
435
|
+
// Newest-first by timestamp; ties broken by file name for determinism.
|
|
436
|
+
bulkFiles.sort((a, b) => {
|
|
437
|
+
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
438
|
+
return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
|
|
338
439
|
});
|
|
339
|
-
sort(
|
|
340
|
-
return
|
|
440
|
+
sort(streamFiles, f => f.timestamp);
|
|
441
|
+
return { bulkFiles, streamFiles };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Writes `rows` directly as bulk file(s), stamped with the current time as their write-time (the rows
|
|
445
|
+
// are being written now). Used by the large-batch write path: no manifest, just new files on disk.
|
|
446
|
+
// The rows carry time=now, so the join orders them correctly against any older stream entry for the
|
|
447
|
+
// same key (newer time wins) — no clobber. A later testMerge consolidates them.
|
|
448
|
+
private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
|
|
449
|
+
const storage = await this.storage();
|
|
450
|
+
const timestamp = nextFileTime();
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
const times = rows.map(() => now);
|
|
453
|
+
for (const buffer of buildFileBuffer(rows, times)) {
|
|
454
|
+
const name = newFileName(timestamp);
|
|
455
|
+
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
456
|
+
}
|
|
457
|
+
this.resetReader();
|
|
458
|
+
void this.maybeMerge();
|
|
341
459
|
}
|
|
342
460
|
|
|
343
461
|
// Reads and parses every stream file in parallel. Returns per-write entries (each carrying its
|
|
344
|
-
// unique timestamp + originating file) so callers can order writes globally across files
|
|
345
|
-
|
|
346
|
-
|
|
462
|
+
// unique timestamp + originating file) so callers can order writes globally across files, the
|
|
463
|
+
// prefix size we read per file (so a merge can verify nothing was appended before deleting it), and
|
|
464
|
+
// whether any listed file was missing (so a read can re-list and retry — a merge deleted it).
|
|
465
|
+
private async loadStreamEntries(streamFiles: StreamFileInfo[]): Promise<{ entries: { time: number; fileName: string; entry: StreamEntry }[]; totalBytes: number; missing: boolean; sizes: Map<string, number> }> {
|
|
466
|
+
const sizes = new Map<string, number>();
|
|
467
|
+
if (!streamFiles.length) return { entries: [], totalBytes: 0, missing: false, sizes };
|
|
347
468
|
const storage = await this.storage();
|
|
348
|
-
|
|
469
|
+
let missing = false;
|
|
470
|
+
// Read a bounded prefix [0, size) rather than the whole file: a foreign writer may be appending
|
|
471
|
+
// concurrently, and storage.get() errors when the file grows past the size it stat'd. Reading a
|
|
472
|
+
// prefix is tolerant — parseStream stops at the last complete frame, the file stays valid, and a
|
|
473
|
+
// later read picks up the rest. A file removed out from under us (a merge) sets `missing`.
|
|
474
|
+
const buffers = await Promise.all(streamFiles.map(async f => {
|
|
475
|
+
try {
|
|
476
|
+
const info = await storage.getInfo(f.fileName);
|
|
477
|
+
if (!info) { missing = true; return undefined; }
|
|
478
|
+
sizes.set(f.fileName, info.size);
|
|
479
|
+
if (info.size === 0) return undefined;
|
|
480
|
+
return await storage.getRange(f.fileName, { start: 0, end: info.size });
|
|
481
|
+
} catch {
|
|
482
|
+
missing = true;
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
}));
|
|
349
486
|
const entries: { time: number; fileName: string; entry: StreamEntry }[] = [];
|
|
350
487
|
let totalBytes = 0;
|
|
351
488
|
for (let i = 0; i < streamFiles.length; i++) {
|
|
@@ -360,7 +497,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
360
497
|
entries.push({ time: entry.time, fileName: streamFiles[i].fileName, entry });
|
|
361
498
|
}
|
|
362
499
|
}
|
|
363
|
-
return { entries, totalBytes };
|
|
500
|
+
return { entries, totalBytes, missing, sizes };
|
|
364
501
|
}
|
|
365
502
|
|
|
366
503
|
// Global mutation order across per-thread files: by unique timestamp, ties broken by file name.
|
|
@@ -372,88 +509,77 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
372
509
|
return entries.map(e => e.entry);
|
|
373
510
|
}
|
|
374
511
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
}
|
|
512
|
+
// Throttled, fire-and-forget after writes: run a background merge check at most once per interval.
|
|
513
|
+
private async maybeMerge(): Promise<void> {
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
if (now - this.lastMergeCheck < bulkDatabase2Timing.mergeCheckIntervalMs) return;
|
|
516
|
+
this.lastMergeCheck = now;
|
|
517
|
+
try {
|
|
518
|
+
await this.tryMergeNow();
|
|
519
|
+
} catch (e) {
|
|
520
|
+
console.warn(`${this.name}: background merge failed: ${(e as Error).message}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Runs one merge pass now (the same one maybeMerge runs on a timer). Returns whether it merged
|
|
525
|
+
// anything, and whether it bailed because another tab/process holds the merge lock — so a caller
|
|
526
|
+
// (e.g. a 30-minute scheduler) can tell "nothing to do" from "someone else is doing it".
|
|
527
|
+
public async tryMergeNow(): Promise<{ merged: boolean; lockFailed: boolean }> {
|
|
528
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return { merged: false, lockFailed: true };
|
|
529
|
+
try {
|
|
530
|
+
return { merged: await this.testMerge(), lockFailed: false };
|
|
531
|
+
} finally {
|
|
532
|
+
releaseMergeLock(this.name, writerId);
|
|
405
533
|
}
|
|
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
534
|
}
|
|
419
535
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// capped files (sealed files are left as-is) rather than one giant one.
|
|
536
|
+
// Full compaction: fold + dedup everything into key-sorted, ~256MB files. Reads the whole collection
|
|
537
|
+
// into memory (the accepted soft bound), so it's an explicit, occasional call. Deletes consumed bulk
|
|
538
|
+
// files and any stream file it's safe to (aged, or sealed-and-stable).
|
|
424
539
|
public async compact(): Promise<void> {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
540
|
+
if (!tryAcquireMergeLock(this.name, writerId)) return; // someone else is already merging; fine
|
|
541
|
+
try {
|
|
542
|
+
syncBroadcastSeal(this.name);
|
|
543
|
+
this.streamFileName = undefined;
|
|
544
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
545
|
+
if (bulkFiles.length + streamFiles.length >= 1) await this.mergeFileSet(bulkFiles, streamFiles);
|
|
546
|
+
} finally {
|
|
547
|
+
releaseMergeLock(this.name, writerId);
|
|
548
|
+
}
|
|
428
549
|
}
|
|
429
550
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
551
|
+
// The unified merge entry point: rewrite everything overlapping [timeLo, timeHi] into fresh
|
|
552
|
+
// key-sorted ~256MB bulk file(s). Selects bulk files by their header time range and stream files by
|
|
553
|
+
// their (creation .. seal-age) window. If the range reaches the present, first asks peers to seal so
|
|
554
|
+
// recent stream data is complete. Callers: testMerge (recent / key-group ranges); external callers
|
|
555
|
+
// can pass any range — older data just produces older files.
|
|
556
|
+
public async merge(timeLo: number, timeHi: number): Promise<void> {
|
|
557
|
+
if (timeHi >= Date.now()) { syncBroadcastSeal(this.name); this.streamFileName = undefined; }
|
|
558
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
559
|
+
const headers = await Promise.all(bulkFiles.map(f => this.readBulkHeader(f.fileName)));
|
|
560
|
+
const selBulk = bulkFiles.filter((f, i) => {
|
|
561
|
+
const h = headers[i];
|
|
562
|
+
if (!h) return false;
|
|
563
|
+
// Old files (no recorded time range) only belong to a merge that reaches back to the start.
|
|
564
|
+
if (!h.maxTime && !h.minTime) return timeLo <= 0;
|
|
565
|
+
return h.minTime <= timeHi && h.maxTime >= timeLo;
|
|
436
566
|
});
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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;
|
|
567
|
+
const selStream = streamFiles.filter(f =>
|
|
568
|
+
f.timestamp <= timeHi && f.timestamp + bulkDatabase2Timing.streamSealAgeMs >= timeLo);
|
|
569
|
+
if (selBulk.length + selStream.length < 2) return;
|
|
570
|
+
await this.mergeFileSet(selBulk, selStream);
|
|
445
571
|
}
|
|
446
572
|
|
|
447
|
-
|
|
573
|
+
// Throws MissingFileError (not a generic error) when the file is gone, so callers can distinguish a
|
|
574
|
+
// file a merge deleted (re-list and retry / skip) from a corrupt one (handle as unreadable).
|
|
575
|
+
private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number }> {
|
|
448
576
|
const storage = await this.storage();
|
|
449
577
|
const info = await storage.getInfo(fileName);
|
|
450
|
-
if (!info)
|
|
578
|
+
if (!info) throw new MissingFileError(`bulk file ${fileName} is missing`);
|
|
451
579
|
const rawGetRange: GetRange = async (start, end) => {
|
|
452
580
|
if (end <= start) return EMPTY_BUFFER;
|
|
453
581
|
const buf = await storage.getRange(fileName, { start, end });
|
|
454
|
-
if (!buf) {
|
|
455
|
-
throw new Error(`Expected range [${start}, ${end}) of ${fileName}, file was missing`);
|
|
456
|
-
}
|
|
582
|
+
if (!buf) throw new MissingFileError(`range [${start}, ${end}) of ${fileName} is missing`);
|
|
457
583
|
return buf;
|
|
458
584
|
};
|
|
459
585
|
return { rawGetRange, size: info.size };
|
|
@@ -461,9 +587,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
461
587
|
|
|
462
588
|
private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
|
|
463
589
|
const raw = await this.makeRawGetRange(fileName);
|
|
464
|
-
if (!raw) {
|
|
465
|
-
throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
|
|
466
|
-
}
|
|
467
590
|
const fileId = nullJoin(this.name, fileName);
|
|
468
591
|
// Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
|
|
469
592
|
// decompressing version (same interface) and read the logical (uncompressed) size from its
|
|
@@ -472,13 +595,25 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
472
595
|
return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
473
596
|
}
|
|
474
597
|
|
|
598
|
+
// Reads only a bulk file's header (row count, time range, key range) — no column data — for merge
|
|
599
|
+
// planning. Returns undefined for a missing/corrupt file so the planner just leaves it out.
|
|
600
|
+
private async readBulkHeader(fileName: string): Promise<BulkHeaderInfo | undefined> {
|
|
601
|
+
try {
|
|
602
|
+
const raw = await this.makeRawGetRange(fileName);
|
|
603
|
+
const fileId = nullJoin(this.name, fileName);
|
|
604
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
605
|
+
return await loadBulkHeader(opened.getRange, opened.uncompressedSize);
|
|
606
|
+
} catch {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
475
611
|
// Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
|
|
476
612
|
// Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
|
|
477
613
|
// missing or unreadable so the planner simply leaves it out of any merge.
|
|
478
614
|
private async fileLogicalSize(fileName: string): Promise<number | undefined> {
|
|
479
615
|
try {
|
|
480
616
|
const raw = await this.makeRawGetRange(fileName);
|
|
481
|
-
if (!raw) return undefined;
|
|
482
617
|
const fileId = nullJoin(this.name, fileName);
|
|
483
618
|
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
484
619
|
return opened.uncompressedSize;
|
|
@@ -507,92 +642,256 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
507
642
|
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
|
|
508
643
|
}
|
|
509
644
|
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
const seen = new Set<string>();
|
|
519
|
-
const mergedRows: Record<string, unknown>[] = [];
|
|
520
|
-
for (const reader of readers) {
|
|
521
|
-
const colData: Record<string, unknown[]> = {};
|
|
645
|
+
// Resolves a set of readers (stream + bulk) by ACTUAL write-time into merged rows + per-row times,
|
|
646
|
+
// plus the surviving tombstones (keys whose newest event is a delete). For each key/column, the
|
|
647
|
+
// value with the newest write-time across readers wins (non-ABSENT); the row's time is the newest of
|
|
648
|
+
// those. A key is deleted iff its newest delete is newer than its newest set. This is the same
|
|
649
|
+
// time-resolution reads use, captured so a merge can write the result back as bulk + a carry stream.
|
|
650
|
+
private async resolveReaders(readers: BaseBulkDatabaseReader[]): Promise<{ rows: Record<string, unknown>[]; times: number[]; deletes: Map<string, number> }> {
|
|
651
|
+
const loaded = await Promise.all(readers.map(async reader => {
|
|
652
|
+
const cols = new Map<string, Map<string, { value: unknown; time: number }>>();
|
|
522
653
|
for (const col of reader.columns) {
|
|
523
|
-
|
|
654
|
+
if (col.column === KEY_COLUMN) continue;
|
|
655
|
+
const entries = await reader.getColumn(col.column);
|
|
656
|
+
cols.set(col.column, new Map(entries.map(e => [e.key, { value: e.value, time: e.time }])));
|
|
657
|
+
}
|
|
658
|
+
return { keyTimes: reader.keyTimes, deleteTimes: reader.deleteTimes, cols };
|
|
659
|
+
}));
|
|
660
|
+
|
|
661
|
+
const deleteTime = new Map<string, number>();
|
|
662
|
+
for (const l of loaded) {
|
|
663
|
+
if (!l.deleteTimes) continue;
|
|
664
|
+
for (const [k, t] of l.deleteTimes) deleteTime.set(k, Math.max(deleteTime.get(k) ?? -Infinity, t));
|
|
665
|
+
}
|
|
666
|
+
const keyTime = new Map<string, number>();
|
|
667
|
+
for (const l of loaded) {
|
|
668
|
+
for (const [k, t] of l.keyTimes) keyTime.set(k, Math.max(keyTime.get(k) ?? -Infinity, t));
|
|
669
|
+
}
|
|
670
|
+
const allCols = new Set<string>();
|
|
671
|
+
for (const l of loaded) for (const c of l.cols.keys()) allCols.add(c);
|
|
672
|
+
|
|
673
|
+
const rows: Record<string, unknown>[] = [];
|
|
674
|
+
const times: number[] = [];
|
|
675
|
+
const deletes = new Map<string, number>();
|
|
676
|
+
const allKeys = new Set<string>([...keyTime.keys(), ...deleteTime.keys()]);
|
|
677
|
+
for (const key of allKeys) {
|
|
678
|
+
const setT = keyTime.get(key) ?? -Infinity;
|
|
679
|
+
const delT = deleteTime.get(key) ?? -Infinity;
|
|
680
|
+
if (setT <= delT) {
|
|
681
|
+
// The newest event for this key is a delete — carry the tombstone forward so it keeps
|
|
682
|
+
// suppressing any older set living in a file outside this merge.
|
|
683
|
+
if (delT > -Infinity) deletes.set(key, delT);
|
|
684
|
+
continue;
|
|
524
685
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
686
|
+
const row: Record<string, unknown> = { [KEY_COLUMN]: key };
|
|
687
|
+
let rowTime = setT;
|
|
688
|
+
for (const col of allCols) {
|
|
689
|
+
let bestTime = -Infinity;
|
|
690
|
+
let bestVal: unknown;
|
|
691
|
+
let found = false;
|
|
692
|
+
for (const l of loaded) {
|
|
693
|
+
const cell = l.cols.get(col)?.get(key);
|
|
694
|
+
if (!cell || cell.value === ABSENT) continue;
|
|
695
|
+
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
532
696
|
}
|
|
533
|
-
|
|
697
|
+
if (found) { row[col] = bestVal; if (bestTime > rowTime) rowTime = bestTime; }
|
|
534
698
|
}
|
|
699
|
+
rows.push(row);
|
|
700
|
+
times.push(rowTime === -Infinity ? 0 : rowTime);
|
|
535
701
|
}
|
|
702
|
+
return { rows, times, deletes };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// The one merge primitive. Reads the given bulk + stream files (skipping any that vanished or won't
|
|
706
|
+
// parse — their data lives elsewhere), resolves them by write-time, writes the result back as fresh
|
|
707
|
+
// key-sorted ~256MB bulk file(s) plus a carry stream for surviving tombstones, THEN deletes the
|
|
708
|
+
// inputs it consumed. Output is always written before any delete, so a crash leaves duplicates (next
|
|
709
|
+
// merge removes them), never a gap. A bulk file is deleted only if we actually read it; a stream file
|
|
710
|
+
// only if it's aged out (its writer has switched files) or — when cross-tab sync sealed it — its size
|
|
711
|
+
// didn't change while we read it. Returns whether it produced anything.
|
|
712
|
+
private async mergeFileSet(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]): Promise<boolean> {
|
|
713
|
+
const storage = await this.storage();
|
|
714
|
+
const timestamp = nextFileTime();
|
|
715
|
+
const now = Date.now();
|
|
716
|
+
|
|
717
|
+
const consumedBulk: BulkFileInfo[] = [];
|
|
718
|
+
const bulkReaders: BaseBulkDatabaseReader[] = [];
|
|
719
|
+
await Promise.all(bulkFiles.map(async f => {
|
|
720
|
+
try {
|
|
721
|
+
const r = await this.loadFileReader(f.fileName);
|
|
722
|
+
bulkReaders.push(r);
|
|
723
|
+
consumedBulk.push(f); // only files we actually read are safe to delete afterwards
|
|
724
|
+
} catch { /* missing or corrupt — skip; its data lives in another file */ }
|
|
725
|
+
}));
|
|
536
726
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
727
|
+
const streamData = await this.loadStreamEntries(streamFiles);
|
|
728
|
+
const ordered = this.orderStreamEntries(streamData.entries);
|
|
729
|
+
const streamReader = ordered.length ? streamReaderFromEntries(ordered, 0).reader : undefined;
|
|
730
|
+
|
|
731
|
+
const readers = streamReader ? [streamReader, ...bulkReaders] : bulkReaders;
|
|
732
|
+
if (!readers.length) return false;
|
|
733
|
+
|
|
734
|
+
const { rows, times, deletes } = await this.resolveReaders(readers);
|
|
735
|
+
|
|
736
|
+
// Write all outputs BEFORE deleting any input, so a throw mid-write just leaves duplicates.
|
|
737
|
+
const newNames: string[] = [];
|
|
738
|
+
if (rows.length) {
|
|
739
|
+
for (const buffer of buildFileBuffer(rows, times)) {
|
|
740
|
+
const name = newFileName(timestamp);
|
|
741
|
+
await storage.set(name, encodeCompressedBlocks(buffer));
|
|
742
|
+
newNames.push(name);
|
|
743
|
+
}
|
|
541
744
|
}
|
|
542
|
-
|
|
543
|
-
|
|
745
|
+
if (deletes.size) {
|
|
746
|
+
const carryName = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 10)}${STREAM_EXTENSION}`;
|
|
747
|
+
await storage.set(carryName, frameDeletes([...deletes].map(([key, time]) => ({ time, key }))));
|
|
544
748
|
}
|
|
749
|
+
|
|
750
|
+
const remove = async (name: string) => { try { await storage.remove(name); } catch { /* already gone */ } };
|
|
751
|
+
for (const f of consumedBulk) await remove(f.fileName);
|
|
752
|
+
for (const f of streamFiles) {
|
|
753
|
+
if (await this.canDeleteStream(f, now, streamData.sizes)) await remove(f.fileName);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
this.resetReader();
|
|
757
|
+
return newNames.length > 0 || deletes.size > 0;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// A stream file is safe to delete iff no writer will ever append to it again: it's aged past the seal
|
|
761
|
+
// age (its writer has provably started a fresh file), OR cross-tab sync is active (so the seal we
|
|
762
|
+
// broadcast reached peers) and its size hasn't changed since we read it (nothing was appended during
|
|
763
|
+
// the merge). When neither holds we leave it: its data is now duplicated into bulk (resolved by time)
|
|
764
|
+
// and a later merge deletes it once aged. (Recreate-on-append means even a wrong delete wouldn't lose
|
|
765
|
+
// data, but the aged check also rules out the rare sparse-offset append race.)
|
|
766
|
+
private async canDeleteStream(f: StreamFileInfo, now: number, sizes: Map<string, number>): Promise<boolean> {
|
|
767
|
+
if (now - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs) return true;
|
|
768
|
+
if (!isSyncSupported()) return false;
|
|
769
|
+
const readSize = sizes.get(f.fileName);
|
|
770
|
+
if (readSize === undefined) return false;
|
|
771
|
+
let info;
|
|
772
|
+
try { info = await (await this.storage()).getInfo(f.fileName); } catch { return false; }
|
|
773
|
+
return !!info && info.size === readSize;
|
|
545
774
|
}
|
|
546
775
|
|
|
547
|
-
// The
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
776
|
+
// The merge policy. Up to two passes:
|
|
777
|
+
// 1) Consolidate recent fragmentation: take the newest files up to ~FIRST_MERGE_BYTES and, if they
|
|
778
|
+
// number more than firstMergeTriggerFiles or span more than firstMergeTriggerRangeMs, merge them
|
|
779
|
+
// into one file. Seals first so recent stream data is complete; in Node (no cross-tab seal) only
|
|
780
|
+
// aged streams are folded, so we never re-fold the same un-deletable stream forever.
|
|
781
|
+
// 2) Key-stratify: sort all keys, walk them in ~KEY_GROUP_BYTES groups, and rewrite the single group
|
|
782
|
+
// whose fraction of duplicate (multi-file) keys is highest above DUP_THRESHOLD — merging every
|
|
783
|
+
// bulk file overlapping that key range. Over time this sorts the data into key-disjoint files.
|
|
784
|
+
// Returns whether either pass merged anything.
|
|
785
|
+
private async testMerge(): Promise<boolean> {
|
|
786
|
+
let merged = false;
|
|
556
787
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
788
|
+
// ── Pass 1: consolidate recent files. ──
|
|
789
|
+
// Only seal (ask peers + ourselves to abandon current stream files) when cross-tab sync can
|
|
790
|
+
// actually fold recent streams; in Node it would just churn — fragmenting streams every pass for
|
|
791
|
+
// no benefit, since canDeleteStream there only deletes aged files anyway.
|
|
792
|
+
const foldRecentStreams = isSyncSupported(); // see canDeleteStream: else we'd re-fold forever
|
|
793
|
+
if (foldRecentStreams) {
|
|
794
|
+
syncBroadcastSeal(this.name);
|
|
795
|
+
this.streamFileName = undefined; // seal our own current stream so its recent data is complete
|
|
796
|
+
}
|
|
797
|
+
{
|
|
798
|
+
const { bulkFiles, streamFiles } = await this.listFiles();
|
|
799
|
+
const bulkMeta = await Promise.all(bulkFiles.map(async f => {
|
|
800
|
+
const [size, header] = await Promise.all([this.fileLogicalSize(f.fileName), this.readBulkHeader(f.fileName)]);
|
|
801
|
+
return { kind: "bulk" as const, file: f, bytes: size ?? 0, time: header?.maxTime || f.timestamp };
|
|
802
|
+
}));
|
|
803
|
+
const streamMeta: { kind: "stream"; file: StreamFileInfo; bytes: number; time: number }[] = [];
|
|
804
|
+
for (const f of streamFiles) {
|
|
805
|
+
const aged = Date.now() - f.timestamp >= bulkDatabase2Timing.streamSealAgeMs;
|
|
806
|
+
if (!foldRecentStreams && !aged) continue;
|
|
807
|
+
let bytes = 0;
|
|
808
|
+
try { const info = await (await this.storage()).getInfo(f.fileName); bytes = info?.size ?? 0; } catch { bytes = 0; }
|
|
809
|
+
streamMeta.push({ kind: "stream", file: f, bytes, time: f.timestamp });
|
|
810
|
+
}
|
|
811
|
+
const items = [...bulkMeta, ...streamMeta].sort((a, b) => b.time - a.time);
|
|
812
|
+
const recent: typeof items = [];
|
|
813
|
+
let recentBytes = 0;
|
|
814
|
+
for (const it of items) {
|
|
815
|
+
recent.push(it);
|
|
816
|
+
recentBytes += it.bytes;
|
|
817
|
+
if (recentBytes >= FIRST_MERGE_BYTES) break;
|
|
818
|
+
}
|
|
819
|
+
const span = recent.length ? recent[0].time - recent[recent.length - 1].time : 0;
|
|
820
|
+
const triggered = recent.length >= 2
|
|
821
|
+
&& (recent.length > bulkDatabase2Timing.firstMergeTriggerFiles || span > bulkDatabase2Timing.firstMergeTriggerRangeMs);
|
|
822
|
+
if (triggered) {
|
|
823
|
+
const rb = recent.filter(i => i.kind === "bulk").map(i => (i.file as BulkFileInfo));
|
|
824
|
+
const rs = recent.filter(i => i.kind === "stream").map(i => (i.file as StreamFileInfo));
|
|
825
|
+
if (await this.mergeFileSet(rb, rs)) merged = true;
|
|
571
826
|
}
|
|
572
|
-
if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
|
|
573
|
-
batch.push(files[i]);
|
|
574
|
-
batchBytes += size;
|
|
575
827
|
}
|
|
576
|
-
flush();
|
|
577
828
|
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
await this.
|
|
829
|
+
// ── Pass 2: key-stratify the bulk files to remove duplication. ──
|
|
830
|
+
{
|
|
831
|
+
const { bulkFiles } = await this.listFiles();
|
|
832
|
+
if (bulkFiles.length >= 2) {
|
|
833
|
+
const infos = await Promise.all(bulkFiles.map(async f => {
|
|
834
|
+
try {
|
|
835
|
+
const reader = await this.loadFileReader(f.fileName);
|
|
836
|
+
const keys = reader.keys;
|
|
837
|
+
let min = keys[0], max = keys[0];
|
|
838
|
+
for (const k of keys) { if (k < min) min = k; if (k > max) max = k; }
|
|
839
|
+
return { file: f, keys, bytes: reader.totalBytes, min, max };
|
|
840
|
+
} catch {
|
|
841
|
+
return { file: f, keys: [] as string[], bytes: 0, min: undefined as string | undefined, max: undefined as string | undefined };
|
|
842
|
+
}
|
|
843
|
+
}));
|
|
844
|
+
const usable = infos.filter(i => i.keys.length > 0);
|
|
845
|
+
const keyCount = new Map<string, number>();
|
|
846
|
+
let totalSlots = 0, totalBytes = 0;
|
|
847
|
+
for (const i of usable) {
|
|
848
|
+
totalBytes += i.bytes;
|
|
849
|
+
for (const k of i.keys) { keyCount.set(k, (keyCount.get(k) || 0) + 1); totalSlots++; }
|
|
850
|
+
}
|
|
851
|
+
if (totalSlots > 0) {
|
|
852
|
+
const bytesPerSlot = totalBytes / totalSlots;
|
|
853
|
+
const sortedKeys = [...keyCount.keys()].sort();
|
|
854
|
+
// Walk sorted keys forming ~KEY_GROUP_BYTES groups; remember the group with the highest
|
|
855
|
+
// duplicate fraction over the threshold (the most benefit), then merge its files.
|
|
856
|
+
let best: { lo: string; hi: string; dup: number } | undefined;
|
|
857
|
+
let gStart = 0, gBytes = 0, gSlots = 0, gUnique = 0;
|
|
858
|
+
for (let i = 0; i < sortedKeys.length; i++) {
|
|
859
|
+
const c = keyCount.get(sortedKeys[i])!;
|
|
860
|
+
gBytes += c * bytesPerSlot; gSlots += c; gUnique += 1;
|
|
861
|
+
if (gBytes >= KEY_GROUP_BYTES || i === sortedKeys.length - 1) {
|
|
862
|
+
const dup = (gSlots - gUnique) / gSlots;
|
|
863
|
+
if (dup > DUP_THRESHOLD && (!best || dup > best.dup)) best = { lo: sortedKeys[gStart], hi: sortedKeys[i], dup };
|
|
864
|
+
gStart = i + 1; gBytes = 0; gSlots = 0; gUnique = 0;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (best) {
|
|
868
|
+
const lo = best.lo, hi = best.hi;
|
|
869
|
+
const groupFiles = usable
|
|
870
|
+
.filter(i => i.min !== undefined && i.max !== undefined && i.min <= hi && i.max >= lo)
|
|
871
|
+
.map(i => i.file);
|
|
872
|
+
if (groupFiles.length >= 2 && await this.mergeFileSet(groupFiles, [])) merged = true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
581
876
|
}
|
|
582
|
-
|
|
877
|
+
|
|
878
|
+
return merged;
|
|
583
879
|
}
|
|
584
880
|
|
|
585
|
-
private formatInfo(reader:
|
|
881
|
+
private formatInfo(reader: ResolvedReader): string {
|
|
586
882
|
return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
|
|
587
883
|
}
|
|
588
884
|
|
|
589
|
-
// Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty.
|
|
885
|
+
// Applies the overlay (pending writes/deletes) on top of a base column. No-op when empty. An
|
|
886
|
+
// overlay entry that doesn't include this column leaves the base (disk) value in place — a partial
|
|
887
|
+
// write/update only overrides the columns it set; everything else falls through.
|
|
590
888
|
private patchColumn(base: { key: string; value: unknown }[], column: string): { key: string; value: unknown }[] {
|
|
591
889
|
if (this.overlay.size === 0) return base;
|
|
592
890
|
const map = new Map(base.map(e => [e.key, e.value]));
|
|
593
891
|
for (const [key, entry] of this.overlay) {
|
|
594
|
-
if (entry.value === DELETED) map.delete(key);
|
|
595
|
-
|
|
892
|
+
if (entry.value === DELETED) { map.delete(key); continue; }
|
|
893
|
+
if (column in entry.value) map.set(key, entry.value[column]);
|
|
894
|
+
else if (!map.has(key)) map.set(key, undefined);
|
|
596
895
|
}
|
|
597
896
|
return [...map].map(([key, value]) => ({ key, value }));
|
|
598
897
|
}
|
|
@@ -604,7 +903,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
604
903
|
const entry = this.overlay.get(key);
|
|
605
904
|
if (entry !== undefined) {
|
|
606
905
|
if (entry.value === DELETED) return undefined;
|
|
607
|
-
return entry.value[String(column)] as T[Column];
|
|
906
|
+
if (String(column) in entry.value) return entry.value[String(column)] as T[Column];
|
|
907
|
+
// column not set in the overlay entry — fall through to disk
|
|
608
908
|
}
|
|
609
909
|
let time = Date.now();
|
|
610
910
|
let reader = await this.reader();
|
|
@@ -689,7 +989,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
689
989
|
let entry = this.overlay.get(key);
|
|
690
990
|
if (entry !== undefined) {
|
|
691
991
|
if (entry.value === DELETED) return undefined;
|
|
692
|
-
return entry.value[col] as T[Column];
|
|
992
|
+
if (col in entry.value) return entry.value[col] as T[Column];
|
|
993
|
+
// column not set in the overlay entry — fall through to the base field cache
|
|
693
994
|
}
|
|
694
995
|
let cacheKey = nullJoin(col, key);
|
|
695
996
|
if (!this.baseFields.has(cacheKey)) {
|
|
@@ -730,24 +1031,38 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
730
1031
|
}
|
|
731
1032
|
}
|
|
732
1033
|
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1034
|
+
// The merged, time-resolved view over all readers. getColumn/getSingleField return plain resolved
|
|
1035
|
+
// values (not {value,time}); the base layers the overlay on top of these.
|
|
1036
|
+
type ResolvedReader = {
|
|
1037
|
+
rowCount: number;
|
|
1038
|
+
totalBytes: number;
|
|
1039
|
+
keys: string[];
|
|
1040
|
+
columns: { column: string; byteSize: number }[];
|
|
1041
|
+
getColumn: (column: string) => Promise<{ key: string; value: unknown }[]>;
|
|
1042
|
+
getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
// Resolve every read by ACTUAL write-time across all readers (stream + bulk), per key and per column:
|
|
1046
|
+
// - a column resolves to the value with the newest write-time among readers that set it (non-ABSENT);
|
|
1047
|
+
// a reader that never set the column for that key falls through to an older reader.
|
|
1048
|
+
// - a key is live iff its newest write is newer than its newest delete; per column, the value is
|
|
1049
|
+
// suppressed if a delete is newer than that column's newest set.
|
|
1050
|
+
// No reliance on file order or partitioning — time is the only thing that decides.
|
|
1051
|
+
async function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): Promise<ResolvedReader> {
|
|
1052
|
+
const deleteTime = new Map<string, number>();
|
|
738
1053
|
for (const db of databases) {
|
|
739
|
-
if (db.
|
|
1054
|
+
if (!db.deleteTimes) continue;
|
|
1055
|
+
for (const [key, t] of db.deleteTimes) deleteTime.set(key, Math.max(deleteTime.get(key) ?? -Infinity, t));
|
|
740
1056
|
}
|
|
741
|
-
|
|
742
|
-
const keys: string[] = [];
|
|
743
|
-
const keySeen = new Set<string>();
|
|
1057
|
+
const keyTime = new Map<string, number>();
|
|
744
1058
|
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
|
-
}
|
|
1059
|
+
for (const [key, t] of db.keyTimes) keyTime.set(key, Math.max(keyTime.get(key) ?? -Infinity, t));
|
|
750
1060
|
}
|
|
1061
|
+
const delOf = (key: string) => deleteTime.get(key) ?? -Infinity;
|
|
1062
|
+
// Live keys: newest write strictly newer than newest delete.
|
|
1063
|
+
const keys: string[] = [];
|
|
1064
|
+
for (const [key, t] of keyTime) if (t > delOf(key)) keys.push(key);
|
|
1065
|
+
|
|
751
1066
|
const columns: { column: string; byteSize: number }[] = [];
|
|
752
1067
|
const columnByName = new Map<string, { column: string; byteSize: number }>();
|
|
753
1068
|
for (const db of databases) {
|
|
@@ -761,37 +1076,41 @@ function joinBulkDatabases(databases: BaseBulkDatabaseReader[]): BaseBulkDatabas
|
|
|
761
1076
|
existing.byteSize += col.byteSize;
|
|
762
1077
|
}
|
|
763
1078
|
}
|
|
1079
|
+
|
|
764
1080
|
return {
|
|
765
1081
|
totalBytes: databases.reduce((acc, db) => acc + db.totalBytes, 0),
|
|
766
1082
|
rowCount: keys.length,
|
|
767
1083
|
keys,
|
|
768
1084
|
columns,
|
|
769
1085
|
async getColumn(column) {
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1086
|
+
const perReader = await Promise.all(databases.map(async db => {
|
|
1087
|
+
if (!db.columns.some(c => c.column === column)) return undefined;
|
|
1088
|
+
const entries = await db.getColumn(column);
|
|
1089
|
+
return new Map(entries.map(e => [e.key, { value: e.value, time: e.time }]));
|
|
1090
|
+
}));
|
|
1091
|
+
return keys.map(key => {
|
|
1092
|
+
let bestTime = -Infinity;
|
|
1093
|
+
let bestVal: unknown;
|
|
1094
|
+
let found = false;
|
|
1095
|
+
for (const m of perReader) {
|
|
1096
|
+
const cell = m && m.get(key);
|
|
1097
|
+
if (!cell || cell.value === ABSENT) continue;
|
|
1098
|
+
if (cell.time > bestTime) { bestTime = cell.time; bestVal = cell.value; found = true; }
|
|
777
1099
|
}
|
|
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;
|
|
1100
|
+
return { key, value: (found && bestTime > delOf(key)) ? bestVal : undefined };
|
|
1101
|
+
});
|
|
786
1102
|
},
|
|
787
1103
|
async getSingleField(key, column) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1104
|
+
let bestTime = -Infinity;
|
|
1105
|
+
let bestVal: unknown;
|
|
1106
|
+
let found = false;
|
|
1107
|
+
for (const db of databases) {
|
|
1108
|
+
if (!db.columns.some(c => c.column === column)) continue;
|
|
1109
|
+
const r = await db.getSingleField(key, column);
|
|
1110
|
+
if (r === ABSENT) continue;
|
|
1111
|
+
if (r.time > bestTime) { bestTime = r.time; bestVal = r.value; found = true; }
|
|
793
1112
|
}
|
|
794
|
-
return undefined;
|
|
1113
|
+
return (found && bestTime > delOf(key)) ? bestVal : undefined;
|
|
795
1114
|
},
|
|
796
1115
|
};
|
|
797
1116
|
}
|