sliftutils 1.1.44 → 1.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +4 -1
- package/index.d.ts +4 -1
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +3 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +124 -48
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +1 -1
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +51 -1
package/index.d.ts
CHANGED
|
@@ -793,8 +793,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabase2" {
|
|
|
793
793
|
private rolloverStream;
|
|
794
794
|
compact(): Promise<void>;
|
|
795
795
|
private listFiles;
|
|
796
|
+
private makeRawGetRange;
|
|
796
797
|
private loadFileReader;
|
|
798
|
+
private fileLogicalSize;
|
|
797
799
|
private handleUnreadableFile;
|
|
800
|
+
private mergeFilesBase;
|
|
798
801
|
private mergeFiles;
|
|
799
802
|
private formatInfo;
|
|
800
803
|
private patchColumn;
|
|
@@ -841,7 +844,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseFormat" {
|
|
|
841
844
|
/// <reference types="node" />
|
|
842
845
|
export declare const KEY_COLUMN = "key";
|
|
843
846
|
export declare const EMPTY_BUFFER: Buffer;
|
|
844
|
-
export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer;
|
|
847
|
+
export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
|
|
845
848
|
export type BaseBulkDatabaseReader = {
|
|
846
849
|
rowCount: number;
|
|
847
850
|
totalBytes: number;
|
package/package.json
CHANGED
|
@@ -28,8 +28,11 @@ export declare class BulkDatabase2<T extends {
|
|
|
28
28
|
private rolloverStream;
|
|
29
29
|
compact(): Promise<void>;
|
|
30
30
|
private listFiles;
|
|
31
|
+
private makeRawGetRange;
|
|
31
32
|
private loadFileReader;
|
|
33
|
+
private fileLogicalSize;
|
|
32
34
|
private handleUnreadableFile;
|
|
35
|
+
private mergeFilesBase;
|
|
33
36
|
private mergeFiles;
|
|
34
37
|
private formatInfo;
|
|
35
38
|
private patchColumn;
|
|
@@ -14,9 +14,18 @@ import { connect as syncConnect, broadcast as syncBroadcast, isSyncSupported, Re
|
|
|
14
14
|
// folder rather than sharing bulkDatabases/.
|
|
15
15
|
const BULK_ROOT_FOLDER = "bulkDatabases2";
|
|
16
16
|
const FILE_EXTENSION = ".bulk";
|
|
17
|
-
//
|
|
17
|
+
// Once this many bulk files pile up we run a merge pass to consolidate small ones (bounded by the
|
|
18
|
+
// byte caps below), so reads don't fan out across an unbounded number of files.
|
|
18
19
|
const MERGE_FILE_COUNT = 8;
|
|
19
20
|
|
|
21
|
+
// Memory ceiling for a single merge. Files are merged in contiguous (newest-first) runs whose combined
|
|
22
|
+
// LOGICAL (uncompressed) size stays under MERGE_MAX_BYTES, so a merge never reads more than this into
|
|
23
|
+
// memory at once. A file at or above MERGE_MIN_BYTES is "sealed": big enough already, never read back
|
|
24
|
+
// in to be merged again. Sizes are measured uncompressed (from each file's index) because that — not
|
|
25
|
+
// the smaller on-disk compressed size — is what actually lands in memory during a merge.
|
|
26
|
+
const MERGE_MIN_BYTES = 400 * 1024 * 1024;
|
|
27
|
+
const MERGE_MAX_BYTES = 800 * 1024 * 1024;
|
|
28
|
+
|
|
20
29
|
// Tier-0 streaming rolls over into a columnar bulk file once it gets big enough — by row count,
|
|
21
30
|
// byte size, or file count (many threads each stream to their own file). A single writeBatch that
|
|
22
31
|
// already exceeds the row/byte limits skips streaming and writes a bulk file directly.
|
|
@@ -39,9 +48,21 @@ let fileNameCounter = 0;
|
|
|
39
48
|
|
|
40
49
|
type BulkFileInfo = { fileName: string; level: number; timestamp: number };
|
|
41
50
|
|
|
42
|
-
|
|
51
|
+
let lastFileTime = 0;
|
|
52
|
+
// A strictly-increasing integer timestamp for newly written files, so the newest-first order is
|
|
53
|
+
// unambiguous even when several writes land in the same millisecond. (getTimeUnique isn't used here
|
|
54
|
+
// because it may return a fractional value, which wouldn't round-trip through the integer file name.)
|
|
55
|
+
function nextFileTime(): number {
|
|
56
|
+
lastFileTime = Math.max(Date.now(), lastFileTime + 1);
|
|
57
|
+
return lastFileTime;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Files are ordered purely by timestamp (newest-first). A merged file is given the newest timestamp
|
|
61
|
+
// of the run it replaced, so it occupies exactly that run's slot. The leading "0" is a vestigial
|
|
62
|
+
// field kept so the name stays in the historical level_timestamp_counter shape parseFileName expects.
|
|
63
|
+
function newFileName(timestamp: number): string {
|
|
43
64
|
fileNameCounter++;
|
|
44
|
-
return
|
|
65
|
+
return `0_${timestamp}_${fileNameCounter}${FILE_EXTENSION}`;
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
type StreamFileInfo = { fileName: string; timestamp: number };
|
|
@@ -239,34 +260,18 @@ export class BulkDatabase2<T extends { key: string }> {
|
|
|
239
260
|
await this.maybeRolloverStream();
|
|
240
261
|
}
|
|
241
262
|
|
|
242
|
-
// Writes one columnar bulk
|
|
263
|
+
// Writes the rows as one or more columnar bulk files (buildFileBuffer splits a too-large batch by
|
|
264
|
+
// row range so no single file approaches the Buffer size limit), all sharing one timestamp since
|
|
265
|
+
// they're one write with disjoint keys. Then, if enough files have accumulated, runs bounded merge
|
|
266
|
+
// passes until nothing more can be consolidated.
|
|
243
267
|
private async writeBulkFile(rows: Record<string, unknown>[]): Promise<void> {
|
|
244
268
|
const storage = await this.storage();
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let list = byLevel.get(f.level);
|
|
252
|
-
if (!list) {
|
|
253
|
-
list = [];
|
|
254
|
-
byLevel.set(f.level, list);
|
|
255
|
-
}
|
|
256
|
-
list.push(f);
|
|
257
|
-
}
|
|
258
|
-
const levels = [...byLevel.keys()];
|
|
259
|
-
sort(levels, l => l);
|
|
260
|
-
const mergeLevel = levels.find(l => {
|
|
261
|
-
const list = byLevel.get(l);
|
|
262
|
-
return list && list.length >= MERGE_FILE_COUNT;
|
|
263
|
-
});
|
|
264
|
-
if (mergeLevel === undefined) break;
|
|
265
|
-
const levelFiles = byLevel.get(mergeLevel);
|
|
266
|
-
if (!levelFiles) {
|
|
267
|
-
throw new Error(`Expected files at level ${mergeLevel}, was empty`);
|
|
268
|
-
}
|
|
269
|
-
await this.mergeFiles(levelFiles, mergeLevel + 1);
|
|
269
|
+
const timestamp = nextFileTime();
|
|
270
|
+
for (const buffer of buildFileBuffer(rows)) {
|
|
271
|
+
await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
|
|
272
|
+
}
|
|
273
|
+
if ((await this.listFiles()).length >= MERGE_FILE_COUNT) {
|
|
274
|
+
while (await this.mergeFiles() > 0) { /* keep merging until no run can be consolidated */ }
|
|
270
275
|
}
|
|
271
276
|
}
|
|
272
277
|
|
|
@@ -358,13 +363,14 @@ export class BulkDatabase2<T extends { key: string }> {
|
|
|
358
363
|
this.resetReader();
|
|
359
364
|
}
|
|
360
365
|
|
|
361
|
-
//
|
|
366
|
+
// Consolidate as much as the caps allow: repeatedly merge contiguous non-sealed runs until nothing
|
|
367
|
+
// more can be combined. Unlike a naive "merge everything into one file", this respects MERGE_MAX_BYTES
|
|
368
|
+
// so it never loads the whole collection into memory — a multi-GB collection settles into several
|
|
369
|
+
// capped files (sealed files are left as-is) rather than one giant one.
|
|
362
370
|
public async compact(): Promise<void> {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
await this.mergeFiles(files, maxLevel + 1);
|
|
367
|
-
this.resetReader();
|
|
371
|
+
let merged = false;
|
|
372
|
+
while (await this.mergeFiles() > 0) merged = true;
|
|
373
|
+
if (merged) this.resetReader();
|
|
368
374
|
}
|
|
369
375
|
|
|
370
376
|
private async listFiles(): Promise<BulkFileInfo[]> {
|
|
@@ -374,20 +380,21 @@ export class BulkDatabase2<T extends { key: string }> {
|
|
|
374
380
|
const parsed = parseFileName(n);
|
|
375
381
|
return parsed && [parsed] || [];
|
|
376
382
|
});
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
383
|
+
// Newest-first by timestamp; ties broken by file name (descending) for a deterministic order.
|
|
384
|
+
// A merged file inherits the newest timestamp of the run it replaced, so it lands exactly where
|
|
385
|
+
// that run was — keeping newest-wins correct without any level bookkeeping.
|
|
386
|
+
files.sort((a, b) => {
|
|
387
|
+
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
388
|
+
return a.fileName < b.fileName && 1 || a.fileName > b.fileName && -1 || 0;
|
|
389
|
+
});
|
|
380
390
|
return files;
|
|
381
391
|
}
|
|
382
392
|
|
|
383
|
-
private async
|
|
393
|
+
private async makeRawGetRange(fileName: string): Promise<{ rawGetRange: GetRange; size: number } | undefined> {
|
|
384
394
|
const storage = await this.storage();
|
|
385
395
|
const info = await storage.getInfo(fileName);
|
|
386
|
-
if (!info)
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
const fileId = `${this.name}\u0000${fileName}`;
|
|
390
|
-
let getRange: GetRange = async (start, end) => {
|
|
396
|
+
if (!info) return undefined;
|
|
397
|
+
const rawGetRange: GetRange = async (start, end) => {
|
|
391
398
|
if (end <= start) return EMPTY_BUFFER;
|
|
392
399
|
const buf = await storage.getRange(fileName, { start, end });
|
|
393
400
|
if (!buf) {
|
|
@@ -395,13 +402,37 @@ export class BulkDatabase2<T extends { key: string }> {
|
|
|
395
402
|
}
|
|
396
403
|
return buf;
|
|
397
404
|
};
|
|
405
|
+
return { rawGetRange, size: info.size };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private async loadFileReader(fileName: string): Promise<BaseBulkDatabaseReader> {
|
|
409
|
+
const raw = await this.makeRawGetRange(fileName);
|
|
410
|
+
if (!raw) {
|
|
411
|
+
throw new Error(`Expected bulk file to exist, was missing: ${fileName}`);
|
|
412
|
+
}
|
|
413
|
+
const fileId = `${this.name}\u0000${fileName}`;
|
|
398
414
|
// Files are immutable and stored as compressed blocks; replace getRange with a block-cached,
|
|
399
415
|
// decompressing version (same interface) and read the logical (uncompressed) size from its
|
|
400
416
|
// index. open() validates the file size against the index and throws if it's truncated/corrupt.
|
|
401
|
-
const opened = await blockCache.open(fileId,
|
|
417
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
402
418
|
return loadBulkDatabase({ totalBytes: opened.uncompressedSize, getRange: opened.getRange });
|
|
403
419
|
}
|
|
404
420
|
|
|
421
|
+
// Logical (uncompressed) size of a bulk file, read from its (cached) index without loading data.
|
|
422
|
+
// Used by the merge planner to bound how much it reads at once. Returns undefined for a file that's
|
|
423
|
+
// missing or unreadable so the planner simply leaves it out of any merge.
|
|
424
|
+
private async fileLogicalSize(fileName: string): Promise<number | undefined> {
|
|
425
|
+
try {
|
|
426
|
+
const raw = await this.makeRawGetRange(fileName);
|
|
427
|
+
if (!raw) return undefined;
|
|
428
|
+
const fileId = `${this.name}\u0000${fileName}`;
|
|
429
|
+
const opened = await blockCache.open(fileId, raw.size, raw.rawGetRange);
|
|
430
|
+
return opened.uncompressedSize;
|
|
431
|
+
} catch {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
405
436
|
// A bulk file that won't load is either a write still in progress (recent) or a stale partial write
|
|
406
437
|
// left by a crash. We can't tell which from the bytes, so we go by age: warn while it's young
|
|
407
438
|
// enough that a writer could still be finishing it, and delete it once it's clearly abandoned.
|
|
@@ -422,8 +453,11 @@ export class BulkDatabase2<T extends { key: string }> {
|
|
|
422
453
|
console.warn(`${this.name}: skipping unreadable bulk file ${file.fileName} (recent — may be an in-progress write): ${message}`);
|
|
423
454
|
}
|
|
424
455
|
|
|
425
|
-
//
|
|
426
|
-
|
|
456
|
+
// Merges exactly the files it's given (already newest-first), newest-wins per key, writing the
|
|
457
|
+
// result with `timestamp` so the new file takes the slot of the run it replaced, then deletes the
|
|
458
|
+
// consumed files. The caller is responsible for keeping the input under MERGE_MAX_BYTES — this
|
|
459
|
+
// function blindly reads it all into memory.
|
|
460
|
+
private async mergeFilesBase(files: BulkFileInfo[], timestamp: number): Promise<void> {
|
|
427
461
|
const storage = await this.storage();
|
|
428
462
|
const readers = await Promise.all(files.map(f => this.loadFileReader(f.fileName)));
|
|
429
463
|
|
|
@@ -446,12 +480,54 @@ export class BulkDatabase2<T extends { key: string }> {
|
|
|
446
480
|
}
|
|
447
481
|
}
|
|
448
482
|
|
|
449
|
-
|
|
483
|
+
// The input is under the cap, so buildFileBuffer almost always returns a single buffer; the loop
|
|
484
|
+
// is only here to stay correct if a merge's deduped output still happens to exceed the split size.
|
|
485
|
+
for (const buffer of buildFileBuffer(mergedRows)) {
|
|
486
|
+
await storage.set(newFileName(timestamp), encodeCompressedBlocks(buffer));
|
|
487
|
+
}
|
|
450
488
|
for (const f of files) {
|
|
451
489
|
await storage.remove(f.fileName);
|
|
452
490
|
}
|
|
453
491
|
}
|
|
454
492
|
|
|
493
|
+
// The cap-aware merge planner. Walks the files newest-first and merges contiguous runs of
|
|
494
|
+
// non-sealed files, each run capped at MERGE_MAX_BYTES of LOGICAL size so a single merge never
|
|
495
|
+
// loads more than that into memory. A file at/over MERGE_MIN_BYTES is sealed (left untouched) and
|
|
496
|
+
// breaks the run, as does an unreadable file. Because each run is contiguous in the newest-first
|
|
497
|
+
// order and its merged file keeps the run's newest timestamp, newest-wins ordering is preserved
|
|
498
|
+
// with no inversions. Returns the number of runs merged (0 means nothing left to consolidate).
|
|
499
|
+
private async mergeFiles(): Promise<number> {
|
|
500
|
+
const files = await this.listFiles();
|
|
501
|
+
const sizes = await Promise.all(files.map(f => this.fileLogicalSize(f.fileName)));
|
|
502
|
+
|
|
503
|
+
const batches: BulkFileInfo[][] = [];
|
|
504
|
+
let batch: BulkFileInfo[] = [];
|
|
505
|
+
let batchBytes = 0;
|
|
506
|
+
const flush = () => {
|
|
507
|
+
// A run of one file is pointless to "merge" — only consolidate when there are at least two.
|
|
508
|
+
if (batch.length >= 2) batches.push(batch);
|
|
509
|
+
batch = [];
|
|
510
|
+
batchBytes = 0;
|
|
511
|
+
};
|
|
512
|
+
for (let i = 0; i < files.length; i++) {
|
|
513
|
+
const size = sizes[i];
|
|
514
|
+
if (size === undefined || size >= MERGE_MIN_BYTES) {
|
|
515
|
+
flush();
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (batch.length && batchBytes + size > MERGE_MAX_BYTES) flush();
|
|
519
|
+
batch.push(files[i]);
|
|
520
|
+
batchBytes += size;
|
|
521
|
+
}
|
|
522
|
+
flush();
|
|
523
|
+
|
|
524
|
+
// batch[0] is the newest file in each (newest-first) run, so its timestamp is the run's slot.
|
|
525
|
+
for (const runFiles of batches) {
|
|
526
|
+
await this.mergeFilesBase(runFiles, runFiles[0].timestamp);
|
|
527
|
+
}
|
|
528
|
+
return batches.length;
|
|
529
|
+
}
|
|
530
|
+
|
|
455
531
|
private formatInfo(reader: BaseBulkDatabaseReader): string {
|
|
456
532
|
return `(collection has ${blue(formatNumber(reader.rowCount))} rows, ${blue(formatNumber(reader.totalBytes))}B)`;
|
|
457
533
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
export declare const KEY_COLUMN = "key";
|
|
4
4
|
export declare const EMPTY_BUFFER: Buffer;
|
|
5
|
-
export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer;
|
|
5
|
+
export declare function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
|
|
6
6
|
export type BaseBulkDatabaseReader = {
|
|
7
7
|
rowCount: number;
|
|
8
8
|
totalBytes: number;
|
|
@@ -134,7 +134,35 @@ function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
|
|
|
134
134
|
return values;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
// Past this estimated logical size a single rows array is split across multiple files, so no one
|
|
138
|
+
// file (and therefore no single column blob, and no single Buffer.concat) ever approaches the
|
|
139
|
+
// ~2GB Buffer length limit. The cap is deliberately the same as the merge cap: 800MB is the most
|
|
140
|
+
// we'll ever hold for one file. The estimate is rough on purpose — it only needs to keep each
|
|
141
|
+
// chunk comfortably under the limit, not be exact.
|
|
142
|
+
const FILE_SPLIT_BYTES = 800 * 1024 * 1024;
|
|
143
|
+
|
|
144
|
+
// Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
|
|
145
|
+
const PER_VALUE_OVERHEAD = 5;
|
|
146
|
+
|
|
147
|
+
function estimateValueBytes(value: unknown): number {
|
|
148
|
+
if (value === undefined || value === null) return 0;
|
|
149
|
+
if (typeof value === "string") return value.length * 2;
|
|
150
|
+
if (typeof value === "number") return 8;
|
|
151
|
+
if (typeof value === "boolean") return 1;
|
|
152
|
+
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
153
|
+
if (typeof value === "object") return JSON.stringify(value).length;
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function estimateRowBytes(row: Record<string, unknown>): number {
|
|
158
|
+
let total = 0;
|
|
159
|
+
for (const value of Object.values(row)) {
|
|
160
|
+
total += estimateValueBytes(value) + PER_VALUE_OVERHEAD;
|
|
161
|
+
}
|
|
162
|
+
return total;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
138
166
|
const columnNames: string[] = [];
|
|
139
167
|
const columnSet = new Set<string>();
|
|
140
168
|
for (const row of rows) {
|
|
@@ -158,6 +186,28 @@ export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer {
|
|
|
158
186
|
return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
|
|
159
187
|
}
|
|
160
188
|
|
|
189
|
+
// Returns one complete, independent file buffer per chunk of rows. When the caller hands us more
|
|
190
|
+
// rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
|
|
191
|
+
// what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
|
|
192
|
+
// the caller just writes each as its own file. A normal-sized write returns a single buffer.
|
|
193
|
+
export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[] {
|
|
194
|
+
if (rows.length === 0) return [buildOneFile([])];
|
|
195
|
+
const result: Buffer[] = [];
|
|
196
|
+
let chunkStart = 0;
|
|
197
|
+
let chunkBytes = 0;
|
|
198
|
+
for (let i = 0; i < rows.length; i++) {
|
|
199
|
+
const rowBytes = estimateRowBytes(rows[i]);
|
|
200
|
+
if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
|
|
201
|
+
result.push(buildOneFile(rows.slice(chunkStart, i)));
|
|
202
|
+
chunkStart = i;
|
|
203
|
+
chunkBytes = 0;
|
|
204
|
+
}
|
|
205
|
+
chunkBytes += rowBytes;
|
|
206
|
+
}
|
|
207
|
+
result.push(buildOneFile(rows.slice(chunkStart)));
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
export type BaseBulkDatabaseReader = {
|
|
162
212
|
rowCount: number;
|
|
163
213
|
totalBytes: number;
|