vectrojs 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +822 -2
- package/bin/vectro.mjs +82 -0
- package/dist/vectro.wasm +0 -0
- package/mcp/server.mjs +288 -0
- package/package.json +91 -4
- package/src/arrow.mjs +905 -0
- package/src/bitpack.mjs +135 -0
- package/src/browser.mjs +129 -0
- package/src/bytes.mjs +60 -0
- package/src/csv-kernel.mjs +205 -0
- package/src/csv.mjs +270 -0
- package/src/dataset.mjs +1763 -0
- package/src/dates.mjs +139 -0
- package/src/emitter.mjs +109 -0
- package/src/engine.mjs +881 -0
- package/src/files-browser.mjs +104 -0
- package/src/files-node.mjs +59 -0
- package/src/frames.mjs +191 -0
- package/src/fuse.mjs +512 -0
- package/src/interp.mjs +305 -0
- package/src/join.mjs +534 -0
- package/src/kernels.mjs +312 -0
- package/src/ndjson.mjs +136 -0
- package/src/node.mjs +120 -0
- package/src/pack.mjs +114 -0
- package/src/parallel-sort.mjs +99 -0
- package/src/partition-kernels.mjs +388 -0
- package/src/partition.mjs +395 -0
- package/src/plan.mjs +551 -0
- package/src/portable.mjs +21 -0
- package/src/rank-kernels.mjs +81 -0
- package/src/rows.mjs +91 -0
- package/src/serve/catalog.mjs +148 -0
- package/src/serve/flight.mjs +270 -0
- package/src/serve/formats.mjs +87 -0
- package/src/serve/http.mjs +182 -0
- package/src/serve/index.mjs +9 -0
- package/src/serve/odata.mjs +291 -0
- package/src/serve/pg.mjs +588 -0
- package/src/serve/pgcatalog.mjs +220 -0
- package/src/serve/pgtypes.mjs +199 -0
- package/src/sketch.mjs +75 -0
- package/src/sort-kernels.mjs +117 -0
- package/src/sort.mjs +102 -0
- package/src/split-worker-core.mjs +252 -0
- package/src/split-worker-node.mjs +8 -0
- package/src/split-worker.mjs +7 -0
- package/src/split.mjs +741 -0
- package/src/sql.mjs +1558 -0
- package/src/sqltext.mjs +132 -0
- package/src/static-kernels.mjs +164 -0
- package/src/stream-worker.mjs +553 -0
- package/src/table-kernels.mjs +323 -0
- package/src/table.mjs +2206 -0
- package/src/textmap.mjs +159 -0
- package/src/vectors.mjs +138 -0
- package/src/wasm.mjs +254 -0
- package/src/window.mjs +160 -0
- package/src/worker-browser.mjs +21 -0
- package/src/worker-loop.mjs +106 -0
- package/src/worker.mjs +22 -0
- package/src/zones.mjs +75 -0
package/src/split.mjs
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
// Split tables: multi-core analytics without shared memory, for pages that
|
|
2
|
+
// can't be cross-origin isolated. Each worker runs its own engine and owns a
|
|
3
|
+
// slice of every table; the page keeps only a small description (columns,
|
|
4
|
+
// dictionaries, rows per slice). A query sends its expression text to every
|
|
5
|
+
// worker, each one answers for its slice, and the page combines the small
|
|
6
|
+
// partial results:
|
|
7
|
+
// sum, count -> added min, max -> min/max mean -> total sum / total count
|
|
8
|
+
// groupBy -> partial groups merged by key
|
|
9
|
+
// The API matches the async API of normal tables (aggAsync, groupBy().aggAsync,
|
|
10
|
+
// collectAsync, deriveAsync, toRowsAsync), so the same code runs on either.
|
|
11
|
+
//
|
|
12
|
+
// Rows are dealt out in blocks, round-robin (block 0 to worker 0, block 1 to
|
|
13
|
+
// worker 1, ...), not cut into one range per worker. Real data is often
|
|
14
|
+
// sorted (by date, by id), and a filter like "the last month" then matches
|
|
15
|
+
// in a few blocks on every worker instead of all in the last worker, so the
|
|
16
|
+
// filtered table that collect() makes is still spread over every worker.
|
|
17
|
+
// The page remembers rows per block ("segments") to put rows back in order.
|
|
18
|
+
import { Engine as LocalEngine } from './engine.mjs';
|
|
19
|
+
import { inspectCSV } from './csv.mjs';
|
|
20
|
+
import { encode, countByte } from './bytes.mjs';
|
|
21
|
+
import { parse, toSource } from './fuse.mjs';
|
|
22
|
+
import { mergeHits } from './vectors.mjs';
|
|
23
|
+
import { isCoded } from './table.mjs';
|
|
24
|
+
import { readArrow, writeArrow } from './arrow.mjs';
|
|
25
|
+
import { SKETCH_SIZE, sketchMerge, sketchEstimate } from './sketch.mjs';
|
|
26
|
+
import { toMillis } from './dates.mjs';
|
|
27
|
+
|
|
28
|
+
const NL = 10, QUOTE = 34;
|
|
29
|
+
const BLOCK_ROWS = 4096; // rows per block when dealing out arrays
|
|
30
|
+
const CSV_PIECE_BYTES = 64 * 1024, MAX_CSV_PIECES_PER_WORKER = 64;
|
|
31
|
+
const AGG_FNS = new Set(['sum', 'mean', 'min', 'max', 'count', 'countDistinct', 'approxDistinct']);
|
|
32
|
+
|
|
33
|
+
// spawn() must return { post(msg, transfer), onMessage(fn), terminate() } and
|
|
34
|
+
// may add onError(fn): fn(error) when the worker crashes or is killed.
|
|
35
|
+
export class SplitEngine {
|
|
36
|
+
constructor({ workers, spawn, engineOptions = {} }) {
|
|
37
|
+
if (!spawn) throw new Error('vectro: SplitEngine needs a spawn() function (vectro/browser provides one)');
|
|
38
|
+
this.size = Math.max(1, workers);
|
|
39
|
+
this.workers = [];
|
|
40
|
+
this.failed = []; // failed[i]: why worker i stopped (its tables are gone)
|
|
41
|
+
this.pending = new Map(); // rid -> { resolve, reject, worker, op }
|
|
42
|
+
this.rid = 0;
|
|
43
|
+
this.nextTable = 0;
|
|
44
|
+
this.localEngine = null;
|
|
45
|
+
for (let i = 0; i < this.size; i++) {
|
|
46
|
+
const w = spawn();
|
|
47
|
+
w.onMessage((m) => {
|
|
48
|
+
const p = this.pending.get(m.rid);
|
|
49
|
+
if (!p) return;
|
|
50
|
+
this.pending.delete(m.rid);
|
|
51
|
+
if (m.ok) p.resolve(m.value); else p.reject(new Error(m.error));
|
|
52
|
+
});
|
|
53
|
+
w.onError?.((err) => this.#fail(i, err));
|
|
54
|
+
this.workers.push(w);
|
|
55
|
+
}
|
|
56
|
+
this.ready = Promise.all(this.workers.map((_, i) => this._call(i, 'init', { engineOptions })));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A worker died (often: the browser stopped it for using too much memory).
|
|
60
|
+
// Its answers will never come, so everything waiting on it fails now.
|
|
61
|
+
#fail(i, err) {
|
|
62
|
+
if (this.failed[i]) return;
|
|
63
|
+
this.failed[i] = new Error(`vectro: split-table worker ${i} stopped (${err?.message || 'no reason given; the browser may have run out of memory'}). Tables on this engine are incomplete; create a new SplitEngine.`);
|
|
64
|
+
for (const [rid, p] of this.pending) if (p.worker === i) { this.pending.delete(rid); p.reject(this.failed[i]); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_call(i, op, args, transfer = []) {
|
|
68
|
+
if (this.failed[i]) return Promise.reject(this.failed[i]);
|
|
69
|
+
const rid = ++this.rid;
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
this.pending.set(rid, { resolve, reject, worker: i, op });
|
|
72
|
+
try {
|
|
73
|
+
this.workers[i].post({ rid, op, args }, transfer);
|
|
74
|
+
} catch (err) { // e.g. out of memory while copying the message
|
|
75
|
+
this.pending.delete(rid);
|
|
76
|
+
reject(err);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A small single-thread engine on the page for results (group-by tables).
|
|
82
|
+
get local() { return (this.localEngine ??= new LocalEngine()); }
|
|
83
|
+
|
|
84
|
+
// Blocks of rows, dealt to workers round-robin: blocks[w] = [[start, end], ...].
|
|
85
|
+
#blocks(n) {
|
|
86
|
+
const count = Math.max(1, Math.ceil(n / BLOCK_ROWS));
|
|
87
|
+
const blocks = Array.from({ length: Math.min(this.size, count) }, () => []);
|
|
88
|
+
for (let b = 0; b < count; b++) blocks[b % blocks.length].push([b * BLOCK_ROWS, Math.min(n, (b + 1) * BLOCK_ROWS)]);
|
|
89
|
+
return blocks;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build a table from columns: { price: Float64Array | number[], region: string[] }.
|
|
93
|
+
// type: Float32Array stores every column as float32 (half the memory).
|
|
94
|
+
// Text is encoded here once (codes + one dictionary), so only numbers travel
|
|
95
|
+
// to the workers, moved without copying.
|
|
96
|
+
async tableAsync(columns, { type = Float64Array, dates = [] } = {}) {
|
|
97
|
+
await this.ready;
|
|
98
|
+
const dateNames = Object.keys(columns).filter((name) => dates.includes(name) || (Array.isArray(columns[name]) && columns[name].find((v) => v != null) instanceof Date));
|
|
99
|
+
if (dateNames.length) columns = { ...columns, ...Object.fromEntries(dateNames.map((name) => [name, Float64Array.from(columns[name], toMillis)])) };
|
|
100
|
+
const entries = Object.entries(columns);
|
|
101
|
+
const len = (v) => (isCoded(v) ? v.codes.length : v.length);
|
|
102
|
+
const n = entries[0] ? len(entries[0][1]) : 0;
|
|
103
|
+
const text = {};
|
|
104
|
+
for (const [name, v] of entries) {
|
|
105
|
+
if (len(v) !== n) throw new RangeError(`vectro: column "${name}" has ${len(v)} rows, expected ${n}`);
|
|
106
|
+
if (isCoded(v)) { // codes + labels: repeated labels share one code
|
|
107
|
+
const dict = [], index = new Map(), lut = v.labels.map((s) => {
|
|
108
|
+
let c = index.get(s);
|
|
109
|
+
if (c === undefined) { c = dict.length; dict.push(s); index.set(s, c); }
|
|
110
|
+
return c;
|
|
111
|
+
});
|
|
112
|
+
const codes = new Float64Array(n);
|
|
113
|
+
for (let i = 0; i < n; i++) {
|
|
114
|
+
const c = lut[v.codes[i]];
|
|
115
|
+
if (c === undefined) throw new RangeError(`vectro: code ${v.codes[i]} at row ${i} has no label (${v.labels.length} labels)`);
|
|
116
|
+
codes[i] = c;
|
|
117
|
+
}
|
|
118
|
+
text[name] = { codes, dict };
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ArrayBuffer.isView(v) || !v.length || typeof v[0] !== 'string') continue;
|
|
122
|
+
const codes = new Float64Array(v.length), dict = [], index = new Map();
|
|
123
|
+
for (let i = 0; i < v.length; i++) {
|
|
124
|
+
let c = index.get(v[i]);
|
|
125
|
+
if (c === undefined) { c = dict.length; dict.push(v[i]); index.set(v[i], c); }
|
|
126
|
+
codes[i] = c;
|
|
127
|
+
}
|
|
128
|
+
text[name] = { codes, dict };
|
|
129
|
+
}
|
|
130
|
+
const id = ++this.nextTable;
|
|
131
|
+
const blocks = this.#blocks(n);
|
|
132
|
+
const gather = (src, list, Type) => { // this worker's blocks, back to back
|
|
133
|
+
const out = new Type(list.reduce((a, [s, e]) => a + e - s, 0));
|
|
134
|
+
let at = 0;
|
|
135
|
+
for (const [s, e] of list) { out.set(ArrayBuffer.isView(src) ? src.subarray(s, e) : src.slice(s, e), at); at += e - s; }
|
|
136
|
+
return out;
|
|
137
|
+
};
|
|
138
|
+
await Promise.all(blocks.map((list, w) => {
|
|
139
|
+
const numbers = {}, textPart = {}, transfer = [];
|
|
140
|
+
for (const [name, v] of entries) {
|
|
141
|
+
let part;
|
|
142
|
+
if (text[name]) {
|
|
143
|
+
part = gather(text[name].codes, list, Float64Array);
|
|
144
|
+
textPart[name] = { codes: part, dict: text[name].dict };
|
|
145
|
+
} else {
|
|
146
|
+
part = gather(v, list, ArrayBuffer.isView(v) ? v.constructor : Float64Array);
|
|
147
|
+
numbers[name] = part;
|
|
148
|
+
}
|
|
149
|
+
transfer.push(part.buffer);
|
|
150
|
+
}
|
|
151
|
+
return this._call(w, 'loadEncoded', { id, order: entries.map(([name]) => name), numbers, text: textPart, float32: type === Float32Array, dates: dateNames }, transfer);
|
|
152
|
+
}));
|
|
153
|
+
const dicts = Object.fromEntries(Object.entries(text).map(([name, t]) => [name, t.dict]));
|
|
154
|
+
const names = entries.map(([name]) => name);
|
|
155
|
+
const segs = blocks.map((list) => Float64Array.from(list, ([s, e]) => e - s));
|
|
156
|
+
return new SplitTable(this, id, segs, { names, dicts, strings: new Set(), dates: dateNames }, [], {}, true);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Every worker encoded its own text; agree on one dictionary per column
|
|
160
|
+
// (first appearance, slice by slice) and renumber each worker's codes.
|
|
161
|
+
async #shareDictionaries(id, loaded) {
|
|
162
|
+
const shared = {};
|
|
163
|
+
const maps = loaded.map(() => ({}));
|
|
164
|
+
for (const name of Object.keys(loaded[0].dicts ?? {})) {
|
|
165
|
+
const dict = [], codes = new Map();
|
|
166
|
+
loaded.forEach((r, w) => {
|
|
167
|
+
const local = r.dicts[name] ?? [];
|
|
168
|
+
const lut = new Float64Array(Math.max(1, local.length));
|
|
169
|
+
local.forEach((str, i) => {
|
|
170
|
+
let g = codes.get(str);
|
|
171
|
+
if (g === undefined) { g = dict.length; dict.push(str); codes.set(str, g); }
|
|
172
|
+
lut[i] = g;
|
|
173
|
+
});
|
|
174
|
+
maps[w][name] = { lut, dict };
|
|
175
|
+
});
|
|
176
|
+
shared[name] = dict;
|
|
177
|
+
}
|
|
178
|
+
if (Object.keys(shared).length) await Promise.all(maps.map((m, w) => this._call(w, 'remap', { id, maps: m })));
|
|
179
|
+
return shared;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Parse CSV text or bytes: the page cuts it at row boundaries (quote-aware)
|
|
183
|
+
// and every worker parses its piece in parallel.
|
|
184
|
+
// Load Arrow IPC bytes (stream or file format) as a split table.
|
|
185
|
+
async fromArrowAsync(bytes, { columns, type } = {}) {
|
|
186
|
+
const r = readArrow(bytes, { columns });
|
|
187
|
+
return this.tableAsync(r.columns, { type, dates: r.dates });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async parseCSVAsync(text, opts = {}) {
|
|
191
|
+
await this.ready;
|
|
192
|
+
const bytes = typeof text === 'string' ? encode(text) : text;
|
|
193
|
+
const info = inspectCSV(bytes, opts);
|
|
194
|
+
const types = Object.fromEntries(info.names.map((nm, f) => [nm, info.kinds[f]]));
|
|
195
|
+
const head = bytes.subarray(info.headerStart, info.start);
|
|
196
|
+
// Small pieces, dealt round-robin like array blocks. Each worker gets the
|
|
197
|
+
// header plus its pieces back to back (every piece ends at a row end, and
|
|
198
|
+
// the file's last piece, which may not, is the last one of its worker).
|
|
199
|
+
const len = info.end - info.start;
|
|
200
|
+
const pieces = Math.max(1, Math.min(this.size * MAX_CSV_PIECES_PER_WORKER, Math.ceil(len / CSV_PIECE_BYTES)));
|
|
201
|
+
const cuts = cutRows(bytes, info.start, info.end, pieces);
|
|
202
|
+
const workers = Math.min(this.size, cuts.length - 1);
|
|
203
|
+
const mine = Array.from({ length: workers }, () => []);
|
|
204
|
+
for (let p = 0; p + 1 < cuts.length; p++) mine[p % workers].push([cuts[p], cuts[p + 1]]);
|
|
205
|
+
const id = ++this.nextTable;
|
|
206
|
+
const loaded = await Promise.all(mine.map((list, w) => {
|
|
207
|
+
const buf = new Uint8Array(head.length + list.reduce((a, [s, e]) => a + e - s, 0));
|
|
208
|
+
buf.set(head, 0);
|
|
209
|
+
let at = head.length;
|
|
210
|
+
for (const [s, e] of list) { buf.set(bytes.subarray(s, e), at); at += e - s; }
|
|
211
|
+
const sizes = list.map(([s, e]) => e - s);
|
|
212
|
+
return this._call(w, 'loadCSV', { id, bytes: buf, pieces: sizes, opts: { ...opts, types: { ...types, ...(opts.types ?? {}) } } }, [buf.buffer]);
|
|
213
|
+
}));
|
|
214
|
+
const dicts = await this.#shareDictionaries(id, loaded);
|
|
215
|
+
const segs = loaded.map((r) => Float64Array.from(r.segs));
|
|
216
|
+
return new SplitTable(this, id, segs, { names: loaded[0].names, dicts, strings: new Set(loaded[0].strings), dates: loaded[0].dates }, [], {}, true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A live table fed by a stream, spread over the workers. format: 'csv'
|
|
220
|
+
// (lines, no header; columns in the order of the fields), 'ndjson' (one
|
|
221
|
+
// flat JSON object per line; columns pick the keys) or 'vectro' (binary
|
|
222
|
+
// column frames from an Emitter; connect each worker, no parsing).
|
|
223
|
+
// columns: { name: 'number' | 'category' }. keepRows: rows kept in total (the oldest are
|
|
224
|
+
// dropped); keepMs + timeColumn: keep rows newer than the newest time minus
|
|
225
|
+
// keepMs. Feed it with push() from here, or connect() each worker to its own
|
|
226
|
+
// TCP stream (Node), so the data never passes through this thread.
|
|
227
|
+
async streamAsync({ columns, format = 'csv', keepRows = Infinity, keepMs = null, timeColumn = null } = {}) {
|
|
228
|
+
await this.ready;
|
|
229
|
+
if (!columns || !Object.keys(columns).length) throw new Error('vectro: streamAsync needs columns, e.g. { ts: "number", region: "category" }');
|
|
230
|
+
const id = ++this.nextTable;
|
|
231
|
+
const perWorker = Number.isFinite(keepRows) ? Math.ceil(keepRows / this.size) : Infinity;
|
|
232
|
+
await Promise.all(this.workers.map((_, w) => this._call(w, 'streamOpen', { id, columns, format, keepRows: perWorker, keepMs, timeColumn })));
|
|
233
|
+
const t = new StreamTable(this, id, Object.keys(columns), [], {}, true, Object.keys(columns).filter((n) => columns[n] === 'date'));
|
|
234
|
+
t.format = format;
|
|
235
|
+
return t;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async close() {
|
|
239
|
+
for (const w of this.workers) w.terminate();
|
|
240
|
+
this.workers = [];
|
|
241
|
+
await this.localEngine?.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// `parts` + 1 cut points in [start, end), each at the start of a row. With
|
|
246
|
+
// quotes in the data, a newline inside a quoted field is not a row end, so
|
|
247
|
+
// the quotes before each cut are counted first.
|
|
248
|
+
function cutRows(bytes, start, end, parts) {
|
|
249
|
+
const cuts = [start];
|
|
250
|
+
const hasQuotes = bytes.indexOf(QUOTE, start) !== -1;
|
|
251
|
+
let quotes = 0, counted = start;
|
|
252
|
+
for (let k = 1; k < parts; k++) {
|
|
253
|
+
let j = Math.max(cuts[k - 1], start + Math.floor(((end - start) * k) / parts));
|
|
254
|
+
if (hasQuotes) {
|
|
255
|
+
quotes += countByte(bytes, QUOTE, counted, j);
|
|
256
|
+
counted = j;
|
|
257
|
+
let inQuotes = quotes % 2 === 1;
|
|
258
|
+
for (; j < end; j++) {
|
|
259
|
+
const c = bytes[j];
|
|
260
|
+
if (c === QUOTE) inQuotes = !inQuotes;
|
|
261
|
+
else if (c === NL && !inQuotes) break;
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
j = bytes.indexOf(NL, j);
|
|
265
|
+
if (j === -1 || j >= end) j = end;
|
|
266
|
+
}
|
|
267
|
+
cuts.push(Math.min(end, j + 1));
|
|
268
|
+
}
|
|
269
|
+
// strictly increasing, ending at `end` (so no piece is empty unless the data is)
|
|
270
|
+
const out = [start];
|
|
271
|
+
for (const c of cuts.slice(1)) if (c > out[out.length - 1] && c < end) out.push(c);
|
|
272
|
+
out.push(end);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 'mean(price)' -> what to ask each worker for, and how to combine answers.
|
|
277
|
+
// A mean becomes a sum and a count (hidden names __np_sum_<out>, __np_n_<out>).
|
|
278
|
+
// Distinct counts don't merge as numbers: they are asked for separately
|
|
279
|
+
// (outs entries with `arg`), as value sets or sketches (see #distinct).
|
|
280
|
+
function plan(spec) {
|
|
281
|
+
const ask = {};
|
|
282
|
+
const outs = Object.entries(spec).map(([out, src]) => {
|
|
283
|
+
const ast = parse(src);
|
|
284
|
+
if (ast.t !== 'call' || !AGG_FNS.has(ast.fn) || ast.args.length > 1 || (ast.fn !== 'count' && ast.args.length !== 1)) {
|
|
285
|
+
throw new SyntaxError(`vectro: an aggregation must look like sum(expr), mean(expr), min(expr), max(expr), count([expr]) or countDistinct(expr), got "${src}"`);
|
|
286
|
+
}
|
|
287
|
+
if (ast.fn === 'countDistinct' || ast.fn === 'approxDistinct') return { out, fn: ast.fn, arg: toSource(ast.args[0]) };
|
|
288
|
+
if (ast.fn === 'mean') {
|
|
289
|
+
const arg = toSource(ast.args[0]);
|
|
290
|
+
ask[`__np_sum_${out}`] = `sum(${arg})`;
|
|
291
|
+
ask[`__np_n_${out}`] = `count((${arg}) == (${arg}))`; // the values present (missing ones don't count)
|
|
292
|
+
return { out, fn: 'mean' };
|
|
293
|
+
}
|
|
294
|
+
ask[out] = src;
|
|
295
|
+
return { out, fn: ast.fn };
|
|
296
|
+
});
|
|
297
|
+
return { ask, outs };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// One string per key tuple: numbers as text (NaN, -0 = 0, Infinity apart),
|
|
301
|
+
// text JSON-quoted, so no two tuples collide.
|
|
302
|
+
const tupleId = (tuple) => tuple.map((v) => (typeof v === 'string' ? JSON.stringify(v) : String(v === 0 ? 0 : v))).join(',');
|
|
303
|
+
|
|
304
|
+
// min / max start from NaN (nothing yet), and a part's NaN (no values) is skipped
|
|
305
|
+
const START = { sum: 0, count: 0, min: NaN, max: NaN };
|
|
306
|
+
const COMBINE = { sum: (a, b) => a + b, count: (a, b) => a + b, min: (a, b) => (b < a || a !== a ? b : a), max: (a, b) => (b > a || a !== a ? b : a) };
|
|
307
|
+
|
|
308
|
+
// Table order is round by round: block 0 of every worker (worker 0 first),
|
|
309
|
+
// then block 1 of every worker, and so on. Calls fn(worker, block, rows).
|
|
310
|
+
function inOrder(segs, fn) {
|
|
311
|
+
const rounds = Math.max(0, ...segs.map((s) => s.length));
|
|
312
|
+
for (let r = 0; r < rounds; r++) {
|
|
313
|
+
for (let w = 0; w < segs.length; w++) if (r < segs[w].length && fn(w, r, segs[w][r]) === false) return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Workers' columns, interleaved back into table order by their segments.
|
|
318
|
+
// Category labels are merged (stream workers each have their own).
|
|
319
|
+
function mergeColumns(parts) {
|
|
320
|
+
const total = parts.reduce((a, p) => a + p.segs.reduce((x, y) => x + y, 0), 0);
|
|
321
|
+
const names = Object.keys(parts[0]?.columns ?? {});
|
|
322
|
+
const out = {}, luts = {};
|
|
323
|
+
for (const name of names) {
|
|
324
|
+
const first = parts[0].columns[name];
|
|
325
|
+
if (ArrayBuffer.isView(first)) out[name] = new first.constructor(total);
|
|
326
|
+
else if (first.codes) {
|
|
327
|
+
const labels = [], index = new Map();
|
|
328
|
+
luts[name] = parts.map((p) => Uint32Array.from(p.columns[name].labels, (s) => {
|
|
329
|
+
let g = index.get(s);
|
|
330
|
+
if (g === undefined) { g = labels.length; labels.push(s); index.set(s, g); }
|
|
331
|
+
return g;
|
|
332
|
+
}));
|
|
333
|
+
out[name] = { codes: new Uint32Array(total), labels };
|
|
334
|
+
} else out[name] = [];
|
|
335
|
+
}
|
|
336
|
+
let at = 0;
|
|
337
|
+
const next = parts.map(() => 0);
|
|
338
|
+
inOrder(parts.map((p) => p.segs), (w, _, k) => {
|
|
339
|
+
const from = next[w];
|
|
340
|
+
for (const name of names) {
|
|
341
|
+
const src = parts[w].columns[name], dst = out[name];
|
|
342
|
+
if (ArrayBuffer.isView(dst)) dst.set(src.subarray(from, from + k), at);
|
|
343
|
+
else if (dst.codes) { const lut = luts[name][w]; for (let i = 0; i < k; i++) dst.codes[at + i] = lut[src.codes[from + i]]; }
|
|
344
|
+
else for (let i = 0; i < k; i++) dst.push(src[from + i]);
|
|
345
|
+
}
|
|
346
|
+
next[w] += k;
|
|
347
|
+
at += k;
|
|
348
|
+
});
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export class SplitTable {
|
|
353
|
+
constructor(engine, id, segs, meta, preds, params, owned) {
|
|
354
|
+
this.engine = engine;
|
|
355
|
+
this.id = id;
|
|
356
|
+
this.segs = segs; // segs[w][k]: rows in worker w's k-th block (see inOrder)
|
|
357
|
+
this.counts = segs.map((s) => s.reduce((a, b) => a + b, 0)); // rows per worker
|
|
358
|
+
this.meta = meta; // { names, dicts: { text column: shared dictionary }, strings }
|
|
359
|
+
this.preds = preds; // filters, as { expr, params }, applied by the workers
|
|
360
|
+
this.params = params;
|
|
361
|
+
this.owned = owned; // false for filter views of another table
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
get length() { return this.counts.reduce((a, b) => a + b, 0); } // rows before filtering
|
|
365
|
+
get columnNames() { return this.meta.names; }
|
|
366
|
+
|
|
367
|
+
#all(op, args, withSegs = false) {
|
|
368
|
+
return Promise.all(this.counts.map((_, w) => this.engine._call(w, op, { id: this.id, preds: this.preds, ...args, ...(withSegs ? { segs: this.segs[w] } : {}) })));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Lazy filter (applied by the workers on every query).
|
|
372
|
+
filter(expr, params = {}) {
|
|
373
|
+
return new SplitTable(this.engine, this.id, this.segs, this.meta, [...this.preds, { expr, params }], { ...this.params, ...params }, false);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async aggAsync(spec, params = {}) {
|
|
377
|
+
const { ask, outs } = plan(spec);
|
|
378
|
+
const parts = Object.keys(ask).length ? await this.#all('agg', { spec: ask, params }) : [];
|
|
379
|
+
const distinct = {};
|
|
380
|
+
for (const o of outs) if (o.arg) distinct[o.out] = (await this.#distinct([], o, params)).get('') ?? 0;
|
|
381
|
+
return Object.fromEntries(outs.map(({ out, fn }) => {
|
|
382
|
+
if (out in distinct) return [out, distinct[out]];
|
|
383
|
+
if (fn === 'mean') {
|
|
384
|
+
const s = parts.reduce((a, p) => a + p[`__np_sum_${out}`], 0);
|
|
385
|
+
const n = parts.reduce((a, p) => a + p[`__np_n_${out}`], 0);
|
|
386
|
+
return [out, s / n];
|
|
387
|
+
}
|
|
388
|
+
return [out, parts.reduce((a, p) => COMBINE[fn](a, p[out]), START[fn])];
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async sumAsync(expr, params) { return (await this.aggAsync({ v: `sum(${expr})` }, params)).v; }
|
|
393
|
+
async meanAsync(expr, params) { return (await this.aggAsync({ v: `mean(${expr})` }, params)).v; }
|
|
394
|
+
async minAsync(expr, params) { return (await this.aggAsync({ v: `min(${expr})` }, params)).v; }
|
|
395
|
+
async maxAsync(expr, params) { return (await this.aggAsync({ v: `max(${expr})` }, params)).v; }
|
|
396
|
+
async countAsync(expr, params) { return (await this.aggAsync({ v: expr ? `count(${expr})` : 'count()' }, params)).v; }
|
|
397
|
+
|
|
398
|
+
groupBy(key) {
|
|
399
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
400
|
+
if (!keys.length) throw new Error('vectro: groupBy needs at least one key column');
|
|
401
|
+
for (const k of keys) if (!this.meta.names.includes(k)) throw new Error(`vectro: no column "${k}"`);
|
|
402
|
+
return { aggAsync: (spec, params = {}) => this.#groupAgg(keys, spec, params) };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Distinct counts per group key id (see tupleId): countDistinct merges
|
|
406
|
+
// every worker's distinct values exactly; approxDistinct merges sketches.
|
|
407
|
+
async #distinct(keys, { fn, arg }, params) {
|
|
408
|
+
const sketch = fn === 'approxDistinct';
|
|
409
|
+
const parts = await this.#all('distinct', { keys, arg, params, sketch });
|
|
410
|
+
const out = new Map();
|
|
411
|
+
if (sketch) {
|
|
412
|
+
const merged = new Map();
|
|
413
|
+
for (const { keyCols, sketches } of parts) {
|
|
414
|
+
const rows = keys.length ? keyCols[0].length : sketches.length / SKETCH_SIZE;
|
|
415
|
+
for (let r = 0; r < rows; r++) {
|
|
416
|
+
const id = tupleId(keyCols.map((c) => c[r]));
|
|
417
|
+
let regs = merged.get(id);
|
|
418
|
+
if (!regs) merged.set(id, (regs = new Uint8Array(SKETCH_SIZE)));
|
|
419
|
+
sketchMerge(regs, 0, sketches, r * SKETCH_SIZE);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const [id, regs] of merged) out.set(id, sketchEstimate(regs));
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
const sets = new Map();
|
|
426
|
+
for (const { keyCols, values } of parts) {
|
|
427
|
+
for (let r = 0; r < values.length; r++) {
|
|
428
|
+
const x = values[r];
|
|
429
|
+
if (x !== x) continue; // NaN: not a value
|
|
430
|
+
const id = tupleId(keyCols.map((c) => c[r]));
|
|
431
|
+
let set = sets.get(id);
|
|
432
|
+
if (!set) sets.set(id, (set = new Set()));
|
|
433
|
+
set.add(typeof x === 'string' ? `s${x}` : x === 0 ? 0 : x);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const [id, set] of sets) out.set(id, set.size);
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Each worker groups its slice; partial groups are merged here by key and
|
|
441
|
+
// returned as a normal (small) table on the page.
|
|
442
|
+
async #groupAgg(keys, spec, params) {
|
|
443
|
+
const { ask, outs } = plan(spec);
|
|
444
|
+
if (!Object.keys(ask).length) ask.__np_rows = 'count()'; // (groups come from the main pass)
|
|
445
|
+
const askKeys = Object.keys(ask);
|
|
446
|
+
const parts = await this.#all('groupBy', { keys, spec: ask, params });
|
|
447
|
+
const index = new Map();
|
|
448
|
+
const acc = askKeys.map(() => []);
|
|
449
|
+
const fnOf = askKeys.map((k) => outs.find((o) => o.out === k)?.fn ?? (k.startsWith('__np_n_') ? 'count' : 'sum'));
|
|
450
|
+
const groups = []; // key tuples
|
|
451
|
+
const one = keys.length === 1;
|
|
452
|
+
for (const { keyCols, cols } of parts) {
|
|
453
|
+
const rows = keyCols[0].length;
|
|
454
|
+
for (let r = 0; r < rows; r++) {
|
|
455
|
+
const tuple = keyCols.map((c) => c[r]);
|
|
456
|
+
const id = one ? tuple[0] : tupleId(tuple); // exact: NaN, -0, Infinity kept apart
|
|
457
|
+
let g = index.get(id);
|
|
458
|
+
if (g === undefined) {
|
|
459
|
+
g = groups.length;
|
|
460
|
+
index.set(id, g);
|
|
461
|
+
groups.push(tuple);
|
|
462
|
+
askKeys.forEach((_, j) => acc[j].push(START[fnOf[j]]));
|
|
463
|
+
}
|
|
464
|
+
askKeys.forEach((k, j) => { acc[j][g] = COMBINE[fnOf[j]](acc[j][g], cols[k][r]); });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Same group order as a normal table: dictionary order for text, numbers
|
|
468
|
+
// ascending (NaN last), earlier keys first. Streams have no shared
|
|
469
|
+
// dictionary: their labels sort A to Z.
|
|
470
|
+
const ranks = keys.map((k) => { const d = this.meta.dicts[k]; return d ? new Map(d.map((s, i) => [s, i])) : null; });
|
|
471
|
+
const cmp = (x, y, rank) => {
|
|
472
|
+
if (rank) { x = rank.get(x); y = rank.get(y); }
|
|
473
|
+
if (x === y) return 0;
|
|
474
|
+
if (typeof x === 'number' && x !== x) return y !== y ? 0 : 1;
|
|
475
|
+
if (typeof y === 'number' && y !== y) return -1;
|
|
476
|
+
return x < y ? -1 : 1;
|
|
477
|
+
};
|
|
478
|
+
const order = groups.map((_, g) => g).sort((a, b) => {
|
|
479
|
+
for (let k = 0; k < keys.length; k++) { const c = cmp(groups[a][k], groups[b][k], ranks[k]); if (c) return c; }
|
|
480
|
+
return 0;
|
|
481
|
+
});
|
|
482
|
+
const col = (k) => order.map((g) => acc[askKeys.indexOf(k)][g]);
|
|
483
|
+
const columns = {};
|
|
484
|
+
keys.forEach((key, k) => { columns[key] = order.map((g) => groups[g][k]); });
|
|
485
|
+
for (const o of outs) {
|
|
486
|
+
if (!o.arg) continue;
|
|
487
|
+
const counts = await this.#distinct(keys, o, params);
|
|
488
|
+
columns[o.out] = order.map((g) => counts.get(tupleId(groups[g])) ?? 0);
|
|
489
|
+
}
|
|
490
|
+
for (const { out, fn, arg } of outs) {
|
|
491
|
+
if (arg) continue;
|
|
492
|
+
if (fn === 'mean') {
|
|
493
|
+
const s = col(`__np_sum_${out}`), n = col(`__np_n_${out}`);
|
|
494
|
+
columns[out] = s.map((v, i) => v / n[i]);
|
|
495
|
+
} else columns[out] = col(out);
|
|
496
|
+
}
|
|
497
|
+
return this.engine.local.table(columns, { dates: keys.filter((k) => this.meta.dates?.includes(k)) });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The filtered rows as a new split table: each worker keeps its own
|
|
501
|
+
// matches, which stay spread out because blocks are dealt round-robin.
|
|
502
|
+
async collectAsync() {
|
|
503
|
+
if (!this.preds.length) return this;
|
|
504
|
+
const newId = ++this.engine.nextTable;
|
|
505
|
+
const parts = await this.#all('collect', { newId }, true);
|
|
506
|
+
return new SplitTable(this.engine, newId, parts.map((p) => p.segs), this.meta, [], {}, true);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async deriveAsync(name, expr, params = {}) {
|
|
510
|
+
const newId = ++this.engine.nextTable;
|
|
511
|
+
await Promise.all(this.counts.map((_, w) => this.engine._call(w, 'derive', { id: this.id, newId, name, expr, params })));
|
|
512
|
+
return new SplitTable(this.engine, newId, this.segs, { ...this.meta, names: [...this.meta.names, name] }, this.preds, this.params, true);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The first k rows by `by` (see Table.top), as a normal table on the page:
|
|
516
|
+
// every worker sends its own first k, and the page merges them. Rows that
|
|
517
|
+
// tie on every key come in worker order.
|
|
518
|
+
// columns: return only these (and read only these), e.g. for wide tables.
|
|
519
|
+
async topAsync(k, by, { columns = null } = {}) {
|
|
520
|
+
const parts = await this.#all('top', { k, by, columns });
|
|
521
|
+
const keys = parts[0]?.keys ?? [];
|
|
522
|
+
const cand = [];
|
|
523
|
+
parts.forEach((p, w) => p.rows.forEach((row, i) => cand.push({ row, w, i, vals: p.values.map((v) => v && v[i]) })));
|
|
524
|
+
const cmp = (a, b) => {
|
|
525
|
+
for (let j = 0; j < keys.length; j++) {
|
|
526
|
+
if (keys[j]) { // text: by label
|
|
527
|
+
const x = a.row[keys[j].label], y = b.row[keys[j].label];
|
|
528
|
+
if (x !== y) return (x < y ? -1 : 1) * (keys[j].desc ? -1 : 1);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const x = a.vals[j], y = b.vals[j];
|
|
532
|
+
if (x !== y) {
|
|
533
|
+
if (x !== x) return 1; // NaN last
|
|
534
|
+
if (y !== y) return -1;
|
|
535
|
+
return x < y ? -1 : 1;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return a.w - b.w || a.i - b.i;
|
|
539
|
+
};
|
|
540
|
+
const rows = cand.sort(cmp).slice(0, k).map((c) => c.row);
|
|
541
|
+
const names = rows.length ? Object.keys(rows[0]) : (columns ?? this.meta.names);
|
|
542
|
+
return this.engine.local.table(Object.fromEntries(names.map((name) => [name, rows.map((r) => r[name])])));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Vector search (see VectorIndex): each worker searches its own rows in one
|
|
546
|
+
// round trip; the page keeps the best k of all workers' hits.
|
|
547
|
+
async vectorSearchAsync(query, opts) {
|
|
548
|
+
const lists = await this.#all('vectorTop', { query, opts });
|
|
549
|
+
return mergeHits(lists, opts.k, opts.metric);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Each worker sends its first `limit` rows (in its order) and the matches
|
|
553
|
+
// per block; walking the blocks in table order interleaves them back.
|
|
554
|
+
async toRowsAsync(limit = Infinity) {
|
|
555
|
+
const parts = await this.#all('toRows', { limit: Number.isFinite(limit) ? limit : undefined }, true);
|
|
556
|
+
const rows = [], next = parts.map(() => 0);
|
|
557
|
+
inOrder(parts.map((p) => p.segs), (w, _, n) => {
|
|
558
|
+
for (let i = 0; i < n && rows.length < limit; i++) rows.push(parts[w].rows[next[w]++]);
|
|
559
|
+
return rows.length < limit;
|
|
560
|
+
});
|
|
561
|
+
return rows;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// The rows (matching the filters) as plain columns on the page, in table
|
|
565
|
+
// order (see Table.toColumns).
|
|
566
|
+
async toColumnsAsync() {
|
|
567
|
+
return mergeColumns(await this.#all('columns', {}, true));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The rows (matching the filters) as Arrow IPC bytes (see Table.toArrow).
|
|
571
|
+
async toArrowAsync({ file = false } = {}) { return writeArrow(await this.toColumnsAsync(), { file, dates: this.meta.dates ?? [] }); }
|
|
572
|
+
|
|
573
|
+
async dispose() {
|
|
574
|
+
if (!this.owned) return;
|
|
575
|
+
this.owned = false;
|
|
576
|
+
await this.#all('dispose', {});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// A stream: queries see the rows each worker has parsed so far (call
|
|
581
|
+
// flushAsync() first to include rows still waiting in a batch). Rows have no
|
|
582
|
+
// global order across workers, so toRowsAsync returns worker 0's rows first.
|
|
583
|
+
export class StreamTable extends SplitTable {
|
|
584
|
+
constructor(engine, id, names, preds = [], params = {}, owned = true, dates = []) {
|
|
585
|
+
super(engine, id, engine.workers.map(() => Float64Array.of(Infinity)), { names, dicts: {}, strings: new Set(), dates }, preds, params, owned);
|
|
586
|
+
this.partial = null;
|
|
587
|
+
this.next = 0; // worker for the next pushed chunk
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
get length() { return NaN; } // changes all the time: use countAsync()
|
|
591
|
+
|
|
592
|
+
// A filtered view of the live stream (still live).
|
|
593
|
+
filter(expr, params = {}) {
|
|
594
|
+
const t = new StreamTable(this.engine, this.id, this.meta.names, [...this.preds, { expr, params }], { ...this.params, ...params }, false, this.meta.dates);
|
|
595
|
+
t.format = this.format;
|
|
596
|
+
return t;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async deriveAsync() {
|
|
600
|
+
throw new Error('vectro: deriveAsync needs fixed rows; call snapshotAsync() (or collectAsync()) on the stream first');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Bytes of CSV lines (no header), cut anywhere. Whole lines go to the
|
|
604
|
+
// workers in turn; the promise resolves once the worker has queued them.
|
|
605
|
+
push(chunk) {
|
|
606
|
+
if (this.format === 'vectro' || this.format === 'arrow') return Promise.reject(new Error("vectro: binary streams take frames over connections: connect each worker to the sender (push() is for 'csv' and 'ndjson')"));
|
|
607
|
+
const bytes = typeof chunk === 'string' ? encode(chunk) : chunk;
|
|
608
|
+
const last = bytes.lastIndexOf(NL);
|
|
609
|
+
if (last === -1) { this.partial = this.partial ? join(this.partial, bytes) : Uint8Array.prototype.slice.call(bytes); return Promise.resolve(); }
|
|
610
|
+
const lines = this.partial ? join(this.partial, bytes.subarray(0, last + 1)) : Uint8Array.prototype.slice.call(bytes, 0, last + 1);
|
|
611
|
+
this.partial = last + 1 < bytes.length ? Uint8Array.prototype.slice.call(bytes, last + 1) : null;
|
|
612
|
+
const w = this.next;
|
|
613
|
+
this.next = (this.next + 1) % this.counts.length;
|
|
614
|
+
return this.engine._call(w, 'streamPush', { id: this.id, bytes: lines }, [lines.buffer]);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Worker `worker` opens its own connection and reads lines from it:
|
|
618
|
+
// { url: 'wss://…' } for a WebSocket (browsers, Node 22+; each message is
|
|
619
|
+
// whole lines), or { host, port } for raw TCP (Node).
|
|
620
|
+
connect(worker, { url, host = '127.0.0.1', port } = {}) {
|
|
621
|
+
return this.engine._call(worker, 'streamConnect', { id: this.id, url, host, port });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Parse whatever every worker has received; returns totals.
|
|
625
|
+
async flushAsync() {
|
|
626
|
+
if (this.partial) { const p = join(this.partial, Uint8Array.of(NL)); this.partial = null; await this.push(p); }
|
|
627
|
+
return total(await Promise.all(this.counts.map((_, w) => this.engine._call(w, 'streamFlush', { id: this.id }))));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async statsAsync() {
|
|
631
|
+
return total(await Promise.all(this.counts.map((_, w) => this.engine._call(w, 'streamStats', { id: this.id }))));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// A fixed copy of the rows kept right now (matching the filters, if any),
|
|
635
|
+
// as a normal split table.
|
|
636
|
+
async snapshotAsync() {
|
|
637
|
+
if (this.preds.length) return super.collectAsync();
|
|
638
|
+
const newId = ++this.engine.nextTable;
|
|
639
|
+
const counts = await Promise.all(this.counts.map((_, w) => this.engine._call(w, 'streamSnapshot', { id: this.id, newId })));
|
|
640
|
+
return new SplitTable(this.engine, newId, counts.map((n) => Float64Array.of(n)), this.meta, [], {}, true);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async collectAsync() { return this.snapshotAsync(); }
|
|
644
|
+
|
|
645
|
+
async toColumnsAsync() {
|
|
646
|
+
const snap = await this.snapshotAsync();
|
|
647
|
+
try { return await snap.toColumnsAsync(); } finally { await snap.dispose(); }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async toRowsAsync(limit = Infinity) {
|
|
651
|
+
const parts = await Promise.all(this.counts.map((_, w) => this.engine._call(w, 'toRows', { id: this.id, preds: this.preds, limit: Number.isFinite(limit) ? limit : undefined, segs: [Infinity] })));
|
|
652
|
+
return parts.flatMap((p) => p.rows).slice(0, limit);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Running totals over a time window, kept up to date as rows arrive:
|
|
656
|
+
// const w = await orders.windowAsync({ by: 'region', every: 1000, last: 300_000,
|
|
657
|
+
// timeColumn: 'ts', agg: { revenue: 'sum(price * qty)', orders: 'count()' } });
|
|
658
|
+
// await w.readAsync(); // per region, over the last 5 minutes
|
|
659
|
+
// await w.readAsync({ series: true }); // per second and region, for charts
|
|
660
|
+
// Workers keep one small total per (slice of `every` ms, group), so reading
|
|
661
|
+
// costs nothing like a scan, and totals outlive rows dropped by keepRows.
|
|
662
|
+
// The window ends at the newest time seen and covers whole slices.
|
|
663
|
+
async windowAsync({ by = null, every, last, timeColumn = null, agg }) {
|
|
664
|
+
const { ask, outs } = plan(agg);
|
|
665
|
+
const exact = outs.find((o) => o.fn === 'countDistinct');
|
|
666
|
+
if (exact) throw new Error(`vectro: windows keep totals, not rows: use approxDistinct(${exact.arg}) for "${exact.out}" (within ~1.6%)`);
|
|
667
|
+
const sketches = outs.filter((o) => o.arg).map(({ out, arg }) => ({ out, arg }));
|
|
668
|
+
if (!Object.keys(ask).length) ask.__np_rows = 'count()';
|
|
669
|
+
const fns = Object.keys(ask).map((k) => outs.find((o) => o.out === k)?.fn ?? (k.startsWith('__np_n_') ? 'count' : 'sum'));
|
|
670
|
+
const wid = ++this.engine.nextTable;
|
|
671
|
+
await Promise.all(this.counts.map((_, w) => this.engine._call(w, 'streamWindow', { id: this.id, wid, by, every, last, timeColumn, ask, fns, sketches })));
|
|
672
|
+
return new LiveWindow(this, wid, { by, every, slices: Math.ceil(last / every), ask, fns, outs, sketches });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
class LiveWindow {
|
|
677
|
+
constructor(stream, wid, spec) { this.stream = stream; this.wid = wid; this.spec = spec; }
|
|
678
|
+
|
|
679
|
+
async readAsync({ series = false } = {}) {
|
|
680
|
+
const { by, every, slices, ask, fns, outs } = this.spec;
|
|
681
|
+
const engine = this.stream.engine, id = this.stream.id;
|
|
682
|
+
const parts = await Promise.all(this.stream.counts.map((_, w) => engine._call(w, 'streamWindowRead', { id, wid: this.wid })));
|
|
683
|
+
const newest = Math.max(-Infinity, ...parts.flat().map((r) => r[0]));
|
|
684
|
+
const askNames = Object.keys(ask);
|
|
685
|
+
const merged = new Map(); // series: slice\0group, totals: group
|
|
686
|
+
for (const part of parts) {
|
|
687
|
+
for (const [slice, group, ...values] of part) {
|
|
688
|
+
if (slice <= newest - slices) continue;
|
|
689
|
+
const key = series ? `${slice}\u0000${group}` : group;
|
|
690
|
+
const acc = merged.get(key);
|
|
691
|
+
if (!acc) { merged.set(key, { slice, group, values: values.slice() }); continue; }
|
|
692
|
+
values.forEach((v, j) => { acc.values[j] = COMBINE[fns[j]](acc.values[j], v); });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// distinct counts: sketches of the same slices, merged across workers
|
|
696
|
+
const { sketches } = this.spec, distinct = new Map();
|
|
697
|
+
if (sketches.length) {
|
|
698
|
+
const J = sketches.length;
|
|
699
|
+
const sk = await Promise.all(this.stream.counts.map((_, w) => engine._call(w, 'streamWindowSketches', { id, wid: this.wid, since: newest - slices + 1, series })));
|
|
700
|
+
const regsOf = new Map();
|
|
701
|
+
for (const { keys, sketches: bytes } of sk) {
|
|
702
|
+
keys.forEach(([slice, group], i) => {
|
|
703
|
+
const key = series ? `${slice}\u0000${group}` : group;
|
|
704
|
+
let regs = regsOf.get(key);
|
|
705
|
+
if (!regs) regsOf.set(key, (regs = new Uint8Array(J * SKETCH_SIZE)));
|
|
706
|
+
for (let j = 0; j < J; j++) sketchMerge(regs, j * SKETCH_SIZE, bytes, (i * J + j) * SKETCH_SIZE);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
for (const [key, regs] of regsOf) distinct.set(key, sketches.map((_, j) => sketchEstimate(regs, j * SKETCH_SIZE)));
|
|
710
|
+
}
|
|
711
|
+
const rows = [...merged.entries()].map(([key, { slice, group, values }]) => {
|
|
712
|
+
const row = series ? { t: slice * every } : {};
|
|
713
|
+
if (by) row[by] = group;
|
|
714
|
+
const val = (name) => values[askNames.indexOf(name)];
|
|
715
|
+
for (const { out, fn, arg } of outs) {
|
|
716
|
+
if (arg) row[out] = distinct.get(key)?.[sketches.findIndex((x) => x.out === out)] ?? 0;
|
|
717
|
+
else row[out] = fn === 'mean' ? val(`__np_sum_${out}`) / val(`__np_n_${out}`) : val(out);
|
|
718
|
+
}
|
|
719
|
+
return row;
|
|
720
|
+
});
|
|
721
|
+
const label = (r) => (by ? r[by] : '');
|
|
722
|
+
return rows.sort((a, b) => (series && a.t !== b.t ? a.t - b.t : label(a) < label(b) ? -1 : label(a) > label(b) ? 1 : 0));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async dispose() {
|
|
726
|
+
await Promise.all(this.stream.counts.map((_, w) => this.stream.engine._call(w, 'streamWindowClose', { id: this.stream.id, wid: this.wid })));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function join(a, b) {
|
|
731
|
+
const out = new Uint8Array(a.length + b.length);
|
|
732
|
+
out.set(a, 0);
|
|
733
|
+
out.set(b, a.length);
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function total(list) {
|
|
738
|
+
const out = { rows: 0, rowsIn: 0, badLines: 0, bytesIn: 0, batches: 0, unparsedBytes: 0, perWorker: list.map((s) => s.rows), errors: list.map((s) => s.error).filter(Boolean) };
|
|
739
|
+
for (const s of list) for (const k of ['rows', 'rowsIn', 'badLines', 'bytesIn', 'batches', 'unparsedBytes']) out[k] += s[k];
|
|
740
|
+
return out;
|
|
741
|
+
}
|