vectrojs 0.0.1 → 0.1.1
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 +89 -0
- package/README.md +828 -4
- package/bin/vectro.mjs +82 -0
- package/dist/vectro.wasm +0 -0
- package/mcp/server.mjs +288 -0
- package/package.json +84 -6
- 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 +2247 -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
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// Worker side of streams. Rows arrive as CSV lines without a header, or as
|
|
2
|
+
// JSON lines (format: 'ndjson', one flat object per line), either
|
|
3
|
+
// from the page (streamPush) or from this worker's own TCP connection
|
|
4
|
+
// (streamConnect). They are parsed in batches of about 1 MB with the normal
|
|
5
|
+
// CSV parser and appended to growing columns in engine memory. Queries get a
|
|
6
|
+
// Table over the rows still kept (see keepRows / keepMs), so every split-table
|
|
7
|
+
// query (agg, groupBy, collect, toRows) works on a live stream unchanged.
|
|
8
|
+
import { Table } from './table.mjs';
|
|
9
|
+
import { parseNDJSON } from './ndjson.mjs';
|
|
10
|
+
import { ZONE_ROWS } from './zones.mjs';
|
|
11
|
+
import { FrameReader } from './frames.mjs';
|
|
12
|
+
import { ArrowReader } from './arrow.mjs';
|
|
13
|
+
import { parse, isMask } from './fuse.mjs';
|
|
14
|
+
import { SKETCH_SIZE, sketchAdd, sketchMerge, hashNumber, hashText } from './sketch.mjs';
|
|
15
|
+
|
|
16
|
+
const BATCH_BYTES = 256 * 1024; // parse once this much has arrived...
|
|
17
|
+
const FLUSH_MS = 20; // ...or this long after the first unparsed line
|
|
18
|
+
// Parsing happens in its own event-loop turn, one batch per turn, never while
|
|
19
|
+
// receiving. A query that arrives meanwhile runs before the next batch, so it
|
|
20
|
+
// waits for at most one batch (a few ms), not for everything queued.
|
|
21
|
+
const nextTurn = globalThis.setImmediate ?? ((fn) => setTimeout(fn, 0));
|
|
22
|
+
// Backpressure: with more than HIGH bytes waiting to be parsed, sockets pause
|
|
23
|
+
// and push replies wait, until the backlog is down to LOW. TCP then slows the
|
|
24
|
+
// sender, instead of unparsed data piling up in memory.
|
|
25
|
+
const HIGH = 4 << 20, LOW = 1 << 20;
|
|
26
|
+
const NL = 10;
|
|
27
|
+
const textEncoder = new TextEncoder();
|
|
28
|
+
const KINDS = { number: 'number', category: 'category', date: 'date' }; // date: ms (ISO text in CSV / JSON lines)
|
|
29
|
+
|
|
30
|
+
export function createStreams(getEngine) {
|
|
31
|
+
const streams = new Map(); // id -> stream state
|
|
32
|
+
|
|
33
|
+
function open({ id, columns, format = 'csv', keepRows = Infinity, keepMs = null, timeColumn = null }) {
|
|
34
|
+
if (!['csv', 'ndjson', 'vectro', 'arrow'].includes(format)) throw new Error(`vectro: stream format must be 'csv', 'ndjson', 'vectro' or 'arrow', got "${format}"`);
|
|
35
|
+
const engine = getEngine();
|
|
36
|
+
const names = Object.keys(columns);
|
|
37
|
+
for (const [name, kind] of Object.entries(columns)) {
|
|
38
|
+
if (!KINDS[kind]) throw new Error(`vectro: stream column "${name}" must be 'number', 'category' or 'date', got "${kind}"`);
|
|
39
|
+
}
|
|
40
|
+
if (keepMs != null && !names.includes(timeColumn)) throw new Error('vectro: keepMs needs timeColumn, the name of a number column holding milliseconds');
|
|
41
|
+
const cols = new Map(names.map((name) => [name, {
|
|
42
|
+
data: engine.alloc(Float64Array, 1 << 16),
|
|
43
|
+
dict: columns[name] === 'category' ? [] : null,
|
|
44
|
+
codes: columns[name] === 'category' ? new Map() : null,
|
|
45
|
+
date: columns[name] === 'date',
|
|
46
|
+
}]));
|
|
47
|
+
streams.set(id, {
|
|
48
|
+
id, names, format, badLines: 0, windows: new Map(),
|
|
49
|
+
types: columns, header: new TextEncoder().encode(`${names.join(',')}\n`),
|
|
50
|
+
zones: new Map(names.map((name) => [name, { mins: [], maxs: [] }])), // per block of ZONE_ROWS buffer rows
|
|
51
|
+
cols, cap: 1 << 16, n: 0, start: 0, keepRows, keepMs, timeColumn, maxTime: -Infinity,
|
|
52
|
+
pending: [], pendingBytes: 0, partial: null, timer: null, scheduled: false, sockets: [], waiters: [], paused: false,
|
|
53
|
+
rowsIn: 0, bytesIn: 0, batches: 0, error: null,
|
|
54
|
+
});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const get = (id) => {
|
|
59
|
+
const s = streams.get(id);
|
|
60
|
+
if (!s) throw new Error(`vectro: no stream ${id} in this worker`);
|
|
61
|
+
return s;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Room for `need` rows: drop the rows no longer kept, then grow by doubling.
|
|
65
|
+
function reserve(s, need) {
|
|
66
|
+
const engine = getEngine();
|
|
67
|
+
if (need <= s.cap) return;
|
|
68
|
+
if (s.start > 0) { // move the kept rows to the front
|
|
69
|
+
for (const c of s.cols.values()) { const d = engine.live(c.data); d.copyWithin(0, s.start, s.n); }
|
|
70
|
+
s.n -= s.start; need -= s.start; s.start = 0;
|
|
71
|
+
for (const z of s.zones.values()) { z.mins.length = 0; z.maxs.length = 0; }
|
|
72
|
+
updateZones(s, 0, s.n); // rows moved: rebuild the zone maps
|
|
73
|
+
}
|
|
74
|
+
// keep at least as much free room as kept rows, so rows move at most once
|
|
75
|
+
// per row added (compaction copies every kept row)
|
|
76
|
+
if (need <= s.cap && s.cap - need >= Math.min(need, s.n)) return;
|
|
77
|
+
const cap = Math.max(need * 2, s.cap * 2);
|
|
78
|
+
for (const c of s.cols.values()) {
|
|
79
|
+
const bigger = engine.alloc(Float64Array, cap);
|
|
80
|
+
bigger.set(engine.live(c.data).subarray(0, s.n));
|
|
81
|
+
engine.free(c.data);
|
|
82
|
+
c.data = bigger;
|
|
83
|
+
}
|
|
84
|
+
s.cap = cap;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse complete lines (CSV or JSON lines) and append them.
|
|
88
|
+
function ingest(s, bytes) {
|
|
89
|
+
if (!bytes.length) return;
|
|
90
|
+
if (s.format === 'ndjson') {
|
|
91
|
+
const r = parseNDJSON(bytes, 0, bytes.length, s.names, s.names.map((n) => s.types[n]));
|
|
92
|
+
s.badLines += r.bad;
|
|
93
|
+
append(s, r.rows, (k) => ({ data: r.cols[k], dict: r.dicts[k] }), bytes.length);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const engine = getEngine();
|
|
97
|
+
const buf = new Uint8Array(s.header.length + bytes.length);
|
|
98
|
+
buf.set(s.header, 0);
|
|
99
|
+
buf.set(bytes, s.header.length);
|
|
100
|
+
const t = engine.parseCSV(buf, { types: s.types, sampleRows: 1, compress: false }); // types are known; a batch is copied in at once
|
|
101
|
+
try {
|
|
102
|
+
append(s, t.length, (k) => { const f = t.cols.get(s.names[k]); return { data: engine.live(f.data), dict: f.dict }; }, bytes.length);
|
|
103
|
+
} finally {
|
|
104
|
+
t.dispose();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Append m parsed rows. part(k) gives column k's values (and, for
|
|
109
|
+
// categories, the batch's own dictionary); it is called after growing,
|
|
110
|
+
// because growing can move engine memory.
|
|
111
|
+
function append(s, m, part, nbytes) {
|
|
112
|
+
const engine = getEngine();
|
|
113
|
+
reserve(s, s.n + m);
|
|
114
|
+
s.names.forEach((name, k) => {
|
|
115
|
+
const c = s.cols.get(name), { data: src, dict } = part(k), dst = engine.live(c.data);
|
|
116
|
+
if (!c.dict || !dict) { dst.set(src, s.n); return; } // numbers, or codes already in this stream's numbering
|
|
117
|
+
// the batch has its own dictionary; renumber into the stream's
|
|
118
|
+
const lut = dict.map((str) => {
|
|
119
|
+
let g = c.codes.get(str);
|
|
120
|
+
if (g === undefined) { g = c.dict.length; c.dict.push(str); c.codes.set(str, g); }
|
|
121
|
+
return g;
|
|
122
|
+
});
|
|
123
|
+
for (let i = 0; i < m; i++) dst[s.n + i] = lut[src[i]];
|
|
124
|
+
});
|
|
125
|
+
if (s.timeColumn) {
|
|
126
|
+
const ts = engine.live(s.cols.get(s.timeColumn).data);
|
|
127
|
+
for (let i = s.n; i < s.n + m; i++) if (ts[i] > s.maxTime) s.maxTime = ts[i];
|
|
128
|
+
}
|
|
129
|
+
for (const w of s.windows.values()) updateWindow(s, w, s.n, m);
|
|
130
|
+
updateZones(s, s.n, m);
|
|
131
|
+
s.n += m;
|
|
132
|
+
s.rowsIn += m;
|
|
133
|
+
s.bytesIn += nbytes;
|
|
134
|
+
s.batches++;
|
|
135
|
+
// what is kept: the last keepRows rows, and rows newer than maxTime - keepMs
|
|
136
|
+
if (s.n - s.start > s.keepRows) s.start = s.n - s.keepRows;
|
|
137
|
+
if (s.keepMs != null) {
|
|
138
|
+
const ts = engine.live(s.cols.get(s.timeColumn).data), oldest = s.maxTime - s.keepMs;
|
|
139
|
+
while (s.start < s.n && ts[s.start] < oldest) s.start++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Parse up to `limit` bytes of queued lines (everything by default).
|
|
144
|
+
function flush(s, limit = Infinity) {
|
|
145
|
+
clearTimeout(s.timer);
|
|
146
|
+
s.timer = null;
|
|
147
|
+
while (s.pendingBytes && limit > 0) {
|
|
148
|
+
let take = 0, bytes = 0;
|
|
149
|
+
while (take < s.pending.length && (take === 0 || bytes + s.pending[take].length <= Math.min(limit, BATCH_BYTES))) bytes += s.pending[take++].length;
|
|
150
|
+
const part = s.pending.splice(0, take);
|
|
151
|
+
s.pendingBytes -= bytes;
|
|
152
|
+
limit -= bytes;
|
|
153
|
+
try { ingest(s, part.length === 1 ? part[0] : concat(part, bytes)); } catch (err) { s.error ??= err.message; }
|
|
154
|
+
}
|
|
155
|
+
drained(s);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// One batch per event-loop turn while a full batch is waiting.
|
|
159
|
+
function tick(s) {
|
|
160
|
+
s.scheduled = false;
|
|
161
|
+
if (!streams.has(s.id)) return;
|
|
162
|
+
if (s.pendingBytes >= BATCH_BYTES) flush(s, BATCH_BYTES);
|
|
163
|
+
schedule(s);
|
|
164
|
+
drained(s);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The backlog went down: resume sockets and answer waiting pushes.
|
|
168
|
+
function drained(s) {
|
|
169
|
+
if (s.pendingBytes > LOW) return;
|
|
170
|
+
if (s.paused) { s.paused = false; for (const socket of s.sockets) socket.resume(); }
|
|
171
|
+
for (const resolve of s.waiters.splice(0)) resolve(true);
|
|
172
|
+
}
|
|
173
|
+
function schedule(s) {
|
|
174
|
+
if (s.pendingBytes >= BATCH_BYTES) {
|
|
175
|
+
if (!s.scheduled) { s.scheduled = true; nextTurn(() => tick(s)); }
|
|
176
|
+
} else if (s.pendingBytes && !s.timer) {
|
|
177
|
+
s.timer = setTimeout(() => flush(s), FLUSH_MS);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Queue complete lines; they are parsed in later turns (see nextTurn).
|
|
182
|
+
function queue(s, lines) {
|
|
183
|
+
if (!lines.length) return;
|
|
184
|
+
s.pending.push(lines);
|
|
185
|
+
s.pendingBytes += lines.length;
|
|
186
|
+
schedule(s);
|
|
187
|
+
if (s.pendingBytes > HIGH && !s.paused && s.sockets.length) { s.paused = true; for (const socket of s.sockets) socket.pause(); }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Bytes cut anywhere: keep what follows the last newline for next time.
|
|
191
|
+
function receive(s, chunk) {
|
|
192
|
+
const last = chunk.lastIndexOf(NL);
|
|
193
|
+
if (last === -1) { s.partial = s.partial ? concat([s.partial, chunk]) : Uint8Array.from(chunk); return; }
|
|
194
|
+
let lines = chunk.subarray(0, last + 1);
|
|
195
|
+
if (s.partial) { lines = concat([s.partial, lines]); s.partial = null; }
|
|
196
|
+
else lines = Uint8Array.prototype.slice.call(lines); // own copy (socket buffers get reused)
|
|
197
|
+
if (last + 1 < chunk.length) s.partial = Uint8Array.prototype.slice.call(chunk, last + 1);
|
|
198
|
+
queue(s, lines);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Zone maps for rows [from, from + m): min and max per block of ZONE_ROWS
|
|
202
|
+
// buffer rows, kept up to date as batches arrive, so filtered queries on
|
|
203
|
+
// live data (usually time-ordered) skip old blocks. See zones.mjs.
|
|
204
|
+
// Min and max skip NaN (x == x is false only for NaN). Long runs use one
|
|
205
|
+
// compiled SIMD pass per block; short ones a plain loop.
|
|
206
|
+
const ZONE_SPEC = ['min(x == x ? x : Infinity)', 'max(x == x ? x : -Infinity)'];
|
|
207
|
+
function updateZones(s, from, m) {
|
|
208
|
+
const engine = getEngine();
|
|
209
|
+
for (const [name, z] of s.zones) {
|
|
210
|
+
const col = s.cols.get(name);
|
|
211
|
+
for (let i = from, end = from + m; i < end; ) {
|
|
212
|
+
const b = Math.floor(i / ZONE_ROWS), stop = Math.min(end, (b + 1) * ZONE_ROWS);
|
|
213
|
+
let lo = z.mins[b] ?? Infinity, hi = z.maxs[b] ?? -Infinity;
|
|
214
|
+
if (stop - i >= 1024 && engine.jit) {
|
|
215
|
+
const [a, c] = engine.eval(ZONE_SPEC, { x: engine.live(col.data).subarray(i, stop) });
|
|
216
|
+
if (a < lo) lo = a;
|
|
217
|
+
if (c > hi) hi = c;
|
|
218
|
+
} else {
|
|
219
|
+
const d = engine.live(col.data);
|
|
220
|
+
for (let j = i; j < stop; j++) { const v = d[j]; if (v < lo) lo = v; if (v > hi) hi = v; }
|
|
221
|
+
}
|
|
222
|
+
z.mins[b] = lo; z.maxs[b] = hi;
|
|
223
|
+
i = stop;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- running totals over time windows ---------------------------------------
|
|
229
|
+
// A window keeps, per time slice of `every` ms and per group, the partial
|
|
230
|
+
// aggregates of every row that arrived (sum and count for means). Slices
|
|
231
|
+
// older than `last` are dropped. Rows can be dropped (keepRows) while their
|
|
232
|
+
// totals stay. Each batch is grouped by (slice, group) with the compiled
|
|
233
|
+
// group-by: key = (slice - first slice in the batch) * groups + group code.
|
|
234
|
+
const GROUP_KEYS = 2 ** 24;
|
|
235
|
+
|
|
236
|
+
function openWindow(s, { wid, by = null, every, last, timeColumn, ask, fns, sketches = [] }) {
|
|
237
|
+
const time = timeColumn ?? s.timeColumn;
|
|
238
|
+
if (!s.cols.has(time)) throw new Error(`vectro: window needs timeColumn, a number column of milliseconds (columns: ${s.names.join(', ')})`);
|
|
239
|
+
if (by != null && !s.cols.get(by)?.dict) throw new Error(`vectro: window "by" must be a category column, got "${by}"`);
|
|
240
|
+
if (!(every > 0) || !(last >= every)) throw new Error('vectro: window needs every > 0 and last >= every (milliseconds)');
|
|
241
|
+
const w = { by, every, slices: Math.ceil(last / every), time, ask, fns, buckets: new Map(), newest: -Infinity, sketches, sk: new Map() };
|
|
242
|
+
s.windows.set(wid, w);
|
|
243
|
+
if (s.n > s.start) updateWindow(s, w, s.start, s.n - s.start); // rows already kept count too
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Add rows [from, from + m) of stream s to window w.
|
|
248
|
+
function updateWindow(s, w, from, m) {
|
|
249
|
+
if (!m) return;
|
|
250
|
+
const engine = getEngine();
|
|
251
|
+
const view = (extra) => {
|
|
252
|
+
const cols = new Map();
|
|
253
|
+
for (const [name, c] of s.cols) cols.set(name, { data: engine.live(c.data).subarray(from, from + m), dict: c.dict, codes: c.codes, strings: null, date: c.date });
|
|
254
|
+
return new Table(engine, cols, m, { owned: [], zones: false });
|
|
255
|
+
};
|
|
256
|
+
const t = view();
|
|
257
|
+
const { lo, hi } = t.filter(`${w.time} == ${w.time}`).agg({ lo: `min(${w.time})`, hi: `max(${w.time})` });
|
|
258
|
+
if (!(lo <= hi)) return; // no row has a time
|
|
259
|
+
const first = Math.floor(lo / w.every), G = w.by ? Math.max(1, s.cols.get(w.by).dict.length) : 1;
|
|
260
|
+
const span = Math.floor(hi / w.every) - first + 1;
|
|
261
|
+
const add = (slice, group, values) => {
|
|
262
|
+
if (slice > w.newest) w.newest = slice;
|
|
263
|
+
let b = w.buckets.get(slice);
|
|
264
|
+
if (!b) w.buckets.set(slice, (b = new Map()));
|
|
265
|
+
const acc = b.get(group);
|
|
266
|
+
if (!acc) { b.set(group, values.slice()); return; }
|
|
267
|
+
for (let j = 0; j < values.length; j++) { // (min / max of a part with no values is NaN: skip it)
|
|
268
|
+
const f = w.fns[j], v = values[j];
|
|
269
|
+
acc[j] = f === 'min' ? (v < acc[j] || acc[j] !== acc[j] ? v : acc[j]) : f === 'max' ? (v > acc[j] || acc[j] !== acc[j] ? v : acc[j]) : acc[j] + v;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const askNames = Object.keys(w.ask);
|
|
273
|
+
const label = (code) => (w.by ? s.cols.get(w.by).dict[code] : null);
|
|
274
|
+
if (span * G < GROUP_KEYS) {
|
|
275
|
+
const keyExpr = `${w.time} == ${w.time} ? (floor(${w.time} / __np_every) - __np_first) * __np_g + ${w.by ?? 0} : 0`;
|
|
276
|
+
const keyed = t.derive('__np_key', keyExpr, { __np_every: w.every, __np_first: first, __np_g: G });
|
|
277
|
+
try {
|
|
278
|
+
// group-by returns only keys that have rows; a pass takes at most 7 aggregates
|
|
279
|
+
const timed = keyed.filter(`${w.time} == ${w.time}`);
|
|
280
|
+
const byKey = new Map();
|
|
281
|
+
for (let i = 0; i < askNames.length; i += 7) {
|
|
282
|
+
const part = askNames.slice(i, i + 7);
|
|
283
|
+
const g = timed.groupBy('__np_key').agg(Object.fromEntries(part.map((a) => [a, w.ask[a]])));
|
|
284
|
+
try {
|
|
285
|
+
const keys = engine.live(g.cols.get('__np_key').data);
|
|
286
|
+
const cols = part.map((a) => engine.live(g.cols.get(a).data));
|
|
287
|
+
for (let r = 0; r < keys.length; r++) {
|
|
288
|
+
let vals = byKey.get(keys[r]);
|
|
289
|
+
if (!vals) byKey.set(keys[r], (vals = new Array(askNames.length)));
|
|
290
|
+
cols.forEach((c, j) => { vals[i + j] = c[r]; });
|
|
291
|
+
}
|
|
292
|
+
} finally { g.dispose(); }
|
|
293
|
+
}
|
|
294
|
+
for (const [key, vals] of byKey) add(first + Math.floor(key / G), label(key % G), vals);
|
|
295
|
+
} finally { keyed.dispose(); }
|
|
296
|
+
} else {
|
|
297
|
+
// times spread too far for one group-by: compute each argument, then add row by row
|
|
298
|
+
const argCols = askNames.map((a) => {
|
|
299
|
+
const src = w.ask[a], arg = src.slice(src.indexOf('(') + 1, -1).trim();
|
|
300
|
+
return w.fns[askNames.indexOf(a)] === 'count' && !arg ? null : arg;
|
|
301
|
+
});
|
|
302
|
+
const isCondition = argCols.map((arg) => arg && isMask(parse(arg)));
|
|
303
|
+
const derived = argCols.map((arg, j) => (arg ? t.derive(`__np_a${j}`, arg) : null));
|
|
304
|
+
try {
|
|
305
|
+
const ts = engine.live(s.cols.get(w.time).data).subarray(from, from + m);
|
|
306
|
+
const codes = w.by ? engine.live(s.cols.get(w.by).data).subarray(from, from + m) : null;
|
|
307
|
+
const vals = derived.map((d, j) => (d ? engine.live(d.cols.get(`__np_a${j}`).data) : null));
|
|
308
|
+
const values = new Array(askNames.length);
|
|
309
|
+
for (let i = 0; i < m; i++) {
|
|
310
|
+
if (Number.isNaN(ts[i])) continue;
|
|
311
|
+
for (let j = 0; j < values.length; j++) { // the table rules: missing values are skipped
|
|
312
|
+
const v = vals[j] ? vals[j][i] : 1, f = w.fns[j];
|
|
313
|
+
values[j] = f === 'count' ? (isCondition[j] ? (v !== 0 ? 1 : 0) : v === v ? 1 : 0)
|
|
314
|
+
: v === v ? v : f === 'min' || f === 'max' ? NaN : 0;
|
|
315
|
+
}
|
|
316
|
+
add(Math.floor(ts[i] / w.every), label(codes ? codes[i] : 0), values);
|
|
317
|
+
}
|
|
318
|
+
} finally { for (const d of derived) d?.dispose(); }
|
|
319
|
+
}
|
|
320
|
+
if (w.sketches.length) updateSketches(s, w, t, from, m);
|
|
321
|
+
// drop slices that fell out of the window
|
|
322
|
+
for (const slice of w.buckets.keys()) if (slice <= w.newest - w.slices) w.buckets.delete(slice);
|
|
323
|
+
for (const slice of w.sk.keys()) if (slice <= w.newest - w.slices) w.sk.delete(slice);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Distinct-count sketches of a window: per (slice, group), one sketch per
|
|
327
|
+
// approxDistinct (SKETCH_SIZE bytes each, side by side). Text hashes by
|
|
328
|
+
// label, so every worker's dictionary gives the same hashes.
|
|
329
|
+
function updateSketches(s, w, t, from, m) {
|
|
330
|
+
const engine = getEngine(), J = w.sketches.length;
|
|
331
|
+
const ts = engine.live(s.cols.get(w.time).data).subarray(from, from + m);
|
|
332
|
+
const codes = w.by ? engine.live(s.cols.get(w.by).data).subarray(from, from + m) : null;
|
|
333
|
+
const dictBy = w.by ? s.cols.get(w.by).dict : null;
|
|
334
|
+
const derived = [];
|
|
335
|
+
const vals = w.sketches.map(({ arg }, j) => {
|
|
336
|
+
const name = arg.trim(), c = /^[A-Za-z_]\w*$/.test(name) ? s.cols.get(name) : null;
|
|
337
|
+
if (c) return { data: engine.live(c.data).subarray(from, from + m), dict: c.dict };
|
|
338
|
+
const d = t.derive(`__np_s${j}`, arg);
|
|
339
|
+
derived.push(d);
|
|
340
|
+
return { data: engine.live(d.cols.get(`__np_s${j}`).data), dict: null };
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
const hashes = vals.map((v) => (v.dict ? v.dict.map(hashText) : null)); // per label, once
|
|
344
|
+
const cache = new Map();
|
|
345
|
+
for (let i = 0; i < m; i++) {
|
|
346
|
+
const tv = ts[i];
|
|
347
|
+
if (tv !== tv) continue;
|
|
348
|
+
const slice = Math.floor(tv / w.every), code = codes ? codes[i] : 0;
|
|
349
|
+
if (slice > w.newest) w.newest = slice;
|
|
350
|
+
let bySlice = cache.get(slice);
|
|
351
|
+
if (!bySlice) cache.set(slice, (bySlice = []));
|
|
352
|
+
let regs = bySlice[code];
|
|
353
|
+
if (!regs) {
|
|
354
|
+
let b = w.sk.get(slice);
|
|
355
|
+
if (!b) w.sk.set(slice, (b = new Map()));
|
|
356
|
+
const group = dictBy ? dictBy[code] : null;
|
|
357
|
+
regs = b.get(group);
|
|
358
|
+
if (!regs) b.set(group, (regs = new Uint8Array(J * SKETCH_SIZE)));
|
|
359
|
+
bySlice[code] = regs;
|
|
360
|
+
}
|
|
361
|
+
for (let j = 0; j < J; j++) {
|
|
362
|
+
const x = vals[j].data[i];
|
|
363
|
+
if (hashes[j]) sketchAdd(regs, j * SKETCH_SIZE, hashes[j][x]);
|
|
364
|
+
else if (x === x) sketchAdd(regs, j * SKETCH_SIZE, hashNumber(x));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} finally {
|
|
368
|
+
for (const d of derived) d.dispose();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const handlers = {
|
|
373
|
+
streamOpen: open,
|
|
374
|
+
// A window's sketches from slice `since` on: merged per group, or per
|
|
375
|
+
// (slice, group) with series. -> { keys: [[slice | null, group]], sketches }
|
|
376
|
+
streamWindowSketches({ id, wid, since, series }) {
|
|
377
|
+
const s = get(id), w = s.windows.get(wid);
|
|
378
|
+
if (!w) throw new Error(`vectro: no window ${wid} on this stream`);
|
|
379
|
+
flush(s);
|
|
380
|
+
const J = w.sketches.length, out = new Map();
|
|
381
|
+
for (const [slice, groups] of w.sk) {
|
|
382
|
+
if (slice < since) continue;
|
|
383
|
+
for (const [group, regs] of groups) {
|
|
384
|
+
const key = series ? `${slice}\u0000${group}` : group;
|
|
385
|
+
let e = out.get(key);
|
|
386
|
+
if (!e) out.set(key, (e = { slice: series ? slice : null, group, regs: new Uint8Array(J * SKETCH_SIZE) }));
|
|
387
|
+
sketchMerge(e.regs, 0, regs, 0);
|
|
388
|
+
for (let j = 1; j < J; j++) sketchMerge(e.regs, j * SKETCH_SIZE, regs, j * SKETCH_SIZE);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const list = [...out.values()];
|
|
392
|
+
const sketches = new Uint8Array(list.length * J * SKETCH_SIZE);
|
|
393
|
+
list.forEach((e, i) => sketches.set(e.regs, i * J * SKETCH_SIZE));
|
|
394
|
+
return { keys: list.map((e) => [e.slice, e.group]), sketches };
|
|
395
|
+
},
|
|
396
|
+
streamWindow({ id, ...opts }) { return openWindow(get(id), opts); },
|
|
397
|
+
// Every slice kept: [slice, group label, ...values in ask order].
|
|
398
|
+
streamWindowRead({ id, wid }) {
|
|
399
|
+
const s = get(id), w = s.windows.get(wid);
|
|
400
|
+
if (!w) throw new Error(`vectro: no window ${wid} on this stream`);
|
|
401
|
+
flush(s);
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const [slice, groups] of w.buckets) for (const [group, acc] of groups) out.push([slice, group, ...acc]);
|
|
404
|
+
return out;
|
|
405
|
+
},
|
|
406
|
+
streamWindowClose({ id, wid }) { return get(id).windows.delete(wid); },
|
|
407
|
+
// Complete lines from the page (it cuts at newlines before routing).
|
|
408
|
+
// The reply waits while the backlog is high, so the page slows down too.
|
|
409
|
+
streamPush({ id, bytes }) {
|
|
410
|
+
const s = get(id);
|
|
411
|
+
if (s.format === 'vectro' || s.format === 'arrow') throw new Error("vectro: binary streams take frames over connections: connect each worker to the sender (push() is for 'csv' and 'ndjson')");
|
|
412
|
+
queue(s, bytes);
|
|
413
|
+
return s.pendingBytes > HIGH ? new Promise((resolve) => s.waiters.push(resolve)) : true;
|
|
414
|
+
},
|
|
415
|
+
// This worker reads its own connection; nothing passes through the page.
|
|
416
|
+
// url: a WebSocket (browsers and Node 22+); each message is one or more
|
|
417
|
+
// whole lines. host + port: raw TCP (Node only), cut anywhere.
|
|
418
|
+
async streamConnect({ id, url, host = '127.0.0.1', port }) {
|
|
419
|
+
const s = get(id);
|
|
420
|
+
if (url) return connectWebSocket(s, url);
|
|
421
|
+
const net = await import('node:net');
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const socket = net.connect({ host, port }, () => resolve(true));
|
|
424
|
+
if (s.format === 'vectro' || s.format === 'arrow') { const reader = frameReceiver(s); socket.on('data', (chunk) => receiveFrames(s, reader, chunk)); } else
|
|
425
|
+
socket.on('data', (chunk) => receive(s, chunk));
|
|
426
|
+
socket.on('end', () => { if (s.partial) { queue(s, concat([s.partial, Uint8Array.of(NL)])); s.partial = null; } flush(s); });
|
|
427
|
+
socket.on('error', (err) => { s.error ??= err.message; reject(err); });
|
|
428
|
+
s.sockets.push(socket);
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
// Parse everything received so far, so the next query sees it.
|
|
432
|
+
streamFlush({ id }) {
|
|
433
|
+
const s = get(id);
|
|
434
|
+
flush(s);
|
|
435
|
+
return stats(s);
|
|
436
|
+
},
|
|
437
|
+
streamStats({ id }) { return stats(get(id)); },
|
|
438
|
+
// A copy of the rows kept right now, as a normal table `newId`.
|
|
439
|
+
streamSnapshot({ id, newId, tables }) {
|
|
440
|
+
const engine = getEngine(), s = get(id);
|
|
441
|
+
flush(s);
|
|
442
|
+
const n = s.n - s.start;
|
|
443
|
+
const cols = new Map();
|
|
444
|
+
for (const [name, c] of s.cols) {
|
|
445
|
+
const data = engine.alloc(Float64Array, n);
|
|
446
|
+
data.set(engine.live(c.data).subarray(s.start, s.n));
|
|
447
|
+
cols.set(name, { data, dict: c.dict && [...c.dict], codes: c.dict && new Map(c.codes), strings: null, date: c.date });
|
|
448
|
+
}
|
|
449
|
+
for (const c of cols.values()) c.data = engine.live(c.data);
|
|
450
|
+
tables.set(newId, new Table(engine, cols, n, { owned: [...cols.keys()] }));
|
|
451
|
+
return n;
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Binary Vectro frames (see frames.mjs) from one connection: number columns
|
|
456
|
+
// are copied straight in; category codes go through a small table from
|
|
457
|
+
// this connection's label numbers to the stream's.
|
|
458
|
+
function frameReceiver(s) {
|
|
459
|
+
let map = null; // stream column k -> index in the connection's schema
|
|
460
|
+
const luts = s.names.map(() => []);
|
|
461
|
+
const Reader = s.format === 'arrow' ? ArrowReader : FrameReader;
|
|
462
|
+
return new Reader({
|
|
463
|
+
onSchema(columns, info) {
|
|
464
|
+
const names = Object.keys(columns);
|
|
465
|
+
map = s.names.map((name) => {
|
|
466
|
+
const at = names.indexOf(name);
|
|
467
|
+
if (at < 0) throw new Error(`vectro: the sender doesn't send column "${name}"`);
|
|
468
|
+
if (columns[name] !== (s.types[name] === 'date' ? 'number' : s.types[name])) throw new Error(`vectro: column "${name}" is ${columns[name] ?? info?.[at]?.type} in the sender but ${s.types[name]} in this stream`);
|
|
469
|
+
return at;
|
|
470
|
+
});
|
|
471
|
+
return s.names; // (Arrow) read only these
|
|
472
|
+
},
|
|
473
|
+
onBatch(rows, columns) {
|
|
474
|
+
const bytes = columns.reduce((a, c) => a + (c.values ? c.values.byteLength : c.codes.byteLength), 0);
|
|
475
|
+
append(s, rows, (k) => {
|
|
476
|
+
const col = columns[map[k]];
|
|
477
|
+
if (col.values) return { data: col.values, dict: null };
|
|
478
|
+
const c = s.cols.get(s.names[k]), lut = luts[k];
|
|
479
|
+
for (let i = lut.length; i < col.labels.length; i++) { // labels new on this connection
|
|
480
|
+
const label = col.labels[i];
|
|
481
|
+
let g = c.codes.get(label);
|
|
482
|
+
if (g === undefined) { g = c.dict.length; c.dict.push(label); c.codes.set(label, g); }
|
|
483
|
+
lut.push(g);
|
|
484
|
+
}
|
|
485
|
+
const out = new Float64Array(rows);
|
|
486
|
+
for (let i = 0; i < rows; i++) out[i] = lut[col.codes[i]];
|
|
487
|
+
return { data: out, dict: null };
|
|
488
|
+
}, bytes);
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const receiveFrames = (s, reader, chunk) => {
|
|
493
|
+
try { reader.push(chunk); } catch (err) { s.error ??= err.message; }
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// WebSockets can't be paused (in browsers or Node), so there is no
|
|
497
|
+
// backpressure here: a feed faster than the workers grows the backlog.
|
|
498
|
+
function connectWebSocket(s, url) {
|
|
499
|
+
if (typeof WebSocket !== 'function') throw new Error('vectro: this runtime has no WebSocket (Node 22+ and browsers do)');
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const ws = new WebSocket(url);
|
|
502
|
+
ws.binaryType = 'arraybuffer';
|
|
503
|
+
let opened = false;
|
|
504
|
+
ws.onopen = () => { opened = true; resolve(true); };
|
|
505
|
+
const reader = s.format === 'vectro' || s.format === 'arrow' ? frameReceiver(s) : null;
|
|
506
|
+
ws.onmessage = (ev) => {
|
|
507
|
+
if (reader) { if (typeof ev.data !== 'string') receiveFrames(s, reader, new Uint8Array(ev.data)); return; }
|
|
508
|
+
const bytes = typeof ev.data === 'string' ? textEncoder.encode(ev.data) : new Uint8Array(ev.data);
|
|
509
|
+
if (!bytes.length) return;
|
|
510
|
+
queue(s, bytes[bytes.length - 1] === NL ? bytes : concat([bytes, Uint8Array.of(NL)]));
|
|
511
|
+
};
|
|
512
|
+
ws.onerror = () => { const err = new Error(`vectro: WebSocket ${url} failed`); s.error ??= err.message; if (!opened) reject(err); };
|
|
513
|
+
ws.onclose = () => flush(s);
|
|
514
|
+
s.sockets.push({ destroy: () => ws.close(), pause() {}, resume() {} });
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const stats = (s) => ({ rows: s.n - s.start, rowsIn: s.rowsIn, badLines: s.badLines, bytesIn: s.bytesIn, batches: s.batches, unparsedBytes: s.pendingBytes + (s.partial?.length ?? 0), error: s.error });
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
handlers,
|
|
522
|
+
has: (id) => streams.has(id),
|
|
523
|
+
// A Table over the rows kept; it owns nothing, so disposing it is harmless.
|
|
524
|
+
view(id) {
|
|
525
|
+
const s = streams.get(id);
|
|
526
|
+
if (!s) return null;
|
|
527
|
+
const engine = getEngine();
|
|
528
|
+
const cols = new Map();
|
|
529
|
+
for (const [name, c] of s.cols) {
|
|
530
|
+
const z = s.zones.get(name);
|
|
531
|
+
cols.set(name, { data: engine.live(c.data).subarray(s.start, s.n), dict: c.dict, codes: c.codes, strings: null, date: c.date, zones: { block: ZONE_ROWS, shift: s.start, mins: z.mins, maxs: z.maxs } });
|
|
532
|
+
}
|
|
533
|
+
return new Table(engine, cols, s.n - s.start, { owned: [] });
|
|
534
|
+
},
|
|
535
|
+
close(id) {
|
|
536
|
+
const s = streams.get(id);
|
|
537
|
+
if (!s) return false;
|
|
538
|
+
clearTimeout(s.timer);
|
|
539
|
+
for (const resolve of s.waiters.splice(0)) resolve(false);
|
|
540
|
+
for (const socket of s.sockets) socket.destroy();
|
|
541
|
+
for (const c of s.cols.values()) getEngine().free(c.data);
|
|
542
|
+
streams.delete(id);
|
|
543
|
+
return true;
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function concat(parts, total = parts.reduce((a, p) => a + p.length, 0)) {
|
|
549
|
+
const out = new Uint8Array(total);
|
|
550
|
+
let at = 0;
|
|
551
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
552
|
+
return out;
|
|
553
|
+
}
|