sliftutils 1.7.32 → 1.7.34
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/builders/extensionBuild.ts +1 -1
- package/bundler/bundleEntry.ts +1 -3
- package/bundler/bundler.ts +2 -4
- package/bundler/sourceMaps.ts +1 -2
- package/examplestorage/browser.tsx +1 -2
- package/examplestorage/exampleserver.ts +1 -2
- package/index.d.ts +134 -102
- package/misc/dist/environment.ts.cache +2 -2
- package/misc/dist/getSecret.ts.cache +2 -2
- package/misc/dist/strings.ts.cache +2 -2
- package/misc/dist/zip.ts.cache +2 -2
- package/misc/https/dist/certs.ts.cache +2 -2
- package/misc/https/dist/dns.ts.cache +7 -16
- package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
- package/misc/https/dns.d.ts +1 -4
- package/misc/https/dns.ts +5 -14
- package/misc/https/hostServer.d.ts +2 -0
- package/misc/https/hostServer.ts +6 -7
- package/misc/https/httpsCerts.ts +4 -10
- package/misc/openrouter.ts +1 -2
- package/package.json +1 -1
- package/render-utils/FullscreenModal.tsx +1 -4
- package/render-utils/GenericFormat.tsx +2 -8
- package/render-utils/Input.tsx +5 -13
- package/render-utils/InputLabel.tsx +4 -6
- package/render-utils/InputPicker.tsx +1 -2
- package/render-utils/URLParam.ts +2 -3
- package/render-utils/dist/observer.tsx.cache +2 -2
- package/render-utils/mobxTyped.ts +2 -3
- package/render-utils/niceStringify.ts +2 -4
- package/storage/ArchivesDisk.ts +4 -14
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +1 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +4 -11
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +42 -114
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +31 -91
- package/storage/BulkDatabase2/BulkDatabaseMerge.ts +19 -57
- package/storage/BulkDatabase2/BulkDatabaseReader.ts +4 -12
- package/storage/BulkDatabase2/LoadedIndex.ts +6 -18
- package/storage/BulkDatabase2/WriteOverlay.ts +3 -8
- package/storage/BulkDatabase2/blockCache.ts +12 -28
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +40 -103
- package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +19 -50
- package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +18 -50
- package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +7 -15
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +9 -21
- package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +6 -11
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +15 -31
- package/storage/BulkDatabase2/dist/mergeLock.ts.cache +10 -29
- package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +10 -18
- package/storage/BulkDatabase2/dist/streamLog.ts.cache +17 -43
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +11 -27
- package/storage/BulkDatabase2/mergeLock.ts +7 -26
- package/storage/BulkDatabase2/mergeMarkers.ts +7 -15
- package/storage/BulkDatabase2/streamLog.ts +14 -40
- package/storage/BulkDatabase2/syncClient.ts +8 -24
- package/storage/FileFolderAPI.tsx +28 -75
- package/storage/IArchives.d.ts +3 -3
- package/storage/IArchives.ts +21 -50
- package/storage/IStorage.ts +2 -4
- package/storage/PendingManager.tsx +2 -3
- package/storage/TransactionStorage.ts +11 -24
- package/storage/backblaze.ts +12 -43
- package/storage/dist/ArchivesDisk.ts.cache +7 -17
- package/storage/dist/FileFolderAPI.tsx.cache +25 -63
- package/storage/dist/IArchives.ts.cache +10 -20
- package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
- package/storage/dist/JSONStorage.ts.cache +2 -2
- package/storage/dist/PendingManager.tsx.cache +5 -6
- package/storage/dist/TransactionStorage.ts.cache +14 -27
- package/storage/dist/backblaze.ts.cache +15 -46
- package/storage/dist/embeddingFormats.ts.cache +11 -24
- package/storage/dist/fileSystemPointer.ts.cache +4 -7
- package/storage/dist/remoteFileStorage.ts.cache +17 -36
- package/storage/embeddingFormats.ts +11 -27
- package/storage/fileSystemPointer.ts +2 -7
- package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +8 -18
- package/storage/proxydatabase/dist/transactionSet.ts.cache +4 -7
- package/storage/proxydatabase/inMemoryDatabase.ts +1 -3
- package/storage/proxydatabase/ivfEmbeddingDatabase.ts +8 -23
- package/storage/proxydatabase/transactionSet.ts +2 -5
- package/storage/remoteFileServer.ts +20 -48
- package/storage/remoteFileStorage.ts +14 -33
- package/storage/remoteStorage/ArchivesRemote.d.ts +1 -4
- package/storage/remoteStorage/ArchivesRemote.ts +11 -26
- package/storage/remoteStorage/ArchivesUrl.ts +2 -4
- package/storage/remoteStorage/accessPage.tsx +1 -5
- package/storage/remoteStorage/blobStore.d.ts +5 -13
- package/storage/remoteStorage/blobStore.ts +52 -130
- package/storage/remoteStorage/createArchives.d.ts +22 -14
- package/storage/remoteStorage/createArchives.ts +54 -188
- package/storage/remoteStorage/deployTakeover.d.ts +18 -32
- package/storage/remoteStorage/deployTakeover.ts +70 -384
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +12 -24
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +5 -7
- package/storage/remoteStorage/dist/blobStore.ts.cache +47 -117
- package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
- package/storage/remoteStorage/dist/createArchives.ts.cache +58 -189
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +81 -387
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +119 -0
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +36 -24
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +20 -40
- package/storage/remoteStorage/dist/storageClientController.ts.cache +4 -6
- package/storage/remoteStorage/dist/storageController.ts.cache +37 -74
- package/storage/remoteStorage/dist/storageServerState.ts.cache +345 -188
- package/storage/remoteStorage/grantAccessCli.ts +1 -3
- package/storage/remoteStorage/intermediateSources.d.ts +21 -0
- package/storage/remoteStorage/intermediateSources.ts +117 -0
- package/storage/remoteStorage/remoteConfig.ts +33 -20
- package/storage/remoteStorage/sourceWrapper.d.ts +8 -19
- package/storage/remoteStorage/sourceWrapper.ts +19 -41
- package/storage/remoteStorage/storageClientController.ts +1 -3
- package/storage/remoteStorage/storageController.d.ts +7 -6
- package/storage/remoteStorage/storageController.ts +36 -82
- package/storage/remoteStorage/storageServer.ts +20 -34
- package/storage/remoteStorage/storageServerState.d.ts +41 -5
- package/storage/remoteStorage/storageServerState.ts +355 -216
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// File format (all integers little-endian):
|
|
2
|
-
// u32 headerLength, then headerLength bytes of JSON: { rowCount, columns: [{ name, offset, length }] }, where offset is relative to the end of the header.
|
|
3
|
-
// Each column blob is: u32 offsets[rowCount + 1] (byte offsets into the column's data section), u8 types[rowCount], then the data section (each value's bytes, concatenated).
|
|
4
|
-
// Values are encoded with an explicit type tag per value (see the TYPE_ constants). Reads only fetch the byte ranges they need.
|
|
2
|
+
// u32 headerLength, then headerLength bytes of JSON: { rowCount, columns: [{ name, offset, length }] }, where offset is relative to the end of the header. Each column blob is: u32 offsets[rowCount + 1] (byte offsets into the column's data section), u8 types[rowCount], then the data section (each value's bytes, concatenated). Values are encoded with an explicit type tag per value (see the TYPE_ constants). Reads only fetch the byte ranges they need.
|
|
5
3
|
|
|
6
4
|
export const KEY_COLUMN = "key";
|
|
7
5
|
|
|
@@ -20,9 +18,7 @@ const TYPE_INT32_ARRAY = 10;
|
|
|
20
18
|
const TYPE_UINT32_ARRAY = 11;
|
|
21
19
|
const TYPE_FLOAT32_ARRAY = 12;
|
|
22
20
|
const TYPE_FLOAT64_ARRAY = 13;
|
|
23
|
-
// A cell whose row never set this column at all — as opposed to TYPE_UNDEFINED, an explicitly stored
|
|
24
|
-
// undefined. On read, ABSENT falls through to older readers for that column; a stored undefined stops
|
|
25
|
-
// the fall-through (it's a real value that clears the column).
|
|
21
|
+
// A cell whose row never set this column at all — as opposed to TYPE_UNDEFINED, an explicitly stored undefined. On read, ABSENT falls through to older readers for that column; a stored undefined stops the fall-through (it's a real value that clears the column).
|
|
26
22
|
const TYPE_ABSENT = 14;
|
|
27
23
|
|
|
28
24
|
const TYPED_ARRAY_TYPES: { type: number; ctor: { new(buffer: ArrayBuffer): ArrayBufferView; BYTES_PER_ELEMENT: number; name: string } }[] = [
|
|
@@ -39,54 +35,35 @@ const TYPED_ARRAY_TYPES: { type: number; ctor: { new(buffer: ArrayBuffer): Array
|
|
|
39
35
|
|
|
40
36
|
export const EMPTY_BUFFER = Buffer.alloc(0) as Buffer;
|
|
41
37
|
|
|
42
|
-
// Sentinel a reader returns for a cell whose row never set this column, so the join can fall through
|
|
43
|
-
// to an older reader for that column. Distinct from a stored undefined, which is a real clearing value.
|
|
38
|
+
// Sentinel a reader returns for a cell whose row never set this column, so the join can fall through to an older reader for that column. Distinct from a stored undefined, which is a real clearing value.
|
|
44
39
|
export const ABSENT = Symbol("absent");
|
|
45
40
|
|
|
46
|
-
// One value exactly as it sits on disk: its type tag + raw bytes. A cell's encoding is position-
|
|
47
|
-
// independent (nothing about it depends on which row/file it lives in), so a merge copies the winning
|
|
48
|
-
// RawCell straight from an input column into the output — never decoding it to a JS value and
|
|
49
|
-
// re-encoding. That's far less memory (no object materialization) and much faster (a byte copy, not a
|
|
50
|
-
// JPEG/typed-array decode + re-encode).
|
|
41
|
+
// One value exactly as it sits on disk: its type tag + raw bytes. A cell's encoding is position- independent (nothing about it depends on which row/file it lives in), so a merge copies the winning RawCell straight from an input column into the output — never decoding it to a JS value and re-encoding. That's far less memory (no object materialization) and much faster (a byte copy, not a JPEG/typed-array decode + re-encode).
|
|
51
42
|
export type RawCell = { type: number; bytes: Buffer };
|
|
52
43
|
|
|
53
|
-
// The type tag a cell carries when its row never set this column — read it from the column's `types`
|
|
54
|
-
// array to detect ABSENT without decoding. Re-exported so the merge planner (which works at the
|
|
55
|
-
// column-index level, no value materialization) can check it.
|
|
44
|
+
// The type tag a cell carries when its row never set this column — read it from the column's `types` array to detect ABSENT without decoding. Re-exported so the merge planner (which works at the column-index level, no value materialization) can check it.
|
|
56
45
|
export const TYPE_ABSENT_TAG = TYPE_ABSENT;
|
|
57
46
|
|
|
58
|
-
// A column's index alone (offsets + types — small, ~5 bytes/row) plus a primitive to read a CONTIGUOUS
|
|
59
|
-
// row range's raw value bytes. Used by the planned merge: it loads the index across every (source,
|
|
60
|
-
// column) once (cheap), uses types to detect ABSENT and offsets to size each cell, plans the byte
|
|
61
|
-
// layout of every output file from those — then in the execute phase reads only the runs of bytes it
|
|
62
|
-
// actually needs, copying them straight into pre-laid-out output buffers. No cell value is ever
|
|
63
|
-
// materialized as a JS object.
|
|
47
|
+
// A column's index alone (offsets + types — small, ~5 bytes/row) plus a primitive to read a CONTIGUOUS row range's raw value bytes. Used by the planned merge: it loads the index across every (source, column) once (cheap), uses types to detect ABSENT and offsets to size each cell, plans the byte layout of every output file from those — then in the execute phase reads only the runs of bytes it actually needs, copying them straight into pre-laid-out output buffers. No cell value is ever materialized as a JS object.
|
|
64
48
|
export type ColumnIndex = {
|
|
65
49
|
// offsets[i]..offsets[i+1] is row i's value byte range within the column's data section.
|
|
66
50
|
offsets: Uint32Array;
|
|
67
51
|
// Per-row type tag; TYPE_ABSENT_TAG marks "this row never set this column" (fall-through).
|
|
68
52
|
types: Uint8Array;
|
|
69
|
-
// Read the contiguous bytes of value(s) for rows [startRow, endRow). Length is exactly
|
|
70
|
-
// offsets[endRow] - offsets[startRow] — the cells in that range concatenated. The caller composes
|
|
71
|
-
// larger reads from consecutive rows (one source-side getRange per run).
|
|
53
|
+
// Read the contiguous bytes of value(s) for rows [startRow, endRow). Length is exactly offsets[endRow] - offsets[startRow] — the cells in that range concatenated. The caller composes larger reads from consecutive rows (one source-side getRange per run).
|
|
72
54
|
readValueBytes: (startRow: number, endRow: number) => Promise<Buffer>;
|
|
73
55
|
};
|
|
74
56
|
|
|
75
|
-
// Hidden per-row column holding each row's write-time (so reads can resolve a key to its latest value
|
|
76
|
-
// by actual time). NUL-prefixed so it can't collide with a user column; excluded from `columns`.
|
|
57
|
+
// Hidden per-row column holding each row's write-time (so reads can resolve a key to its latest value by actual time). NUL-prefixed so it can't collide with a user column; excluded from `columns`.
|
|
77
58
|
const TIME_COLUMN = String.fromCharCode(0) + "t";
|
|
78
59
|
|
|
79
60
|
type FileHeader = {
|
|
80
61
|
rowCount: number;
|
|
81
62
|
columns: { name: string; offset: number; length: number }[];
|
|
82
|
-
// Oldest/newest write-time of the data in this file (from the stream entries it was folded from,
|
|
83
|
-
// carried through merges). Lets the merge planner pick files overlapping a time range. Absent (0)
|
|
84
|
-
// in files written before this existed.
|
|
63
|
+
// Oldest/newest write-time of the data in this file (from the stream entries it was folded from, carried through merges). Lets the merge planner pick files overlapping a time range. Absent (0) in files written before this existed.
|
|
85
64
|
minTime?: number;
|
|
86
65
|
maxTime?: number;
|
|
87
|
-
// Lexicographically smallest/largest key in this file (rows are stored key-sorted). Lets the merge
|
|
88
|
-
// planner group/dedup by key range and lets a single-key read skip files whose range excludes the
|
|
89
|
-
// key. Absent (undefined) in files written before this existed — treated as "spans all keys".
|
|
66
|
+
// Lexicographically smallest/largest key in this file (rows are stored key-sorted). Lets the merge planner group/dedup by key range and lets a single-key read skip files whose range excludes the key. Absent (undefined) in files written before this existed — treated as "spans all keys".
|
|
90
67
|
minKey?: string;
|
|
91
68
|
maxKey?: string;
|
|
92
69
|
};
|
|
@@ -177,9 +154,7 @@ function encodeBulkData(data: unknown[]): Buffer {
|
|
|
177
154
|
return Buffer.concat([offsets, types, ...parts]);
|
|
178
155
|
}
|
|
179
156
|
|
|
180
|
-
// Like encodeBulkData but the values are already encoded (a merge supplies the winning cells' raw bytes
|
|
181
|
-
// straight from the input files). Lays out the same offsets / types / data section without touching the
|
|
182
|
-
// values themselves.
|
|
157
|
+
// Like encodeBulkData but the values are already encoded (a merge supplies the winning cells' raw bytes straight from the input files). Lays out the same offsets / types / data section without touching the values themselves.
|
|
183
158
|
function encodeBulkDataRaw(cells: RawCell[]): Buffer {
|
|
184
159
|
const n = cells.length;
|
|
185
160
|
const offsets = Buffer.alloc(4 * (n + 1));
|
|
@@ -209,11 +184,7 @@ function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
|
|
|
209
184
|
return values;
|
|
210
185
|
}
|
|
211
186
|
|
|
212
|
-
// Target logical (uncompressed) size of one bulk file. A rows array bigger than this is split across
|
|
213
|
-
// multiple key-contiguous files, so files stay around this size (the merge policy's target) and no
|
|
214
|
-
// single column blob / Buffer.concat ever approaches the ~2GB Buffer length limit. The estimate is
|
|
215
|
-
// rough on purpose — it only needs to keep each chunk near the target, not be exact. A single row
|
|
216
|
-
// bigger than the target still becomes its own (oversized) file, since we never split within a key.
|
|
187
|
+
// Target logical (uncompressed) size of one bulk file. A rows array bigger than this is split across multiple key-contiguous files, so files stay around this size (the merge policy's target) and no single column blob / Buffer.concat ever approaches the ~2GB Buffer length limit. The estimate is rough on purpose — it only needs to keep each chunk near the target, not be exact. A single row bigger than the target still becomes its own (oversized) file, since we never split within a key.
|
|
217
188
|
export const TARGET_FILE_BYTES = 256 * 1024 * 1024;
|
|
218
189
|
|
|
219
190
|
// Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
|
|
@@ -237,9 +208,7 @@ function estimateRowBytes(row: Record<string, unknown>): number {
|
|
|
237
208
|
return total;
|
|
238
209
|
}
|
|
239
210
|
|
|
240
|
-
// Concatenates already-encoded column blobs into a complete file (4-byte header length + header JSON +
|
|
241
|
-
// blobs), computing the header's time + key bounds from the per-row times and keys. Shared by the
|
|
242
|
-
// object-based builder (buildOneFile) and the raw-splice builder (buildOneFileRaw).
|
|
211
|
+
// Concatenates already-encoded column blobs into a complete file (4-byte header length + header JSON + blobs), computing the header's time + key bounds from the per-row times and keys. Shared by the object-based builder (buildOneFile) and the raw-splice builder (buildOneFileRaw).
|
|
243
212
|
function assembleFile(columnNames: string[], blobs: Buffer[], rowCount: number, times: number[], keys: string[]): Buffer {
|
|
244
213
|
let offset = 0;
|
|
245
214
|
const columns = columnNames.map((name, i) => {
|
|
@@ -281,21 +250,15 @@ function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer
|
|
|
281
250
|
return assembleFile(columnNames, blobs, rows.length, times, rows.map(r => r[KEY_COLUMN] as string));
|
|
282
251
|
}
|
|
283
252
|
|
|
284
|
-
// A resolved output row for the raw-splice merge: its key, write-time, and the winning raw cell for each
|
|
285
|
-
// column it has (a column it lacks is written ABSENT — fall-through). The cell bytes are copied straight
|
|
286
|
-
// from the input files; no value is ever decoded.
|
|
253
|
+
// A resolved output row for the raw-splice merge: its key, write-time, and the winning raw cell for each column it has (a column it lacks is written ABSENT — fall-through). The cell bytes are copied straight from the input files; no value is ever decoded.
|
|
287
254
|
export type RawRow = { key: string; time: number; cells: Map<string, RawCell> };
|
|
288
255
|
|
|
289
|
-
// Size of a column blob's INDEX section (offsets array + types array) for a column of N rows. The data
|
|
290
|
-
// section follows immediately after. The planned merge uses this to size output buffers exactly.
|
|
256
|
+
// Size of a column blob's INDEX section (offsets array + types array) for a column of N rows. The data section follows immediately after. The planned merge uses this to size output buffers exactly.
|
|
291
257
|
export function columnIndexByteLength(rowCount: number): number {
|
|
292
258
|
return 4 * (rowCount + 1) + rowCount;
|
|
293
259
|
}
|
|
294
260
|
|
|
295
|
-
// Assembles a complete bulk file from a set of pre-built value column blobs plus the keys + times. Auto-
|
|
296
|
-
// adds the KEY_COLUMN (keys) and hidden TIME_COLUMN (times) — they're small enough to encode in one shot
|
|
297
|
-
// here. Used by the planned merge: it builds each value column's blob by-hand (offsets/types + raw bytes
|
|
298
|
-
// copied directly from inputs, no value materialization) and hands the result here for header + bounds.
|
|
261
|
+
// Assembles a complete bulk file from a set of pre-built value column blobs plus the keys + times. Auto- adds the KEY_COLUMN (keys) and hidden TIME_COLUMN (times) — they're small enough to encode in one shot here. Used by the planned merge: it builds each value column's blob by-hand (offsets/types + raw bytes copied directly from inputs, no value materialization) and hands the result here for header + bounds.
|
|
299
262
|
export function assemblePlannedFile(config: {
|
|
300
263
|
valueColumns: { name: string; blob: Buffer }[];
|
|
301
264
|
keys: string[];
|
|
@@ -313,8 +276,7 @@ export function assemblePlannedFile(config: {
|
|
|
313
276
|
const ABSENT_CELL: RawCell = { type: TYPE_ABSENT, bytes: EMPTY_BUFFER };
|
|
314
277
|
|
|
315
278
|
function buildOneFileRaw(rows: RawRow[]): Buffer {
|
|
316
|
-
// Columns present in this chunk, in first-seen order (matches buildOneFile, which only emits columns
|
|
317
|
-
// some row actually has).
|
|
279
|
+
// Columns present in this chunk, in first-seen order (matches buildOneFile, which only emits columns some row actually has).
|
|
318
280
|
const valueColumns: string[] = [];
|
|
319
281
|
const seen = new Set<string>();
|
|
320
282
|
for (const row of rows) for (const col of row.cells.keys()) {
|
|
@@ -338,16 +300,10 @@ function estimateRawRowBytes(row: RawRow): number {
|
|
|
338
300
|
return total;
|
|
339
301
|
}
|
|
340
302
|
|
|
341
|
-
// One complete, independent file: the encoded buffer plus its key range + row count (the caller logs the
|
|
342
|
-
// range when a merge splits across several files).
|
|
303
|
+
// One complete, independent file: the encoded buffer plus its key range + row count (the caller logs the range when a merge splits across several files).
|
|
343
304
|
export interface BuiltFile { buffer: Buffer; minKey: string; maxKey: string; rowCount: number; }
|
|
344
305
|
|
|
345
|
-
// Returns one complete, independent file per chunk of rows. Rows are first sorted by key, then
|
|
346
|
-
// partitioned into key-contiguous chunks of ~targetBytes each — so every returned file is key-sorted
|
|
347
|
-
// (tight minKey/maxKey for the read-skip + merge planner) and stays near the target size, and no
|
|
348
|
-
// single column blob / Buffer.concat approaches the ~2GB limit. The chunks have disjoint key ranges,
|
|
349
|
-
// so the caller just writes each as its own file. A normal-sized write returns a single file.
|
|
350
|
-
// `times[i]` is row i's write-time, stored per row so reads resolve a key to its latest value by time.
|
|
306
|
+
// Returns one complete, independent file per chunk of rows. Rows are first sorted by key, then partitioned into key-contiguous chunks of ~targetBytes each — so every returned file is key-sorted (tight minKey/maxKey for the read-skip + merge planner) and stays near the target size, and no single column blob / Buffer.concat approaches the ~2GB limit. The chunks have disjoint key ranges, so the caller just writes each as its own file. A normal-sized write returns a single file. `times[i]` is row i's write-time, stored per row so reads resolve a key to its latest value by time.
|
|
351
307
|
export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): BuiltFile[] {
|
|
352
308
|
// A chunk is already key-sorted, so its first/last row are its min/max key.
|
|
353
309
|
const make = (rs: Record<string, unknown>[], ts: number[]): BuiltFile => ({
|
|
@@ -381,9 +337,7 @@ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[]
|
|
|
381
337
|
return result;
|
|
382
338
|
}
|
|
383
339
|
|
|
384
|
-
// The raw-splice counterpart of buildFileBuffer, used by merges: the rows already carry their winning
|
|
385
|
-
// cells as raw on-disk bytes (no JS values), so this just sorts by key, chunks to ~targetBytes, and
|
|
386
|
-
// concatenates the bytes. Same output guarantees: key-contiguous, disjoint, ascending files.
|
|
340
|
+
// The raw-splice counterpart of buildFileBuffer, used by merges: the rows already carry their winning cells as raw on-disk bytes (no JS values), so this just sorts by key, chunks to ~targetBytes, and concatenates the bytes. Same output guarantees: key-contiguous, disjoint, ascending files.
|
|
387
341
|
export function buildFileBufferRaw(rows: RawRow[], targetBytes = TARGET_FILE_BYTES): BuiltFile[] {
|
|
388
342
|
const make = (rs: RawRow[]): BuiltFile => ({
|
|
389
343
|
buffer: buildOneFileRaw(rs),
|
|
@@ -410,8 +364,7 @@ export function buildFileBufferRaw(rows: RawRow[], targetBytes = TARGET_FILE_BYT
|
|
|
410
364
|
}
|
|
411
365
|
|
|
412
366
|
export type BaseBulkDatabaseReader = {
|
|
413
|
-
// Identifies the source this reader came from (the bulk file name, or "(streams)") so the join can
|
|
414
|
-
// name the offending file when one of its reads fails. Undefined for readers built without one.
|
|
367
|
+
// Identifies the source this reader came from (the bulk file name, or "(streams)") so the join can name the offending file when one of its reads fails. Undefined for readers built without one.
|
|
415
368
|
name?: string;
|
|
416
369
|
rowCount: number;
|
|
417
370
|
totalBytes: number;
|
|
@@ -424,33 +377,23 @@ export type BaseBulkDatabaseReader = {
|
|
|
424
377
|
// Keys is special, it's always automatically decoded, even though it is stored as a normal column
|
|
425
378
|
keys: string[];
|
|
426
379
|
columns: { column: string; byteSize: number }[];
|
|
427
|
-
// Each key's row write-time (the time of its newest write in this reader). The join compares these
|
|
428
|
-
// across readers to resolve a key to its latest value.
|
|
380
|
+
// Each key's row write-time (the time of its newest write in this reader). The join compares these across readers to resolve a key to its latest value.
|
|
429
381
|
keyTimes: Map<string, number>;
|
|
430
|
-
// Per-key tombstone time: the key was deleted at this time. The join treats a delete like any other
|
|
431
|
-
// event — a delete only wins if it's newer than every set for the key. Only the stream reader sets it.
|
|
382
|
+
// Per-key tombstone time: the key was deleted at this time. The join treats a delete like any other event — a delete only wins if it's newer than every set for the key. Only the stream reader sets it.
|
|
432
383
|
deleteTimes?: Map<string, number>;
|
|
433
|
-
// Each key's value for the column plus the row's write-time. value may be ABSENT (the row never set
|
|
434
|
-
// this column — the join then falls through to an older reader for that key/column).
|
|
384
|
+
// Each key's value for the column plus the row's write-time. value may be ABSENT (the row never set this column — the join then falls through to an older reader for that key/column).
|
|
435
385
|
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
436
|
-
// Like getColumn but returns each cell's raw on-disk encoding (type tag + bytes) keyed by key, WITHOUT
|
|
437
|
-
// decoding the value — for merges, which splice the winning bytes straight into the output. ABSENT
|
|
438
|
-
// cells are omitted (a missing key means this reader didn't set this column, so the merge falls
|
|
439
|
-
// through to an older reader). Each cell's write-time is the reader's keyTimes value for that key.
|
|
386
|
+
// Like getColumn but returns each cell's raw on-disk encoding (type tag + bytes) keyed by key, WITHOUT decoding the value — for merges, which splice the winning bytes straight into the output. ABSENT cells are omitted (a missing key means this reader didn't set this column, so the merge falls through to an older reader). Each cell's write-time is the reader's keyTimes value for that key.
|
|
440
387
|
getRawColumn: (column: string) => Promise<Map<string, RawCell>>;
|
|
441
|
-
// Returns the column's INDEX (offsets + types) plus a contiguous row-range byte reader. The planned
|
|
442
|
-
// merge loads this for every (source, column) once — small (~5B/row), no value bytes pulled — and
|
|
443
|
-
// uses types/offsets to plan the output's byte layout. Execute then reads only the needed runs.
|
|
388
|
+
// Returns the column's INDEX (offsets + types) plus a contiguous row-range byte reader. The planned merge loads this for every (source, column) once — small (~5B/row), no value bytes pulled — and uses types/offsets to plan the output's byte layout. Execute then reads only the needed runs.
|
|
444
389
|
getColumnIndex: (column: string) => Promise<ColumnIndex>;
|
|
445
|
-
// Maps a key to its row index in this reader (and undefined if absent). The planned merge uses this
|
|
446
|
-
// to look up a winning cell's source row without going through a column read.
|
|
390
|
+
// Maps a key to its row index in this reader (and undefined if absent). The planned merge uses this to look up a winning cell's source row without going through a column read.
|
|
447
391
|
rowOfKey: (key: string) => number | undefined;
|
|
448
392
|
// The value + write-time for (key, column), or ABSENT if this reader has no such cell.
|
|
449
393
|
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | typeof ABSENT>;
|
|
450
394
|
};
|
|
451
395
|
|
|
452
|
-
// Reads just the file header (the 4-byte length + header JSON) — no column data. Used by the merge
|
|
453
|
-
// planner to get each file's row count, time range, and key range cheaply across many files.
|
|
396
|
+
// Reads just the file header (the 4-byte length + header JSON) — no column data. Used by the merge planner to get each file's row count, time range, and key range cheaply across many files.
|
|
454
397
|
export type BulkHeaderInfo = { rowCount: number; minTime: number; maxTime: number; minKey?: string; maxKey?: string; columns: { column: string; byteSize: number }[] };
|
|
455
398
|
export async function loadBulkHeader(getRange: (start: number, end: number) => Promise<Buffer>, totalBytes: number): Promise<BulkHeaderInfo> {
|
|
456
399
|
const headerLength = (await getRange(0, 4)).readUInt32LE(0);
|
|
@@ -499,8 +442,7 @@ export async function loadBulkDatabase(config: {
|
|
|
499
442
|
});
|
|
500
443
|
const keyIndex = new Map(keys.map((key, i) => [key, i]));
|
|
501
444
|
|
|
502
|
-
// Per-row write-times. Old files (written before this column existed) fall back to the file's header
|
|
503
|
-
// time (or 0) for every row — fine, since such files predate concurrent-time resolution.
|
|
445
|
+
// Per-row write-times. Old files (written before this column existed) fall back to the file's header time (or 0) for every row — fine, since such files predate concurrent-time resolution.
|
|
504
446
|
const times: number[] = colByName.has(TIME_COLUMN)
|
|
505
447
|
? (await readWholeColumn(TIME_COLUMN)).map(v => typeof v === "number" ? v : 0)
|
|
506
448
|
: keys.map(() => header.maxTime || 0);
|
|
@@ -538,8 +480,7 @@ export async function loadBulkDatabase(config: {
|
|
|
538
480
|
async getColumnIndex(column) {
|
|
539
481
|
const col = colByName.get(column);
|
|
540
482
|
if (!col) {
|
|
541
|
-
// File lacks this column — present an all-ABSENT index so the planner can treat it
|
|
542
|
-
// uniformly with files that have the column.
|
|
483
|
+
// File lacks this column — present an all-ABSENT index so the planner can treat it uniformly with files that have the column.
|
|
543
484
|
const offsets = new Uint32Array(rowCount + 1);
|
|
544
485
|
const types = new Uint8Array(rowCount).fill(TYPE_ABSENT);
|
|
545
486
|
return {
|
|
@@ -550,8 +491,7 @@ export async function loadBulkDatabase(config: {
|
|
|
550
491
|
}
|
|
551
492
|
const colBase = dataBase + col.offset;
|
|
552
493
|
const indexSize = 4 * (rowCount + 1) + rowCount;
|
|
553
|
-
// One read pulls offsets + types (small — ~5 B/row). Block cache makes subsequent value reads
|
|
554
|
-
// of nearby rows cheap. Decoded into aligned typed arrays so the executor can do O(1) lookups.
|
|
494
|
+
// One read pulls offsets + types (small — ~5 B/row). Block cache makes subsequent value reads of nearby rows cheap. Decoded into aligned typed arrays so the executor can do O(1) lookups.
|
|
555
495
|
const indexBuf = await config.getRange(colBase, colBase + indexSize);
|
|
556
496
|
const offsets = new Uint32Array(rowCount + 1);
|
|
557
497
|
for (let i = 0; i <= rowCount; i++) offsets[i] = indexBuf.readUInt32LE(4 * i);
|
|
@@ -10,13 +10,10 @@ import {
|
|
|
10
10
|
TYPE_ABSENT_TAG,
|
|
11
11
|
} from "./BulkDatabaseFormat";
|
|
12
12
|
|
|
13
|
-
// Default total in-flight buffer budget for the executor: how much output value-data we hold in memory at
|
|
14
|
-
// once. Multiple output files (≤ TARGET_FILE_BYTES each) are built together inside this budget so reading
|
|
15
|
-
// inputs once amortizes their I/O across several outputs. Tighter budget → more passes, less memory.
|
|
13
|
+
// Default total in-flight buffer budget for the executor: how much output value-data we hold in memory at once. Multiple output files (≤ TARGET_FILE_BYTES each) are built together inside this budget so reading inputs once amortizes their I/O across several outputs. Tighter budget → more passes, less memory.
|
|
16
14
|
const DEFAULT_OUTPUT_BATCH_BYTES = 2 * 1024 * 1024 * 1024;
|
|
17
15
|
|
|
18
|
-
// Per-value on-disk overhead (4-byte offset entry + 1-byte type tag). Used for the partition-cut size
|
|
19
|
-
// estimate; the executor doesn't use it (offsets/types are sized exactly from the plan).
|
|
16
|
+
// Per-value on-disk overhead (4-byte offset entry + 1-byte type tag). Used for the partition-cut size estimate; the executor doesn't use it (offsets/types are sized exactly from the plan).
|
|
20
17
|
const PER_VALUE_OVERHEAD = 5;
|
|
21
18
|
|
|
22
19
|
function fmtBytes(n: number): string {
|
|
@@ -26,9 +23,7 @@ function fmtBytes(n: number): string {
|
|
|
26
23
|
return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
// A contiguous copy operation: take `sourceEndRow - sourceStartRow` consecutive rows of one source
|
|
30
|
-
// column, splice the resulting bytes into the output column's data section at outputByteStart. Adjacent
|
|
31
|
-
// per-row decisions get RLE'd into one copy op — one source-side read + memcpy instead of one per row.
|
|
26
|
+
// A contiguous copy operation: take `sourceEndRow - sourceStartRow` consecutive rows of one source column, splice the resulting bytes into the output column's data section at outputByteStart. Adjacent per-row decisions get RLE'd into one copy op — one source-side read + memcpy instead of one per row.
|
|
32
27
|
type CopyRun = {
|
|
33
28
|
sourceIdx: number;
|
|
34
29
|
sourceStartRow: number;
|
|
@@ -39,8 +34,7 @@ type CopyRun = {
|
|
|
39
34
|
|
|
40
35
|
type PlannedOutputColumn = {
|
|
41
36
|
name: string;
|
|
42
|
-
// offsets[i]..offsets[i+1] = row i's value byte range in the column's data section. Populated by the
|
|
43
|
-
// planner; the executor writes it verbatim into the output column blob.
|
|
37
|
+
// offsets[i]..offsets[i+1] = row i's value byte range in the column's data section. Populated by the planner; the executor writes it verbatim into the output column blob.
|
|
44
38
|
offsets: Uint32Array;
|
|
45
39
|
// Per-row type tag (TYPE_ABSENT for fall-through). Populated by the planner.
|
|
46
40
|
types: Uint8Array;
|
|
@@ -71,17 +65,7 @@ export type PlannedMergeOutput = {
|
|
|
71
65
|
sources: Map<string, number>;
|
|
72
66
|
};
|
|
73
67
|
|
|
74
|
-
// Plan a merge over `sources` and execute it, writing one or more output files via the caller's
|
|
75
|
-
// `writeFile`. Two phases:
|
|
76
|
-
// • Planning: load only per-column INDEXES (offsets + types — ~5 B/row, small even on 20GB
|
|
77
|
-
// collections), determine the winning cell per (live key, column) by newest write-time + non-ABSENT
|
|
78
|
-
// fall-through, sort keys, partition into ~targetFileBytes output files, and pre-compute each output
|
|
79
|
-
// column's offsets/types arrays plus a run-length-encoded copy plan (no value data touched yet).
|
|
80
|
-
// • Execute: group outputs into ~targetBatchBytes batches; per batch, allocate column blobs with
|
|
81
|
-
// offsets/types already filled in, then iterate input sources copying contiguous byte runs straight
|
|
82
|
-
// into the output buffers. A source with no contribution to this batch is skipped entirely.
|
|
83
|
-
// Returns the new output file descriptors plus any tombstones whose newest event is still a delete
|
|
84
|
-
// (carried forward when older files outside the merge could still hold a now-stale set for that key).
|
|
68
|
+
// Plan a merge over `sources` and execute it, writing one or more output files via the caller's `writeFile`. Two phases: • Planning: load only per-column INDEXES (offsets + types — ~5 B/row, small even on 20GB collections), determine the winning cell per (live key, column) by newest write-time + non-ABSENT fall-through, sort keys, partition into ~targetFileBytes output files, and pre-compute each output column's offsets/types arrays plus a run-length-encoded copy plan (no value data touched yet). • Execute: group outputs into ~targetBatchBytes batches; per batch, allocate column blobs with offsets/types already filled in, then iterate input sources copying contiguous byte runs straight into the output buffers. A source with no contribution to this batch is skipped entirely. Returns the new output file descriptors plus any tombstones whose newest event is still a delete (carried forward when older files outside the merge could still hold a now-stale set for that key).
|
|
85
69
|
export async function runPlannedMerge(config: {
|
|
86
70
|
sources: BaseBulkDatabaseReader[];
|
|
87
71
|
sourceNames: string[];
|
|
@@ -97,11 +81,7 @@ export async function runPlannedMerge(config: {
|
|
|
97
81
|
const targetBatchBytes = config.targetBatchBytes ?? DEFAULT_OUTPUT_BATCH_BYTES;
|
|
98
82
|
const log = config.log ?? (line => console.log(`${blue(config.collectionName)} ${line}`));
|
|
99
83
|
|
|
100
|
-
// ─────────────────────────────────────────── Phase 1: plan ───────────────────────────────────────────
|
|
101
|
-
// Load per-source column indexes FIRST so a file that's gone (deleted between caller's listFiles and
|
|
102
|
-
// now) gets caught here and excluded from the merge entirely. Each source's load is wrapped in a
|
|
103
|
-
// try/catch; on failure we mark the source bad, log a "skipped" line, and the rest of planning +
|
|
104
|
-
// execute pretends the source doesn't exist. The caller only deletes inputs that were actually used.
|
|
84
|
+
// ─────────────────────────────────────────── Phase 1: plan ─────────────────────────────────────────── Load per-source column indexes FIRST so a file that's gone (deleted between caller's listFiles and now) gets caught here and excluded from the merge entirely. Each source's load is wrapped in a try/catch; on failure we mark the source bad, log a "skipped" line, and the rest of planning + execute pretends the source doesn't exist. The caller only deletes inputs that were actually used.
|
|
105
85
|
const sourceOk = new Array<boolean>(config.sources.length).fill(true);
|
|
106
86
|
const indexesPerSource: Map<string, ColumnIndex>[] = await Promise.all(config.sources.map(async (src, si) => {
|
|
107
87
|
const map = new Map<string, ColumnIndex>();
|
|
@@ -112,8 +92,7 @@ export async function runPlannedMerge(config: {
|
|
|
112
92
|
} catch (e) {
|
|
113
93
|
sourceOk[si] = false;
|
|
114
94
|
log(`${magenta("skipped")} ${config.sourceNames[si]}: ${(e as Error).message}`);
|
|
115
|
-
// Stack goes straight to console (not the buffered step log) so the merge summary stays
|
|
116
|
-
// readable but we can still trace where the failure came from.
|
|
95
|
+
// Stack goes straight to console (not the buffered step log) so the merge summary stays readable but we can still trace where the failure came from.
|
|
117
96
|
console.log(`${config.collectionName} skipped ${config.sourceNames[si]}:`, (e as Error).stack || e);
|
|
118
97
|
}
|
|
119
98
|
return map;
|
|
@@ -123,9 +102,7 @@ export async function runPlannedMerge(config: {
|
|
|
123
102
|
if (sourceOk[si]) usedSourceNames.add(config.sourceNames[si]);
|
|
124
103
|
}
|
|
125
104
|
|
|
126
|
-
// Aggregate keyTimes + deleteTimes across GOOD sources only (max per key). A bad source's cached
|
|
127
|
-
// metadata might claim a key as newest, but we can't actually read its cells — letting it win the
|
|
128
|
-
// time resolution would just drop that key out of the output.
|
|
105
|
+
// Aggregate keyTimes + deleteTimes across GOOD sources only (max per key). A bad source's cached metadata might claim a key as newest, but we can't actually read its cells — letting it win the time resolution would just drop that key out of the output.
|
|
129
106
|
const deleteTime = new Map<string, number>();
|
|
130
107
|
const keyTime = new Map<string, number>();
|
|
131
108
|
for (let si = 0; si < config.sources.length; si++) {
|
|
@@ -143,8 +120,7 @@ export async function runPlannedMerge(config: {
|
|
|
143
120
|
}
|
|
144
121
|
}
|
|
145
122
|
|
|
146
|
-
// Live keys (newest set strictly newer than newest delete) + tombstones to carry forward (newest
|
|
147
|
-
// event is a delete and the merge doesn't include the oldest data — that's the caller's call).
|
|
123
|
+
// Live keys (newest set strictly newer than newest delete) + tombstones to carry forward (newest event is a delete and the merge doesn't include the oldest data — that's the caller's call).
|
|
148
124
|
const liveKeys: string[] = [];
|
|
149
125
|
for (const [k, t] of keyTime) {
|
|
150
126
|
const dT = deleteTime.get(k) ?? -Infinity;
|
|
@@ -156,8 +132,7 @@ export async function runPlannedMerge(config: {
|
|
|
156
132
|
if (dT >= sT) carriedDeletes.set(k, dT);
|
|
157
133
|
}
|
|
158
134
|
|
|
159
|
-
// All distinct value columns from GOOD sources (KEY_COLUMN excluded — it's added at file-assembly
|
|
160
|
-
// time). Order: first-seen, matching the existing builders.
|
|
135
|
+
// All distinct value columns from GOOD sources (KEY_COLUMN excluded — it's added at file-assembly time). Order: first-seen, matching the existing builders.
|
|
161
136
|
const allColumns: string[] = [];
|
|
162
137
|
const seenCols = new Set<string>();
|
|
163
138
|
for (let si = 0; si < config.sources.length; si++) {
|
|
@@ -169,9 +144,7 @@ export async function runPlannedMerge(config: {
|
|
|
169
144
|
}
|
|
170
145
|
}
|
|
171
146
|
|
|
172
|
-
// For each live key, for each column, find the winning source: among sources that have this key, the
|
|
173
|
-
// one whose keyTime is largest AND whose column-index reports non-ABSENT. Record source + sourceRow +
|
|
174
|
-
// byteLen + type so the planner can size offsets and the executor can copy the bytes.
|
|
147
|
+
// For each live key, for each column, find the winning source: among sources that have this key, the one whose keyTime is largest AND whose column-index reports non-ABSENT. Record source + sourceRow + byteLen + type so the planner can size offsets and the executor can copy the bytes.
|
|
175
148
|
type CellChoice = { sourceIdx: number; sourceRow: number; byteLen: number; type: number };
|
|
176
149
|
const cellsPerKey = new Map<string, (CellChoice | undefined)[]>();
|
|
177
150
|
for (const key of liveKeys) {
|
|
@@ -206,12 +179,10 @@ export async function runPlannedMerge(config: {
|
|
|
206
179
|
cellsPerKey.set(key, cells);
|
|
207
180
|
}
|
|
208
181
|
|
|
209
|
-
// Sort live keys lexicographically so each output file is key-contiguous (tight minKey/maxKey + fast
|
|
210
|
-
// single-key reads via header skip).
|
|
182
|
+
// Sort live keys lexicographically so each output file is key-contiguous (tight minKey/maxKey + fast single-key reads via header skip).
|
|
211
183
|
liveKeys.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
212
184
|
|
|
213
|
-
// Per-key estimated bytes for partition cutting: KEY_COLUMN cell + per value-column cell (bytes + 5B
|
|
214
|
-
// overhead) + TIME_COLUMN cell. Rough — only needs to keep each output near the target.
|
|
185
|
+
// Per-key estimated bytes for partition cutting: KEY_COLUMN cell + per value-column cell (bytes + 5B overhead) + TIME_COLUMN cell. Rough — only needs to keep each output near the target.
|
|
215
186
|
function keyTotalBytes(key: string): number {
|
|
216
187
|
const cells = cellsPerKey.get(key)!;
|
|
217
188
|
let total = key.length * 2 + PER_VALUE_OVERHEAD;
|
|
@@ -220,8 +191,7 @@ export async function runPlannedMerge(config: {
|
|
|
220
191
|
return total;
|
|
221
192
|
}
|
|
222
193
|
|
|
223
|
-
// Walk sorted keys, cut each file when accumulated bytes would exceed targetFileBytes. A single key
|
|
224
|
-
// larger than the target still becomes its own (oversized) file — we never split within a key.
|
|
194
|
+
// Walk sorted keys, cut each file when accumulated bytes would exceed targetFileBytes. A single key larger than the target still becomes its own (oversized) file — we never split within a key.
|
|
225
195
|
const fileKeyRanges: { start: number; end: number }[] = [];
|
|
226
196
|
{
|
|
227
197
|
let chunkStart = 0;
|
|
@@ -243,9 +213,7 @@ export async function runPlannedMerge(config: {
|
|
|
243
213
|
|
|
244
214
|
log(`${magenta("plan")}: ${formatNumber(liveKeys.length)} live keys, ${plans.length} output file(s), ${formatNumber(carriedDeletes.size)} tombstones carried`);
|
|
245
215
|
|
|
246
|
-
// ───────────────────────────────────────── Phase 2: execute ──────────────────────────────────────────
|
|
247
|
-
// Group output files into batches that fit within targetBatchBytes so we read inputs once per batch
|
|
248
|
-
// (and skip inputs that contribute nothing to this batch).
|
|
216
|
+
// ───────────────────────────────────────── Phase 2: execute ────────────────────────────────────────── Group output files into batches that fit within targetBatchBytes so we read inputs once per batch (and skip inputs that contribute nothing to this batch).
|
|
249
217
|
const batches: PlannedOutputFile[][] = [];
|
|
250
218
|
{
|
|
251
219
|
let start = 0;
|
|
@@ -305,9 +273,7 @@ function buildOutputPlan(
|
|
|
305
273
|
}
|
|
306
274
|
types[i] = cell.type;
|
|
307
275
|
sourceCounts.set(cell.sourceIdx, (sourceCounts.get(cell.sourceIdx) ?? 0) + 1);
|
|
308
|
-
// RLE: extend the current run iff the next cell is the next row of the same source (its
|
|
309
|
-
// source-side bytes sit immediately after, so one read suffices) AND non-ABSENT (we never
|
|
310
|
-
// landed in the break-path above).
|
|
276
|
+
// RLE: extend the current run iff the next cell is the next row of the same source (its source-side bytes sit immediately after, so one read suffices) AND non-ABSENT (we never landed in the break-path above).
|
|
311
277
|
if (currentRun && currentRun.sourceIdx === cell.sourceIdx && currentRun.sourceEndRow === cell.sourceRow) {
|
|
312
278
|
currentRun.sourceEndRow = cell.sourceRow + 1;
|
|
313
279
|
currentRun.byteLength += cell.byteLen;
|
|
@@ -329,8 +295,7 @@ function buildOutputPlan(
|
|
|
329
295
|
return { name: colName, offsets, types, dataLength: outputByte, runs };
|
|
330
296
|
});
|
|
331
297
|
|
|
332
|
-
// File size estimate: KEY_COLUMN blob + value column blobs + TIME_COLUMN blob + header guess. Used to
|
|
333
|
-
// batch output files; only needs to be close, not exact.
|
|
298
|
+
// File size estimate: KEY_COLUMN blob + value column blobs + TIME_COLUMN blob + header guess. Used to batch output files; only needs to be close, not exact.
|
|
334
299
|
let estimatedFileBytes = 4 + 2048;
|
|
335
300
|
let keyBytes = 0;
|
|
336
301
|
for (const k of keys) keyBytes += k.length * 2;
|
|
@@ -357,8 +322,7 @@ async function executeBatch(
|
|
|
357
322
|
writeFile: (data: Buffer) => Promise<{ name: string; size: number }>,
|
|
358
323
|
log: (line: string) => void,
|
|
359
324
|
): Promise<PlannedMergeOutput[]> {
|
|
360
|
-
// Allocate one big buffer per (output file, column) — offsets + types are written immediately from
|
|
361
|
-
// the plan, the data section is filled below by the per-source copy loop.
|
|
325
|
+
// Allocate one big buffer per (output file, column) — offsets + types are written immediately from the plan, the data section is filled below by the per-source copy loop.
|
|
362
326
|
type ColumnBuffer = { name: string; blob: Buffer; dataStart: number };
|
|
363
327
|
const blobsPerFile: ColumnBuffer[][] = plans.map(plan => plan.columns.map(col => {
|
|
364
328
|
const indexSize = columnIndexByteLength(col.offsets.length - 1);
|
|
@@ -368,9 +332,7 @@ async function executeBatch(
|
|
|
368
332
|
return { name: col.name, blob, dataStart: indexSize };
|
|
369
333
|
}));
|
|
370
334
|
|
|
371
|
-
// For each source, copy its byte runs into every output column whose plan references it. A source
|
|
372
|
-
// that's not referenced by any column in this batch is skipped entirely — that's the point of the
|
|
373
|
-
// batch shape: read 20GB of inputs only once for the whole pass, not once per output.
|
|
335
|
+
// For each source, copy its byte runs into every output column whose plan references it. A source that's not referenced by any column in this batch is skipped entirely — that's the point of the batch shape: read 20GB of inputs only once for the whole pass, not once per output.
|
|
374
336
|
for (let si = 0; si < sources.length; si++) {
|
|
375
337
|
let contributes = false;
|
|
376
338
|
for (let fi = 0; fi < plans.length && !contributes; fi++) {
|
|
@@ -21,10 +21,7 @@ export type ReaderConfig = {
|
|
|
21
21
|
maxTriggerThrottleMs?: number;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
// Owns the current LoadedIndex and WriteOverlay and serves every read (async + sync reactive). Apply
|
|
25
|
-
// writes/deletes through applyWrite/applyDelete so the right signals fire. The host swaps in a fresh
|
|
26
|
-
// LoadedIndex atomically via setIndex once the new one has fully built — no lazy rebuild on the next
|
|
27
|
-
// read, no synchronous lag spike.
|
|
24
|
+
// Owns the current LoadedIndex and WriteOverlay and serves every read (async + sync reactive). Apply writes/deletes through applyWrite/applyDelete so the right signals fire. The host swaps in a fresh LoadedIndex atomically via setIndex once the new one has fully built — no lazy rebuild on the next read, no synchronous lag spike.
|
|
28
25
|
export class BulkDatabaseReader<T extends { key: string }> {
|
|
29
26
|
constructor(private readonly cfg: ReaderConfig) { }
|
|
30
27
|
|
|
@@ -89,8 +86,7 @@ export class BulkDatabaseReader<T extends { key: string }> {
|
|
|
89
86
|
return -Infinity;
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
// Counter (so concurrent / nested merges in the same instance compose correctly). Signal fires
|
|
93
|
-
// immediately, not through the trigger throttle — UI spinners should show right away.
|
|
89
|
+
// Counter (so concurrent / nested merges in the same instance compose correctly). Signal fires immediately, not through the trigger throttle — UI spinners should show right away.
|
|
94
90
|
private compactingCount = 0;
|
|
95
91
|
beginCompaction(): void {
|
|
96
92
|
this.compactingCount++;
|
|
@@ -247,9 +243,7 @@ export class BulkDatabaseReader<T extends { key: string }> {
|
|
|
247
243
|
return false;
|
|
248
244
|
}
|
|
249
245
|
|
|
250
|
-
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
|
251
|
-
// Both async reads await this. The host wires an `ensureIndex` that triggers the initial build if
|
|
252
|
-
// no index is loaded yet — reads that race the first load all wait on the same promise.
|
|
246
|
+
// ── helpers ────────────────────────────────────────────────────────────────────────────────────── Both async reads await this. The host wires an `ensureIndex` that triggers the initial build if no index is loaded yet — reads that race the first load all wait on the same promise.
|
|
253
247
|
setEnsureIndex(fn: () => Promise<LoadedIndex<T>>): void { this.ensureIndexFn = fn; }
|
|
254
248
|
private ensureIndexFn: (() => Promise<LoadedIndex<T>>) | undefined;
|
|
255
249
|
private async requireIndex(): Promise<LoadedIndex<T>> {
|
|
@@ -262,9 +256,7 @@ export class BulkDatabaseReader<T extends { key: string }> {
|
|
|
262
256
|
return `(collection has ${blue(formatNumber(index.reader.rowCount))} rows, ${blue(formatNumber(index.reader.totalBytes))}B)`;
|
|
263
257
|
}
|
|
264
258
|
|
|
265
|
-
// Notifications are rampingly delayed under sustained changes (so a high-rate writer can't re-run
|
|
266
|
-
// watchers per change). Underlying data is always current — only the observable notification is
|
|
267
|
-
// batched/delayed.
|
|
259
|
+
// Notifications are rampingly delayed under sustained changes (so a high-rate writer can't re-run watchers per change). Underlying data is always current — only the observable notification is batched/delayed.
|
|
268
260
|
private invalidateSignal(signal: string): void {
|
|
269
261
|
const maxMs = this.cfg.maxTriggerThrottleMs ?? 0;
|
|
270
262
|
if (maxMs <= 0) { this.cfg.deps.invalidate(signal); return; }
|
|
@@ -39,10 +39,7 @@ function nullJoin(a: string, b: string): string {
|
|
|
39
39
|
return a + String.fromCharCode(0) + b;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// A loaded snapshot of one collection's on-disk state: the resolved reader + the per-file decoded sub-
|
|
43
|
-
// readers it stitched, the stream file sizes/times it observed, and the sync (reactive) caches that
|
|
44
|
-
// surface column/field reads. Built once via LoadedIndex.build — eagerly, so the swap into a fresh
|
|
45
|
-
// LoadedIndex never blocks a read on a synchronous rebuild.
|
|
42
|
+
// A loaded snapshot of one collection's on-disk state: the resolved reader + the per-file decoded sub- readers it stitched, the stream file sizes/times it observed, and the sync (reactive) caches that surface column/field reads. Built once via LoadedIndex.build — eagerly, so the swap into a fresh LoadedIndex never blocks a read on a synchronous rebuild.
|
|
46
43
|
export class LoadedIndex<T extends { key: string }> {
|
|
47
44
|
private constructor(
|
|
48
45
|
public readonly name: string,
|
|
@@ -67,8 +64,7 @@ export class LoadedIndex<T extends { key: string }> {
|
|
|
67
64
|
private baseColumnsLoading = new Set<string>();
|
|
68
65
|
private baseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
69
66
|
private baseFieldsLoading = new Set<string>();
|
|
70
|
-
// Last-known values from the previous index, served until this index loads its own fresh value, so
|
|
71
|
-
// a swap doesn't flash empty in sync reads. Cleared per-entry when the fresh value lands.
|
|
67
|
+
// Last-known values from the previous index, served until this index loads its own fresh value, so a swap doesn't flash empty in sync reads. Cleared per-entry when the fresh value lands.
|
|
72
68
|
private staleBaseColumns = new Map<string, { key: string; value: unknown; time: number }[]>();
|
|
73
69
|
private staleBaseFields = new Map<string, { value: unknown; time: number } | undefined>();
|
|
74
70
|
|
|
@@ -150,9 +146,7 @@ export class LoadedIndex<T extends { key: string }> {
|
|
|
150
146
|
}
|
|
151
147
|
}
|
|
152
148
|
|
|
153
|
-
// Adopts the previous index's loaded base values as STALE on this one, so a sync read mid-swap can
|
|
154
|
-
// serve the old value (patched with the current overlay) instead of flashing empty while the fresh
|
|
155
|
-
// load runs. Per-entry: dropped when the fresh value lands here.
|
|
149
|
+
// Adopts the previous index's loaded base values as STALE on this one, so a sync read mid-swap can serve the old value (patched with the current overlay) instead of flashing empty while the fresh load runs. Per-entry: dropped when the fresh value lands here.
|
|
156
150
|
inheritStaleFrom(prev: LoadedIndex<T>): void {
|
|
157
151
|
for (const [k, v] of prev.baseColumns) this.staleBaseColumns.set(k, v);
|
|
158
152
|
for (const [k, v] of prev.staleBaseColumns) if (!this.staleBaseColumns.has(k)) this.staleBaseColumns.set(k, v);
|
|
@@ -203,9 +197,7 @@ export class LoadedIndex<T extends { key: string }> {
|
|
|
203
197
|
})();
|
|
204
198
|
}
|
|
205
199
|
|
|
206
|
-
// Returns the loaded value (fresh if available, else last-known stale) for the column, or undefined
|
|
207
|
-
// if neither is present yet. The boolean indicates whether the returned value is fresh; callers may
|
|
208
|
-
// skip caching a stale value.
|
|
200
|
+
// Returns the loaded value (fresh if available, else last-known stale) for the column, or undefined if neither is present yet. The boolean indicates whether the returned value is fresh; callers may skip caching a stale value.
|
|
209
201
|
getBaseColumn(column: string): { entries: { key: string; value: unknown; time: number }[]; fresh: boolean } | undefined {
|
|
210
202
|
const fresh = this.baseColumns.get(column);
|
|
211
203
|
if (fresh) return { entries: fresh, fresh: true };
|
|
@@ -230,8 +222,7 @@ export class LoadedIndex<T extends { key: string }> {
|
|
|
230
222
|
return this.baseFields.has(ck) || this.staleBaseFields.has(ck);
|
|
231
223
|
}
|
|
232
224
|
|
|
233
|
-
// Drops in-memory loaded values for this index, so a subsequent read reloads fresh from disk. Used
|
|
234
|
-
// by reloadFromDisk() — a hard reset, no stale fallback survives.
|
|
225
|
+
// Drops in-memory loaded values for this index, so a subsequent read reloads fresh from disk. Used by reloadFromDisk() — a hard reset, no stale fallback survives.
|
|
235
226
|
dropLoadedValues(): void {
|
|
236
227
|
this.baseColumns.clear();
|
|
237
228
|
this.baseColumnsLoading.clear();
|
|
@@ -330,10 +321,7 @@ function pruneSubCaches(bulkFiles: BulkFileInfo[], streamFiles: StreamFileInfo[]
|
|
|
330
321
|
for (const n of subCaches.stream.keys()) if (!liveStream.has(n)) subCaches.stream.delete(n);
|
|
331
322
|
}
|
|
332
323
|
|
|
333
|
-
// A corrupt/unreadable column in ONE underlying file must not break the whole joined read. When a
|
|
334
|
-
// source's column read throws, we drop that source for that column (falling through to older readers,
|
|
335
|
-
// exactly as if the file never set the column) and warn once per (file, column) so the corruption is
|
|
336
|
-
// visible without flooding the log on every rebuild.
|
|
324
|
+
// A corrupt/unreadable column in ONE underlying file must not break the whole joined read. When a source's column read throws, we drop that source for that column (falling through to older readers, exactly as if the file never set the column) and warn once per (file, column) so the corruption is visible without flooding the log on every rebuild.
|
|
337
325
|
const warnedCorruptReads = new Set<string>();
|
|
338
326
|
function warnCorruptRead(db: BaseBulkDatabaseReader, column: string, e: unknown): void {
|
|
339
327
|
const path = db.name || "(unknown source)";
|
|
@@ -2,10 +2,7 @@ export const DELETED = Symbol("deleted");
|
|
|
2
2
|
|
|
3
3
|
export type OverlayEntry = { time: number; value: Record<string, unknown> | typeof DELETED };
|
|
4
4
|
|
|
5
|
-
// In-memory pending mutations layered on top of a LoadedIndex. Each entry carries the write's unique
|
|
6
|
-
// timestamp so cross-tab writes can be ordered + the swap can drop entries the new index already
|
|
7
|
-
// covers. Methods that mutate report which columns invalidate ("all" if the key's liveness flipped),
|
|
8
|
-
// so the host can drop the right caches without scanning every column.
|
|
5
|
+
// In-memory pending mutations layered on top of a LoadedIndex. Each entry carries the write's unique timestamp so cross-tab writes can be ordered + the swap can drop entries the new index already covers. Methods that mutate report which columns invalidate ("all" if the key's liveness flipped), so the host can drop the right caches without scanning every column.
|
|
9
6
|
export class WriteOverlay {
|
|
10
7
|
private entries = new Map<string, OverlayEntry>();
|
|
11
8
|
|
|
@@ -29,16 +26,14 @@ export class WriteOverlay {
|
|
|
29
26
|
|
|
30
27
|
clear(): void { this.entries.clear(); }
|
|
31
28
|
|
|
32
|
-
// Drop entries the new index already reflects (its keyTime or deleteTime ≥ the entry's time). The
|
|
33
|
-
// remaining entries are writes the index doesn't yet see — they have to keep serving reads.
|
|
29
|
+
// Drop entries the new index already reflects (its keyTime or deleteTime ≥ the entry's time). The remaining entries are writes the index doesn't yet see — they have to keep serving reads.
|
|
34
30
|
sweepCovered(authority: (key: string) => number): void {
|
|
35
31
|
for (const [key, entry] of this.entries) {
|
|
36
32
|
if (entry.time <= authority(key)) this.entries.delete(key);
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
// Apply this overlay onto the base column entries. A column-unset overlay entry leaves the base
|
|
41
|
-
// value+time in place (a partial write only overrides the columns it set).
|
|
36
|
+
// Apply this overlay onto the base column entries. A column-unset overlay entry leaves the base value+time in place (a partial write only overrides the columns it set).
|
|
42
37
|
patchColumn(base: { key: string; value: unknown; time: number }[], column: string): { key: string; value: unknown; time: number }[] {
|
|
43
38
|
if (this.entries.size === 0) return base;
|
|
44
39
|
const map = new Map(base.map(e => [e.key, { value: e.value, time: e.time }]));
|