sliftutils 1.4.48 → 1.4.50

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.
@@ -0,0 +1,398 @@
1
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
2
+ import { blue, red } from "socket-function/src/formatting/logColors";
3
+ import {
4
+ BaseBulkDatabaseReader,
5
+ ColumnIndex,
6
+ assemblePlannedFile,
7
+ columnIndexByteLength,
8
+ KEY_COLUMN,
9
+ TARGET_FILE_BYTES,
10
+ TYPE_ABSENT_TAG,
11
+ } from "./BulkDatabaseFormat";
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.
16
+ const DEFAULT_OUTPUT_BATCH_BYTES = 2 * 1024 * 1024 * 1024;
17
+
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).
20
+ const PER_VALUE_OVERHEAD = 5;
21
+
22
+ function fmtBytes(n: number): string {
23
+ if (n < 1024) return n + "B";
24
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB";
25
+ if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + "MB";
26
+ return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
27
+ }
28
+
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.
32
+ type CopyRun = {
33
+ sourceIdx: number;
34
+ sourceStartRow: number;
35
+ sourceEndRow: number;
36
+ outputByteStart: number;
37
+ byteLength: number;
38
+ };
39
+
40
+ type PlannedOutputColumn = {
41
+ 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.
44
+ offsets: Uint32Array;
45
+ // Per-row type tag (TYPE_ABSENT for fall-through). Populated by the planner.
46
+ types: Uint8Array;
47
+ // Total bytes the data section will occupy = offsets[rowCount].
48
+ dataLength: number;
49
+ // Run-length encoded per-row copy decisions, in output row order. ABSENT rows contribute no run.
50
+ runs: CopyRun[];
51
+ };
52
+
53
+ export type PlannedOutputFile = {
54
+ keys: string[];
55
+ times: number[];
56
+ minKey: string;
57
+ maxKey: string;
58
+ columns: PlannedOutputColumn[];
59
+ // Sum of all column data sizes + index overhead + header guess; used to group outputs into batches.
60
+ estimatedFileBytes: number;
61
+ // sourceIdx → non-ABSENT cell count contributed to this output file (for logging "where it came from").
62
+ sourceCounts: Map<number, number>;
63
+ };
64
+
65
+ export type PlannedMergeOutput = {
66
+ name: string;
67
+ minKey: string;
68
+ maxKey: string;
69
+ rowCount: number;
70
+ size: number;
71
+ sources: Map<string, number>;
72
+ };
73
+
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).
85
+ export async function runPlannedMerge(config: {
86
+ sources: BaseBulkDatabaseReader[];
87
+ sourceNames: string[];
88
+ collectionName: string;
89
+ targetFileBytes?: number;
90
+ targetBatchBytes?: number;
91
+ writeFile: (data: Buffer) => Promise<{ name: string; size: number }>;
92
+ }): Promise<{ outputs: PlannedMergeOutput[]; carriedDeletes: Map<string, number> }> {
93
+ const targetFileBytes = config.targetFileBytes ?? TARGET_FILE_BYTES;
94
+ const targetBatchBytes = config.targetBatchBytes ?? DEFAULT_OUTPUT_BATCH_BYTES;
95
+
96
+ // ─────────────────────────────────────────── Phase 1: plan ───────────────────────────────────────────
97
+ const planStart = Date.now();
98
+
99
+ // Aggregate keyTimes + deleteTimes across all sources (max per key).
100
+ const deleteTime = new Map<string, number>();
101
+ const keyTime = new Map<string, number>();
102
+ for (const src of config.sources) {
103
+ for (const [k, t] of src.keyTimes) {
104
+ const prev = keyTime.get(k);
105
+ if (prev === undefined || t > prev) keyTime.set(k, t);
106
+ }
107
+ if (src.deleteTimes) {
108
+ for (const [k, t] of src.deleteTimes) {
109
+ const prev = deleteTime.get(k);
110
+ if (prev === undefined || t > prev) deleteTime.set(k, t);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Live keys (newest set strictly newer than newest delete) + tombstones to carry forward (newest
116
+ // event is a delete and the merge doesn't include the oldest data — that's the caller's call).
117
+ const liveKeys: string[] = [];
118
+ for (const [k, t] of keyTime) {
119
+ const dT = deleteTime.get(k) ?? -Infinity;
120
+ if (t > dT) liveKeys.push(k);
121
+ }
122
+ const carriedDeletes = new Map<string, number>();
123
+ for (const [k, dT] of deleteTime) {
124
+ const sT = keyTime.get(k) ?? -Infinity;
125
+ if (dT >= sT) carriedDeletes.set(k, dT);
126
+ }
127
+
128
+ // All distinct value columns (KEY_COLUMN excluded — it's added at file-assembly time). Order: first-
129
+ // seen across sources, matching the existing builders.
130
+ const allColumns: string[] = [];
131
+ const seenCols = new Set<string>();
132
+ for (const src of config.sources) {
133
+ for (const c of src.columns) {
134
+ if (c.column === KEY_COLUMN || seenCols.has(c.column)) continue;
135
+ seenCols.add(c.column);
136
+ allColumns.push(c.column);
137
+ }
138
+ }
139
+
140
+ // Load every (source, column) index in parallel — small data (offsets+types), no values pulled. Each
141
+ // index is kept for the executor too, so it can read contiguous row-ranges from the right source.
142
+ const indexesPerSource: Map<string, ColumnIndex>[] = await Promise.all(config.sources.map(async src => {
143
+ const map = new Map<string, ColumnIndex>();
144
+ await Promise.all(allColumns.map(async col => {
145
+ map.set(col, await src.getColumnIndex(col));
146
+ }));
147
+ return map;
148
+ }));
149
+
150
+ // For each live key, for each column, find the winning source: among sources that have this key, the
151
+ // one whose keyTime is largest AND whose column-index reports non-ABSENT. Record source + sourceRow +
152
+ // byteLen + type so the planner can size offsets and the executor can copy the bytes.
153
+ type CellChoice = { sourceIdx: number; sourceRow: number; byteLen: number; type: number };
154
+ const cellsPerKey = new Map<string, (CellChoice | undefined)[]>();
155
+ for (const key of liveKeys) {
156
+ const cells: (CellChoice | undefined)[] = new Array(allColumns.length);
157
+ for (let ci = 0; ci < allColumns.length; ci++) {
158
+ const col = allColumns[ci];
159
+ let bestTime = -Infinity;
160
+ let best: CellChoice | undefined;
161
+ for (let si = 0; si < config.sources.length; si++) {
162
+ const src = config.sources[si];
163
+ const t = src.keyTimes.get(key);
164
+ if (t === undefined) continue;
165
+ const rowIdx = src.rowOfKey(key);
166
+ if (rowIdx === undefined) continue;
167
+ const idx = indexesPerSource[si].get(col);
168
+ if (!idx) continue;
169
+ const type = idx.types[rowIdx];
170
+ if (type === TYPE_ABSENT_TAG) continue;
171
+ if (t > bestTime) {
172
+ bestTime = t;
173
+ best = {
174
+ sourceIdx: si,
175
+ sourceRow: rowIdx,
176
+ byteLen: idx.offsets[rowIdx + 1] - idx.offsets[rowIdx],
177
+ type,
178
+ };
179
+ }
180
+ }
181
+ cells[ci] = best;
182
+ }
183
+ cellsPerKey.set(key, cells);
184
+ }
185
+
186
+ // Sort live keys lexicographically so each output file is key-contiguous (tight minKey/maxKey + fast
187
+ // single-key reads via header skip).
188
+ liveKeys.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
189
+
190
+ // Per-key estimated bytes for partition cutting: KEY_COLUMN cell + per value-column cell (bytes + 5B
191
+ // overhead) + TIME_COLUMN cell. Rough — only needs to keep each output near the target.
192
+ function keyTotalBytes(key: string): number {
193
+ const cells = cellsPerKey.get(key)!;
194
+ let total = key.length * 2 + PER_VALUE_OVERHEAD;
195
+ for (const cell of cells) total += (cell ? cell.byteLen : 0) + PER_VALUE_OVERHEAD;
196
+ total += 8 + PER_VALUE_OVERHEAD;
197
+ return total;
198
+ }
199
+
200
+ // Walk sorted keys, cut each file when accumulated bytes would exceed targetFileBytes. A single key
201
+ // larger than the target still becomes its own (oversized) file — we never split within a key.
202
+ const fileKeyRanges: { start: number; end: number }[] = [];
203
+ {
204
+ let chunkStart = 0;
205
+ let chunkBytes = 0;
206
+ for (let i = 0; i < liveKeys.length; i++) {
207
+ const kb = keyTotalBytes(liveKeys[i]);
208
+ if (i > chunkStart && chunkBytes + kb > targetFileBytes) {
209
+ fileKeyRanges.push({ start: chunkStart, end: i });
210
+ chunkStart = i;
211
+ chunkBytes = 0;
212
+ }
213
+ chunkBytes += kb;
214
+ }
215
+ if (liveKeys.length > chunkStart) fileKeyRanges.push({ start: chunkStart, end: liveKeys.length });
216
+ }
217
+
218
+ // Build per-file plans: offsets/types/copy-runs per column.
219
+ const plans = fileKeyRanges.map(range => buildOutputPlan(range, liveKeys, cellsPerKey, allColumns, keyTime));
220
+
221
+ const planTime = Date.now() - planStart;
222
+ console.log(`${blue(config.collectionName)} planned merge: mapping done — ${formatNumber(liveKeys.length)} live keys, ${plans.length} output file(s), ${formatNumber(carriedDeletes.size)} tombstones carried, in ${red(formatTime(planTime))}`);
223
+
224
+ // ───────────────────────────────────────── Phase 2: execute ──────────────────────────────────────────
225
+ // Group output files into batches that fit within targetBatchBytes so we read inputs once per batch
226
+ // (and skip inputs that contribute nothing to this batch).
227
+ const batches: PlannedOutputFile[][] = [];
228
+ {
229
+ let start = 0;
230
+ while (start < plans.length) {
231
+ let end = start + 1;
232
+ let total = plans[start].estimatedFileBytes;
233
+ while (end < plans.length && total + plans[end].estimatedFileBytes <= targetBatchBytes) {
234
+ total += plans[end].estimatedFileBytes;
235
+ end++;
236
+ }
237
+ batches.push(plans.slice(start, end));
238
+ start = end;
239
+ }
240
+ }
241
+
242
+ const outputs: PlannedMergeOutput[] = [];
243
+ const execStart = Date.now();
244
+ for (let bi = 0; bi < batches.length; bi++) {
245
+ const batch = batches[bi];
246
+ const batchBytes = batch.reduce((a, p) => a + p.estimatedFileBytes, 0);
247
+ console.log(`${blue(config.collectionName)} merge batch ${bi + 1}/${batches.length}: ${batch.length} output file(s), ~${fmtBytes(batchBytes)} budget`);
248
+ const batchOutputs = await executeBatch(batch, indexesPerSource, config.sources, config.sourceNames, config.collectionName, config.writeFile);
249
+ outputs.push(...batchOutputs);
250
+ }
251
+ const execTime = Date.now() - execStart;
252
+ const writtenBytes = outputs.reduce((a, o) => a + o.size, 0);
253
+ console.log(`${blue(config.collectionName)} planned merge: done — ${outputs.length} file(s), ${fmtBytes(writtenBytes)} written, in ${red(formatTime(execTime))} (planning + execute: ${red(formatTime(Date.now() - planStart))})`);
254
+
255
+ return { outputs, carriedDeletes };
256
+ }
257
+
258
+ function buildOutputPlan(
259
+ range: { start: number; end: number },
260
+ liveKeys: string[],
261
+ cellsPerKey: Map<string, ({ sourceIdx: number; sourceRow: number; byteLen: number; type: number } | undefined)[]>,
262
+ allColumns: string[],
263
+ keyTime: Map<string, number>,
264
+ ): PlannedOutputFile {
265
+ const keys = liveKeys.slice(range.start, range.end);
266
+ const rowCount = keys.length;
267
+ const times = keys.map(k => keyTime.get(k) ?? 0);
268
+ const sourceCounts = new Map<number, number>();
269
+
270
+ const columns: PlannedOutputColumn[] = allColumns.map((colName, ci) => {
271
+ const offsets = new Uint32Array(rowCount + 1);
272
+ const types = new Uint8Array(rowCount);
273
+ const runs: CopyRun[] = [];
274
+ let outputByte = 0;
275
+ let currentRun: CopyRun | undefined;
276
+
277
+ for (let i = 0; i < rowCount; i++) {
278
+ const cell = cellsPerKey.get(keys[i])![ci];
279
+ offsets[i] = outputByte;
280
+ if (!cell) {
281
+ types[i] = TYPE_ABSENT_TAG;
282
+ // ABSENT contributes nothing to the data section and breaks any extending run.
283
+ if (currentRun) { runs.push(currentRun); currentRun = undefined; }
284
+ continue;
285
+ }
286
+ types[i] = cell.type;
287
+ sourceCounts.set(cell.sourceIdx, (sourceCounts.get(cell.sourceIdx) ?? 0) + 1);
288
+ // RLE: extend the current run iff the next cell is the next row of the same source (its
289
+ // source-side bytes sit immediately after, so one read suffices) AND non-ABSENT (we never
290
+ // landed in the break-path above).
291
+ if (currentRun && currentRun.sourceIdx === cell.sourceIdx && currentRun.sourceEndRow === cell.sourceRow) {
292
+ currentRun.sourceEndRow = cell.sourceRow + 1;
293
+ currentRun.byteLength += cell.byteLen;
294
+ } else {
295
+ if (currentRun) runs.push(currentRun);
296
+ currentRun = {
297
+ sourceIdx: cell.sourceIdx,
298
+ sourceStartRow: cell.sourceRow,
299
+ sourceEndRow: cell.sourceRow + 1,
300
+ outputByteStart: outputByte,
301
+ byteLength: cell.byteLen,
302
+ };
303
+ }
304
+ outputByte += cell.byteLen;
305
+ }
306
+ if (currentRun) runs.push(currentRun);
307
+ offsets[rowCount] = outputByte;
308
+
309
+ return { name: colName, offsets, types, dataLength: outputByte, runs };
310
+ });
311
+
312
+ // File size estimate: KEY_COLUMN blob + value column blobs + TIME_COLUMN blob + header guess. Used to
313
+ // batch output files; only needs to be close, not exact.
314
+ let estimatedFileBytes = 4 + 2048;
315
+ let keyBytes = 0;
316
+ for (const k of keys) keyBytes += k.length * 2;
317
+ estimatedFileBytes += columnIndexByteLength(rowCount) + keyBytes;
318
+ for (const col of columns) estimatedFileBytes += columnIndexByteLength(rowCount) + col.dataLength;
319
+ estimatedFileBytes += columnIndexByteLength(rowCount) + rowCount * 8;
320
+
321
+ return {
322
+ keys,
323
+ times,
324
+ minKey: keys[0] ?? "",
325
+ maxKey: keys[rowCount - 1] ?? "",
326
+ columns,
327
+ estimatedFileBytes,
328
+ sourceCounts,
329
+ };
330
+ }
331
+
332
+ async function executeBatch(
333
+ plans: PlannedOutputFile[],
334
+ indexesPerSource: Map<string, ColumnIndex>[],
335
+ sources: BaseBulkDatabaseReader[],
336
+ sourceNames: string[],
337
+ collectionName: string,
338
+ writeFile: (data: Buffer) => Promise<{ name: string; size: number }>,
339
+ ): Promise<PlannedMergeOutput[]> {
340
+ // Allocate one big buffer per (output file, column) — offsets + types are written immediately from
341
+ // the plan, the data section is filled below by the per-source copy loop.
342
+ type ColumnBuffer = { name: string; blob: Buffer; dataStart: number };
343
+ const blobsPerFile: ColumnBuffer[][] = plans.map(plan => plan.columns.map(col => {
344
+ const indexSize = columnIndexByteLength(col.offsets.length - 1);
345
+ const blob = Buffer.alloc(indexSize + col.dataLength);
346
+ for (let i = 0; i < col.offsets.length; i++) blob.writeUInt32LE(col.offsets[i], i * 4);
347
+ blob.set(col.types, 4 * col.offsets.length);
348
+ return { name: col.name, blob, dataStart: indexSize };
349
+ }));
350
+
351
+ // For each source, copy its byte runs into every output column whose plan references it. A source
352
+ // that's not referenced by any column in this batch is skipped entirely — that's the point of the
353
+ // batch shape: read 20GB of inputs only once for the whole pass, not once per output.
354
+ for (let si = 0; si < sources.length; si++) {
355
+ let contributes = false;
356
+ for (let fi = 0; fi < plans.length && !contributes; fi++) {
357
+ for (const col of plans[fi].columns) {
358
+ if (col.runs.some(r => r.sourceIdx === si)) { contributes = true; break; }
359
+ }
360
+ }
361
+ if (!contributes) continue;
362
+
363
+ for (let fi = 0; fi < plans.length; fi++) {
364
+ const plan = plans[fi];
365
+ for (let ci = 0; ci < plan.columns.length; ci++) {
366
+ const col = plan.columns[ci];
367
+ const target = blobsPerFile[fi][ci];
368
+ const colIndex = indexesPerSource[si].get(col.name);
369
+ if (!colIndex) continue;
370
+ for (const run of col.runs) {
371
+ if (run.sourceIdx !== si) continue;
372
+ const bytes = await colIndex.readValueBytes(run.sourceStartRow, run.sourceEndRow);
373
+ if (bytes.length !== run.byteLength) {
374
+ throw new Error(`Expected ${run.byteLength} bytes from source #${si} (${sourceNames[si]}) column ${col.name} rows [${run.sourceStartRow}, ${run.sourceEndRow}), got ${bytes.length}`);
375
+ }
376
+ bytes.copy(target.blob, target.dataStart + run.outputByteStart);
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // Assemble + write each output file in this batch.
383
+ const outputs: PlannedMergeOutput[] = [];
384
+ for (let fi = 0; fi < plans.length; fi++) {
385
+ const plan = plans[fi];
386
+ const start = Date.now();
387
+ const valueColumns = blobsPerFile[fi].map(b => ({ name: b.name, blob: b.blob }));
388
+ const fileBuf = assemblePlannedFile({ valueColumns, keys: plan.keys, times: plan.times });
389
+ const { name, size } = await writeFile(fileBuf);
390
+ const elapsed = Date.now() - start;
391
+ const sourcesNamed = new Map<string, number>();
392
+ for (const [si, n] of plan.sourceCounts) sourcesNamed.set(sourceNames[si], n);
393
+ const srcText = [...sourcesNamed.entries()].sort((a, b) => b[1] - a[1]).map(([s, n]) => `${s}:${formatNumber(n)}`).join(", ") || "—";
394
+ console.log(` ${blue(collectionName)} output ${name}: ${formatNumber(plan.keys.length)} rows [${plan.minKey} .. ${plan.maxKey}] from {${srcText}}, ${fmtBytes(size)} in ${formatTime(elapsed)}`);
395
+ outputs.push({ name, minKey: plan.minKey, maxKey: plan.maxKey, rowCount: plan.keys.length, size, sources: sourcesNamed });
396
+ }
397
+ return outputs;
398
+ }
@@ -0,0 +1,71 @@
1
+ import { LoadedIndex } from "./LoadedIndex";
2
+ import { WriteOverlay } from "./WriteOverlay";
3
+ import type { ReactiveDeps } from "./BulkDatabaseBase";
4
+ declare function nullJoin(a: string, b: string): string;
5
+ export type ReaderConfig = {
6
+ name: string;
7
+ deps: ReactiveDeps;
8
+ maxTriggerThrottleMs?: number;
9
+ };
10
+ export declare class BulkDatabaseReader<T extends {
11
+ key: string;
12
+ }> {
13
+ private readonly cfg;
14
+ constructor(cfg: ReaderConfig);
15
+ index: LoadedIndex<T> | undefined;
16
+ readonly overlay: WriteOverlay;
17
+ private dataGen;
18
+ private columnCache;
19
+ private pendingSignals;
20
+ private triggerTimer;
21
+ private currentTriggerDelay;
22
+ private lastTriggerTime;
23
+ get name(): string;
24
+ get deps(): ReactiveDeps;
25
+ get dataGeneration(): number;
26
+ setIndex(newIndex: LoadedIndex<T>, options?: {
27
+ dropStaleFallback?: boolean;
28
+ }): void;
29
+ applyWrite(key: string, row: Record<string, unknown>, time: number): void;
30
+ applyDelete(key: string, time: number): void;
31
+ isKeyWatched(key: string): boolean;
32
+ isLiveNow(key: string): boolean;
33
+ localTime(key: string): number;
34
+ private notifyOverlayMutation;
35
+ getKeys(): Promise<string[]>;
36
+ getColumn<C extends keyof T>(column: C): Promise<{
37
+ key: string;
38
+ value: T[C];
39
+ time: number;
40
+ }[]>;
41
+ getSingleField<C extends keyof T>(key: string, column: C): Promise<T[C] | undefined>;
42
+ getSingleFieldObj<C extends keyof T>(key: string, column: C): Promise<{
43
+ key: string;
44
+ value: T[C];
45
+ time: number;
46
+ } | undefined>;
47
+ getSingleFieldSync<C extends keyof T>(key: string, column: C): T[C] | undefined;
48
+ getSingleFieldObjSync<C extends keyof T>(key: string, column: C): {
49
+ key: string;
50
+ value: T[C];
51
+ time: number;
52
+ } | undefined;
53
+ getColumnSync<C extends keyof T>(column: C): {
54
+ key: string;
55
+ value: T[C];
56
+ time: number;
57
+ }[] | undefined;
58
+ isFieldLoadedSync<C extends keyof T>(key: string, column: C): boolean;
59
+ isColumnLoadedSync<C extends keyof T>(column: C): boolean;
60
+ setEnsureIndex(fn: () => Promise<LoadedIndex<T>>): void;
61
+ private ensureIndexFn;
62
+ private requireIndex;
63
+ private formatInfo;
64
+ private invalidateSignal;
65
+ private flushSignals;
66
+ }
67
+ export declare const READER_SIGNALS: {
68
+ LOAD: string;
69
+ OVERLAY: string;
70
+ };
71
+ export { nullJoin };