vectrojs 0.0.1 → 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/README.md +821 -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 +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/dataset.mjs
ADDED
|
@@ -0,0 +1,1763 @@
|
|
|
1
|
+
// Datasets: tables bigger than memory, kept on disk (Node.js).
|
|
2
|
+
//
|
|
3
|
+
// engine.csvToDataset('orders.csv', 'orders.vectro'); // a CSV of any size
|
|
4
|
+
// const w = engine.createDataset('orders.vectro'); // or append tables
|
|
5
|
+
// w.append(table); w.append(moreRows); w.close();
|
|
6
|
+
//
|
|
7
|
+
// const orders = engine.openDataset('orders.vectro');
|
|
8
|
+
// orders.filter("region == 'EU'").groupBy('day').agg({ revenue: 'sum(price * qty)' }).toRows();
|
|
9
|
+
// sql('SELECT region, COUNT(*) FROM orders GROUP BY region', { orders }).toRows();
|
|
10
|
+
//
|
|
11
|
+
// The file holds chunks of rows (4M by default). Each column of each chunk
|
|
12
|
+
// is stored compressed, exactly as it sits in engine memory (pack.mjs), so
|
|
13
|
+
// loading a chunk is one read per column: no parsing, no decoding. A footer
|
|
14
|
+
// holds the schema, the category labels (one dictionary for the whole
|
|
15
|
+
// dataset) and the min / max of every column in every chunk.
|
|
16
|
+
//
|
|
17
|
+
// A query is the usual lazy plan (plan.mjs), run one chunk at a time:
|
|
18
|
+
// - only the columns the plan uses are read, and chunks whose min / max
|
|
19
|
+
// rule out the filters are not read at all;
|
|
20
|
+
// - filters, derived columns and joins (the other table in memory) run on
|
|
21
|
+
// each chunk with the normal kernels, on every thread;
|
|
22
|
+
// - the first step that needs all rows takes partial results per chunk
|
|
23
|
+
// and merges them: totals add up (a mean is a sum and a count), groups
|
|
24
|
+
// are merged by key as they come (memory follows the groups, not the
|
|
25
|
+
// rows), top-k keeps the best k of each chunk, limit stops reading
|
|
26
|
+
// once it has its rows, collect concatenates the matching rows;
|
|
27
|
+
// - everything after that step runs in memory on the merged result.
|
|
28
|
+
// With async calls (toRowsAsync, aggAsync...) the next chunk is read while
|
|
29
|
+
// the current one is computed.
|
|
30
|
+
//
|
|
31
|
+
// File layout: 'VECTRODS', version (u32), 0 (u32); column blobs (64-byte
|
|
32
|
+
// aligned); footer JSON; footer offset and length (f64 each); 'VECTRODS'.
|
|
33
|
+
|
|
34
|
+
import { Table, rewriteText } from './table.mjs';
|
|
35
|
+
import { Query, execute, findDataset, optimize, vars, namesOf, joinNames } from './plan.mjs';
|
|
36
|
+
import { parse, toSource, isMask } from './fuse.mjs';
|
|
37
|
+
import { KINDS, decodeOne } from './pack.mjs';
|
|
38
|
+
import { conditions, mayMatch, computeZones } from './zones.mjs';
|
|
39
|
+
import { STR_SHIFT, STR_ESCAPED } from './csv-kernel.mjs';
|
|
40
|
+
import { inspectCSV, loadBytes, loadBytesAsync } from './csv.mjs';
|
|
41
|
+
import { freeBuild } from './join.mjs';
|
|
42
|
+
import { copyRows, plainInto, remapCodes, textLength } from './rows.mjs';
|
|
43
|
+
export { copyRows };
|
|
44
|
+
import { OP } from './kernels.mjs';
|
|
45
|
+
import { bitPlan, packBits, unpackBits, unpackModule, seqModule } from './bitpack.mjs';
|
|
46
|
+
|
|
47
|
+
// Files: Node registers its own (files-node.mjs: paths, sync and worker-thread
|
|
48
|
+
// reads, writing, temporary folders for spilling). Without them (browsers,
|
|
49
|
+
// edge runtimes) datasets open from a File / Blob or a URL and are read
|
|
50
|
+
// asynchronously; writing and spilling need Node.
|
|
51
|
+
let files = null;
|
|
52
|
+
export function setFiles(f) { files = f; }
|
|
53
|
+
const need = (what) => {
|
|
54
|
+
if (!files) throw new Error(`vectro: ${what} needs Node (here, datasets open read-only from a File, Blob or URL: openDatasetAsync)`);
|
|
55
|
+
return files;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Temporary files for spilling (huge group-bys, sorts, joins): Node's temp
|
|
59
|
+
// folder (from its files), or browser storage (files-browser.mjs: opfsTemp,
|
|
60
|
+
// registered by vectrojs/browser). Its calls may be async; the spilling code
|
|
61
|
+
// asks for them as { call } requests that the drivers resolve.
|
|
62
|
+
let tempStorage = null;
|
|
63
|
+
export function setTempStorage(t) { tempStorage = t; }
|
|
64
|
+
const temp = () => {
|
|
65
|
+
const t = tempStorage ?? (files && { dir: files.tempDir, join: files.join, openWrite: (p) => files.openWrite(p), openRead: (p) => files.openRead(p), remove: files.remove });
|
|
66
|
+
if (!t) throw new Error('vectro: this query needs to spill to disk (too many groups, a big sort or join), which needs Node or browser file storage');
|
|
67
|
+
return t;
|
|
68
|
+
};
|
|
69
|
+
function* tempDataset(engine, path) { // (a spill file, written and closed)
|
|
70
|
+
const file = yield { call: () => temp().openRead(path) };
|
|
71
|
+
const footer = file.readSync ? readFooter(file) : yield { call: () => readFooterAsync(file) };
|
|
72
|
+
return new Dataset(engine, file, footer);
|
|
73
|
+
}
|
|
74
|
+
function* tempWriter(engine, path, opts) { return new DatasetWriter(engine, yield { call: () => temp().openWrite(path) }, opts); }
|
|
75
|
+
function* closeWriter(w) { w.close(); yield { call: () => w.file.done?.() }; }
|
|
76
|
+
const removeTemp = (path) => { try { const r = tempStorage ? tempStorage.remove(path) : files?.remove(path); r?.catch?.(() => {}); } catch { /* (best effort) */ } };
|
|
77
|
+
|
|
78
|
+
const MAGIC = new TextEncoder().encode('VECTRODS');
|
|
79
|
+
const VERSION = 2; // (2: bit-packed columns; version 1 files read too)
|
|
80
|
+
const HEADER = 16, TAIL = 24, ALIGN = 64;
|
|
81
|
+
const SLACK = 16;
|
|
82
|
+
const READ_PIECE = 1 << 20; // reads are cut into pieces this big, read by several threads at once // bytes after packed codes (SIMD loads read a little past the end)
|
|
83
|
+
export const CHUNK_ROWS = 1 << 22;
|
|
84
|
+
const BLOCK = 1 << 16; // min / max per block of rows inside a chunk: a query reads only the blocks that can match
|
|
85
|
+
// partial groups are merged again once this many pile up (tests make it small)
|
|
86
|
+
// how often partial results went to disk (tests, benchmarks)
|
|
87
|
+
export const stats = { spills: 0, resplits: 0, joinPartitions: 0 };
|
|
88
|
+
export const tuning = { mergeRows: 1 << 20, denseMin: undefined, spillRows: 1 << 23, partitionRows: 1 << 24, joinRows: 1 << 24 };
|
|
89
|
+
|
|
90
|
+
// ---- writing --------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export class DatasetWriter {
|
|
93
|
+
// rowsPerChunk: rows per chunk (the last one may be shorter); append: add
|
|
94
|
+
// to an existing dataset (same columns) instead of replacing it.
|
|
95
|
+
// bitPack: false stores packed codes in whole bytes (bigger files, no unpacking).
|
|
96
|
+
// path: a path (Node) or a writable file object (see createDatasetAsync).
|
|
97
|
+
constructor(engine, path, { rowsPerChunk = CHUNK_ROWS, append = false, bitPack = true, footer = null } = {}) {
|
|
98
|
+
this.engine = engine;
|
|
99
|
+
this.bitPack = bitPack;
|
|
100
|
+
this.path = typeof path === 'string' ? path : path.name;
|
|
101
|
+
this.rowsPerChunk = Math.max(1, Math.floor(rowsPerChunk));
|
|
102
|
+
this.pending = null; // rows waiting for a full chunk (a table this writer owns)
|
|
103
|
+
this.meta = null;
|
|
104
|
+
this.labels = new Map(); // category column -> { dict, codes }: one dictionary for the dataset
|
|
105
|
+
this.file = typeof path === 'string' ? need('writing a dataset').openWrite(path, { append }) : path;
|
|
106
|
+
if (this.file.existed) {
|
|
107
|
+
if (!footer) try { footer = readFooter(this.file); } catch (err) { this.file.close(); throw err; }
|
|
108
|
+
const { meta, footerStart } = footer;
|
|
109
|
+
this.meta = meta;
|
|
110
|
+
this.pos = footerStart; // new chunks overwrite the old footer
|
|
111
|
+
const v = new Uint8Array(4);
|
|
112
|
+
new DataView(v.buffer).setUint32(0, VERSION, true);
|
|
113
|
+
this.file.write(v, 8); // (new chunks may use this version's features)
|
|
114
|
+
meta.version = VERSION;
|
|
115
|
+
for (const c of meta.columns) if (c.kind === 'category') this.labels.set(c.name, { dict: c.dict, codes: new Map(c.dict.map((s, i) => [s, i])) });
|
|
116
|
+
} else {
|
|
117
|
+
const head = new Uint8Array(HEADER);
|
|
118
|
+
head.set(MAGIC);
|
|
119
|
+
new DataView(head.buffer).setUint32(8, VERSION, true);
|
|
120
|
+
this.file.write(head, 0);
|
|
121
|
+
this.pos = HEADER;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Add rows: a Table (only the rows matching its filter; or its rows
|
|
126
|
+
// lo..hi) or { name: values }.
|
|
127
|
+
append(source, lo = 0, hi = Infinity) {
|
|
128
|
+
const { engine } = this;
|
|
129
|
+
let t = source instanceof Table ? source : engine.table(source, { compress: false });
|
|
130
|
+
const temps = t === source ? [] : [t];
|
|
131
|
+
try {
|
|
132
|
+
if (t.pred) { t = t.collect(); temps.push(t); }
|
|
133
|
+
this.#schema(t);
|
|
134
|
+
hi = Math.min(hi, t.n);
|
|
135
|
+
if (this.pending) {
|
|
136
|
+
const all = copyRows(engine, [[this.pending, 0, this.pending.n], [t, lo, hi]]);
|
|
137
|
+
this.pending.dispose();
|
|
138
|
+
this.pending = null;
|
|
139
|
+
temps.push(all);
|
|
140
|
+
t = all;
|
|
141
|
+
lo = 0;
|
|
142
|
+
hi = all.n;
|
|
143
|
+
}
|
|
144
|
+
for (; hi - lo >= this.rowsPerChunk; lo += this.rowsPerChunk) this.#writeChunk(t, lo, lo + this.rowsPerChunk);
|
|
145
|
+
if (lo < hi || !this.meta.chunks.length) this.pending = copyRows(engine, [[t, lo, hi]]);
|
|
146
|
+
} finally {
|
|
147
|
+
for (const x of temps) x.dispose();
|
|
148
|
+
}
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Write what's left and the footer. Returns { path, rows, chunks, bytes }.
|
|
153
|
+
close() {
|
|
154
|
+
if (this.summary) return this.summary;
|
|
155
|
+
if (this.aborted) throw new Error('vectro: this dataset writer was aborted');
|
|
156
|
+
try {
|
|
157
|
+
if (!this.meta) throw new Error('vectro: append at least one table to a dataset (it can have no rows)');
|
|
158
|
+
if (this.pending && (this.pending.n || !this.meta.chunks.length)) this.#writeChunk(this.pending, 0, this.pending.n);
|
|
159
|
+
for (const c of this.meta.columns) if (c.kind === 'category') c.dict = this.labels.get(c.name).dict;
|
|
160
|
+
const json = new TextEncoder().encode(JSON.stringify(this.meta));
|
|
161
|
+
const { off } = this.#write(json);
|
|
162
|
+
const tail = new Uint8Array(TAIL), dv = new DataView(tail.buffer);
|
|
163
|
+
dv.setFloat64(0, off, true);
|
|
164
|
+
dv.setFloat64(8, json.length, true);
|
|
165
|
+
tail.set(MAGIC, 16);
|
|
166
|
+
this.file.write(tail, this.pos);
|
|
167
|
+
this.pos += TAIL;
|
|
168
|
+
this.file.truncate(this.pos);
|
|
169
|
+
this.summary = { path: this.path, rows: this.meta.rows, chunks: this.meta.chunks.length, bytes: this.pos };
|
|
170
|
+
return this.summary;
|
|
171
|
+
} finally {
|
|
172
|
+
this.pending?.dispose();
|
|
173
|
+
this.pending = null;
|
|
174
|
+
this.file.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Stop without a footer (the file is left unreadable as a dataset; a
|
|
179
|
+
// browser file is discarded).
|
|
180
|
+
abort() {
|
|
181
|
+
if (this.summary || this.aborted) return;
|
|
182
|
+
this.aborted = true;
|
|
183
|
+
this.pending?.dispose();
|
|
184
|
+
this.pending = null;
|
|
185
|
+
if (this.file.abort) this.file.abort(); else this.file.close();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The same as append / close, then waiting until the bytes are written
|
|
189
|
+
// (browser files write in the background; in Node they already are).
|
|
190
|
+
async appendAsync(source, lo, hi) { this.append(source, lo, hi); await this.file.flush?.(); return this; }
|
|
191
|
+
async closeAsync() { const summary = this.close(); await this.file.done?.(); return summary; }
|
|
192
|
+
|
|
193
|
+
#schema(t) {
|
|
194
|
+
const kindOf = (c) => (c.strings ? 'text' : c.dict ? 'category' : c.date ? 'date' : 'number');
|
|
195
|
+
const names = t.columnNames;
|
|
196
|
+
if (!this.meta) {
|
|
197
|
+
this.meta = { version: VERSION, type: t.type === Float32Array ? 'f32' : 'f64', rows: 0, columns: names.map((name) => ({ name, kind: kindOf(t.cols.get(name)) })), chunks: [] };
|
|
198
|
+
for (const c of this.meta.columns) if (c.kind === 'category') this.labels.set(c.name, { dict: [], codes: new Map() });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const want = this.meta.columns;
|
|
202
|
+
if (names.length !== want.length || want.some((c) => !t.cols.has(c.name))) {
|
|
203
|
+
throw new Error(`vectro: appended columns (${names.join(', ')}) differ from the dataset's (${want.map((c) => c.name).join(', ')})`);
|
|
204
|
+
}
|
|
205
|
+
for (const c of want) {
|
|
206
|
+
const k = kindOf(t.cols.get(c.name));
|
|
207
|
+
if (k !== c.kind) throw new Error(`vectro: column "${c.name}" is ${k} here but ${c.kind} in the dataset`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Rows [lo, hi) of t as one chunk: plain values (text as dataset codes),
|
|
212
|
+
// min / max, then packed with the kernels and written column by column.
|
|
213
|
+
#writeChunk(t, lo, hi) {
|
|
214
|
+
const { engine } = this;
|
|
215
|
+
const n = hi - lo, T = this.meta.type === 'f32' ? Float32Array : Float64Array, f32 = T === Float32Array;
|
|
216
|
+
const chunk = { rows: n, cols: {} };
|
|
217
|
+
const numeric = this.meta.columns.filter((m) => m.kind !== 'text');
|
|
218
|
+
const cols = new Map(numeric.map((m) => [m.name, { data: engine.alloc(T, n), dict: null, codes: null }]));
|
|
219
|
+
const ct = new Table(engine, cols, n, { owned: [...cols.keys()], zones: false });
|
|
220
|
+
try {
|
|
221
|
+
for (const m of this.meta.columns) {
|
|
222
|
+
const c = t.cols.get(m.name);
|
|
223
|
+
if (m.kind === 'text') { chunk.cols[m.name] = this.#writeText(c, lo, hi); continue; }
|
|
224
|
+
const out = engine.live(cols.get(m.name).data);
|
|
225
|
+
plainInto(engine, c, lo, hi, out, f32);
|
|
226
|
+
if (m.kind === 'category') remapCodes(out, c.dict, this.labels.get(m.name));
|
|
227
|
+
}
|
|
228
|
+
for (const [name, c] of cols) {
|
|
229
|
+
c.data = engine.live(c.data);
|
|
230
|
+
const [min, max, missing] = n ? engine.eval(['min(v == v ? v : Infinity)', 'max(v == v ? v : -Infinity)', 'count(v != v)'], { v: c.data }) : [Infinity, -Infinity, 0];
|
|
231
|
+
chunk.cols[name] = { min: jsonNumber(min), max: jsonNumber(max), missing };
|
|
232
|
+
if (n > BLOCK) { const z = computeZones(engine.live(c.data), n, BLOCK); chunk.cols[name].blocks = { min: Array.from(z.mins, jsonNumber), max: Array.from(z.maxs, jsonNumber) }; }
|
|
233
|
+
}
|
|
234
|
+
if (n) ct.compress();
|
|
235
|
+
for (const [name, c] of cols) {
|
|
236
|
+
const d = engine.live(c.data);
|
|
237
|
+
const seq = c.enc && !chunk.cols[name].missing ? sequence(d, n) : null;
|
|
238
|
+
if (seq) { Object.assign(chunk.cols[name], { off: 0, len: 0, enc: c.enc, seq }); continue; } // (nothing to store: rebuilt on read)
|
|
239
|
+
const plan = c.enc && this.bitPack ? bitPlan(d, n, KINDS[c.enc.kind].nan, KINDS[c.enc.kind].bytes * 8) : null;
|
|
240
|
+
if (plan) { // each code in only the bits it needs
|
|
241
|
+
Object.assign(chunk.cols[name], this.#write(packBits(d, n, plan)), { enc: c.enc, bits: plan.bits, bbase: plan.base }, plan.missing !== null ? { bmissing: plan.missing } : {});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const bytes = c.enc ? KINDS[c.enc.kind].bytes * n : d.BYTES_PER_ELEMENT * n;
|
|
245
|
+
Object.assign(chunk.cols[name], this.#write(new Uint8Array(d.buffer, d.byteOffset, bytes)), c.enc ? { enc: c.enc } : {});
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
ct.dispose();
|
|
249
|
+
}
|
|
250
|
+
this.meta.chunks.push(chunk);
|
|
251
|
+
this.meta.rows += n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Free text: the chunk's own bytes (only the values it uses), and each
|
|
255
|
+
// value's position in them.
|
|
256
|
+
#writeText(c, lo, hi) {
|
|
257
|
+
const { engine } = this;
|
|
258
|
+
const ptrs = engine.live(c.data), bytes = engine.live(c.strings);
|
|
259
|
+
let total = 0;
|
|
260
|
+
for (let i = lo; i < hi; i++) { const v = ptrs[i]; if (v === v) total += textLength(v); }
|
|
261
|
+
const outPtr = new Float64Array(hi - lo), outBytes = new Uint8Array(total);
|
|
262
|
+
for (let i = lo, at = 0; i < hi; i++) {
|
|
263
|
+
const v = ptrs[i];
|
|
264
|
+
if (v !== v) { outPtr[i - lo] = NaN; continue; }
|
|
265
|
+
const start = Math.floor(v / STR_SHIFT), field = v - start * STR_SHIFT, len = textLength(v);
|
|
266
|
+
outBytes.set(bytes.subarray(start, start + len), at);
|
|
267
|
+
outPtr[i - lo] = at * STR_SHIFT + field;
|
|
268
|
+
at += len;
|
|
269
|
+
}
|
|
270
|
+
const p = this.#write(new Uint8Array(outPtr.buffer)), s = this.#write(outBytes);
|
|
271
|
+
return { off: p.off, len: p.len, soff: s.off, slen: s.len };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#write(u8) {
|
|
275
|
+
const pad = (ALIGN - (this.pos % ALIGN)) % ALIGN;
|
|
276
|
+
if (pad) { this.file.write(new Uint8Array(pad), this.pos); this.pos += pad; }
|
|
277
|
+
const off = this.pos;
|
|
278
|
+
this.file.write(u8, off);
|
|
279
|
+
this.pos += u8.length;
|
|
280
|
+
return { off, len: u8.length };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Codes first, first + step, first + 2 * step... (row numbers, times at a
|
|
285
|
+
// fixed interval): { first, step }, else null.
|
|
286
|
+
function sequence(codes, n) {
|
|
287
|
+
if (n < 2) return null;
|
|
288
|
+
const first = codes[0], step = codes[1] - first;
|
|
289
|
+
for (let i = 2; i < n; i++) if (codes[i] !== first + i * step) return null;
|
|
290
|
+
return { first, step };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const jsonNumber = (v) => (Number.isFinite(v) ? v : String(v)); // JSON has no Infinity
|
|
294
|
+
const fromJson = (v) => (typeof v === 'string' ? Number(v) : v);
|
|
295
|
+
|
|
296
|
+
// ---- reading --------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
// The footer of a dataset file: { meta, footerStart, size }. A generator
|
|
299
|
+
// that yields { read: bytes, pos } (filled by readFooter / readFooterAsync).
|
|
300
|
+
function* footerG(file) {
|
|
301
|
+
const { size, name } = file;
|
|
302
|
+
const head = new Uint8Array(HEADER), tail = new Uint8Array(TAIL);
|
|
303
|
+
if (size < HEADER + TAIL) throw new Error(`vectro: ${name} is not a Vectro dataset (or wasn't closed)`);
|
|
304
|
+
yield { read: head, pos: 0 };
|
|
305
|
+
yield { read: tail, pos: size - TAIL };
|
|
306
|
+
if (!MAGIC.every((b, i) => head[i] === b && tail[16 + i] === b)) throw new Error(`vectro: ${name} is not a Vectro dataset (or wasn't closed)`);
|
|
307
|
+
const version = new DataView(head.buffer).getUint32(8, true);
|
|
308
|
+
if (version < 1 || version > VERSION) throw new Error(`vectro: ${name} is dataset version ${version}; this Vectro reads versions 1 to ${VERSION}`);
|
|
309
|
+
const dv = new DataView(tail.buffer);
|
|
310
|
+
const footerStart = dv.getFloat64(0, true), len = dv.getFloat64(8, true);
|
|
311
|
+
const json = new Uint8Array(len);
|
|
312
|
+
yield { read: json, pos: footerStart };
|
|
313
|
+
return { meta: JSON.parse(new TextDecoder().decode(json)), footerStart, size };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readFooter(file) {
|
|
317
|
+
const g = footerG(file);
|
|
318
|
+
for (let step = g.next(); ; ) {
|
|
319
|
+
if (step.done) return step.value;
|
|
320
|
+
file.readSync(step.value.read, step.value.pos);
|
|
321
|
+
step = g.next();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function readFooterAsync(file) {
|
|
326
|
+
const g = footerG(file);
|
|
327
|
+
for (let step = g.next(); ; ) {
|
|
328
|
+
if (step.done) return step.value;
|
|
329
|
+
await file.read(step.value.read, step.value.pos);
|
|
330
|
+
step = g.next();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// A dataset in a Blob or File (a browser's file input or drop; anything with
|
|
335
|
+
// slice()): read-only, async.
|
|
336
|
+
export function blobFile(blob, name = blob.name ?? 'dataset') {
|
|
337
|
+
return {
|
|
338
|
+
name,
|
|
339
|
+
size: blob.size,
|
|
340
|
+
async read(u8, pos) { u8.set(new Uint8Array(await blob.slice(pos, pos + u8.length).arrayBuffer())); },
|
|
341
|
+
close() {},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// A dataset at a URL, read with HTTP range requests (the server must answer
|
|
346
|
+
// "Range: bytes=a-b" with 206): only the parts a query needs are fetched.
|
|
347
|
+
export async function urlFile(url, { fetch: get = globalThis.fetch, headers = {} } = {}) {
|
|
348
|
+
const probe = await get(url, { headers: { ...headers, Range: 'bytes=0-0' } });
|
|
349
|
+
const range = probe.headers.get('content-range'); // bytes 0-0/12345
|
|
350
|
+
await probe.body?.cancel?.();
|
|
351
|
+
if (probe.status !== 206 || !range) throw new Error(`vectro: ${url} doesn't answer range requests (HTTP ${probe.status}); serve it with Range support, or download it first`);
|
|
352
|
+
const size = Number(range.split('/')[1]);
|
|
353
|
+
return {
|
|
354
|
+
name: url,
|
|
355
|
+
size,
|
|
356
|
+
pieceBytes: 8 << 20, // (fewer, bigger requests)
|
|
357
|
+
coalesce: 1 << 16, // neighbouring reads closer than this become one request
|
|
358
|
+
async read(u8, pos) {
|
|
359
|
+
if (!u8.length) return;
|
|
360
|
+
const res = await get(url, { headers: { ...headers, Range: `bytes=${pos}-${pos + u8.length - 1}` } });
|
|
361
|
+
if (res.status !== 206) throw new Error(`vectro: ${url}: HTTP ${res.status} for a range request`);
|
|
362
|
+
const got = new Uint8Array(await res.arrayBuffer());
|
|
363
|
+
if (got.length !== u8.length) throw new Error(`vectro: ${url}: expected ${u8.length} bytes, got ${got.length}`);
|
|
364
|
+
u8.set(got);
|
|
365
|
+
},
|
|
366
|
+
close() {},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A dataset from a path (Node), a URL, a File / Blob or a file object (see
|
|
371
|
+
// blobFile): reads its footer and returns the Dataset.
|
|
372
|
+
export async function openDatasetAsync(engine, source) {
|
|
373
|
+
let file = source;
|
|
374
|
+
if (typeof source === 'string') file = /^https?:/i.test(source) || !files ? await urlFile(source) : files.openRead(source);
|
|
375
|
+
else if (typeof Blob === 'function' && source instanceof Blob) file = blobFile(source);
|
|
376
|
+
let footer;
|
|
377
|
+
try { footer = file.readSync ? readFooter(file) : await readFooterAsync(file); } catch (err) { file.close(); throw err; }
|
|
378
|
+
return new Dataset(engine, file, footer);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const bytesOf = (a, len) => new Uint8Array(a.buffer, a.byteOffset, len);
|
|
382
|
+
|
|
383
|
+
export class Dataset {
|
|
384
|
+
// source: a path (Node), or a file object from blobFile / urlFile (those
|
|
385
|
+
// need openDatasetAsync, which reads the footer first).
|
|
386
|
+
constructor(engine, source, footer = null) {
|
|
387
|
+
const file = typeof source === 'string' ? need('opening a dataset by path').openRead(source) : source;
|
|
388
|
+
if (!footer) {
|
|
389
|
+
if (!file.readSync) throw new Error('vectro: open this dataset with openDatasetAsync (it is read asynchronously)');
|
|
390
|
+
try { footer = readFooter(file); } catch (err) { file.close(); throw err; }
|
|
391
|
+
}
|
|
392
|
+
const { meta, size } = footer;
|
|
393
|
+
this.engine = engine;
|
|
394
|
+
this.file = file;
|
|
395
|
+
this.path = file.name;
|
|
396
|
+
this.meta = meta;
|
|
397
|
+
this.bytes = size;
|
|
398
|
+
this.isDataset = true;
|
|
399
|
+
this.type = meta.type === 'f32' ? Float32Array : Float64Array;
|
|
400
|
+
// column info the planner and SQL look at (labels, dates, free text)
|
|
401
|
+
this.cols = new Map(meta.columns.map((c) => [c.name, c.kind === 'category'
|
|
402
|
+
? { dict: c.dict, codes: new Map(c.dict.map((s, i) => [s, i])) }
|
|
403
|
+
: { dict: null, codes: null, date: c.kind === 'date', strings: c.kind === 'text' || undefined }]));
|
|
404
|
+
this.lastRun = null; // { chunks, read, skipped, rows, bytesRead, ms } of the last query
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get length() { return this.meta.rows; }
|
|
408
|
+
get columnNames() { return this.meta.columns.map((c) => c.name); }
|
|
409
|
+
get chunkCount() { return this.meta.chunks.length; }
|
|
410
|
+
|
|
411
|
+
// ---- queries (see plan.mjs) -------------------------------------------------------
|
|
412
|
+
lazy() { return new Query({ op: 'scan', table: this }); }
|
|
413
|
+
filter(expr, params) { return this.lazy().filter(expr, params); }
|
|
414
|
+
derive(name, expr, params) { return this.lazy().derive(name, expr, params); }
|
|
415
|
+
select(...names) { return this.lazy().select(...names); }
|
|
416
|
+
sort(by) { return this.lazy().sort(by); }
|
|
417
|
+
top(k, by) { return this.lazy().top(k, by); }
|
|
418
|
+
limit(n, offset) { return this.lazy().limit(n, offset); }
|
|
419
|
+
join(other, opts) { return this.lazy().join(other, opts); }
|
|
420
|
+
groupBy(keys) { return this.lazy().groupBy(keys); }
|
|
421
|
+
agg(spec, params) { return this.lazy().agg(spec, params); }
|
|
422
|
+
aggAsync(spec, params) { return this.lazy().aggAsync(spec, params); }
|
|
423
|
+
count(expr) { return expr ? this.agg({ v: `count(${expr})` }).v : this.lazy().count(); }
|
|
424
|
+
async countAsync(expr) { return expr ? (await this.aggAsync({ v: `count(${expr})` })).v : this.lazy().countAsync(); }
|
|
425
|
+
sum(expr, params) { return this.agg({ v: `sum(${expr})` }, params).v; }
|
|
426
|
+
mean(expr, params) { return this.agg({ v: `mean(${expr})` }, params).v; }
|
|
427
|
+
min(expr, params) { return this.agg({ v: `min(${expr})` }, params).v; }
|
|
428
|
+
max(expr, params) { return this.agg({ v: `max(${expr})` }, params).v; }
|
|
429
|
+
head(n) { return this.lazy().limit(n).collect(); }
|
|
430
|
+
headAsync(n) { return this.lazy().limit(n).collectAsync(); }
|
|
431
|
+
toRows(limit = Infinity) { return this.lazy().toRows(limit); }
|
|
432
|
+
toRowsAsync(limit = Infinity) { return this.lazy().toRowsAsync(limit); }
|
|
433
|
+
collect() { return this.lazy().collect(); } // the whole dataset in memory
|
|
434
|
+
collectAsync() { return this.lazy().collectAsync(); }
|
|
435
|
+
explain() { return this.lazy().explain(); }
|
|
436
|
+
|
|
437
|
+
// Called by plan.mjs for a plan that reads a dataset.
|
|
438
|
+
runPlan(plan, sync) { return sync ? driveSync(runPlanG(plan)) : driveAsync(runPlanG(plan)); }
|
|
439
|
+
|
|
440
|
+
async close() {
|
|
441
|
+
if (this.file) { const f = this.file; this.file = null; f.close(); }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- chunks -------------------------------------------------------------------------
|
|
445
|
+
// Rows [lo, hi) of chunk i (a piece) as a table, with engine arrays ready
|
|
446
|
+
// to be read into. Every column has fixed-width rows, so a row range is one
|
|
447
|
+
// read per column (free text: its positions, plus the chunk's text bytes).
|
|
448
|
+
#allocChunk({ i, lo, hi }, names) {
|
|
449
|
+
const { engine } = this;
|
|
450
|
+
const ch = i < 0 ? { rows: 0, cols: {} } : this.meta.chunks[i];
|
|
451
|
+
const n = hi - lo;
|
|
452
|
+
const cols = new Map(), owned = [], ownedBuffers = [], reads = [], seqs = [], unpacks = [], temps = [];
|
|
453
|
+
for (const name of names) {
|
|
454
|
+
const m = ch.cols[name], base = this.cols.get(name);
|
|
455
|
+
if (base.strings) {
|
|
456
|
+
const data = engine.alloc(Float64Array, n), strings = engine.alloc(Uint8Array, m?.slen ?? 0);
|
|
457
|
+
cols.set(name, { data, dict: null, codes: null, strings });
|
|
458
|
+
ownedBuffers.push(strings);
|
|
459
|
+
if (m) reads.push([data, m.off + lo * 8, n * 8], [strings, m.soff, m.slen]);
|
|
460
|
+
} else if (m?.enc) {
|
|
461
|
+
const k = KINDS[m.enc.kind];
|
|
462
|
+
const data = engine.alloc(k.Type, n + SLACK / k.bytes);
|
|
463
|
+
cols.set(name, { data, dict: base.dict, codes: base.codes, date: base.date, enc: m.enc, plainType: this.type, rows: n });
|
|
464
|
+
if (m.seq) seqs.push([name, m.seq]);
|
|
465
|
+
else if (m.bits) { // bit-packed: the words holding rows lo..hi, unpacked after the read
|
|
466
|
+
const w0 = (lo * m.bits) / 32, w1 = Math.ceil((hi * m.bits) / 32); // (lo is a multiple of 65,536: whole words)
|
|
467
|
+
const words = engine.alloc(Uint8Array, (w1 - w0) * 4 + 8);
|
|
468
|
+
temps.push(words);
|
|
469
|
+
reads.push([words, m.off + w0 * 4, (w1 - w0) * 4]);
|
|
470
|
+
unpacks.push([words, data, n, { bits: m.bits, base: m.bbase, missing: m.bmissing ?? null }, k.bytes]);
|
|
471
|
+
} else reads.push([data, m.off + lo * k.bytes, n * k.bytes]);
|
|
472
|
+
} else {
|
|
473
|
+
const w = this.type.BYTES_PER_ELEMENT, data = engine.alloc(this.type, n);
|
|
474
|
+
cols.set(name, { data, dict: base.dict, codes: base.codes, date: base.date });
|
|
475
|
+
if (m) reads.push([data, m.off + lo * w, n * w]);
|
|
476
|
+
}
|
|
477
|
+
owned.push(name);
|
|
478
|
+
}
|
|
479
|
+
for (const c of cols.values()) { c.data = engine.live(c.data); if (c.strings) c.strings = engine.live(c.strings); }
|
|
480
|
+
for (const [name, { first, step }] of seqs) unpacks.push(['seq', cols.get(name).data, n, { first: first + lo * step, step }, KINDS[cols.get(name).enc.kind].bytes]);
|
|
481
|
+
const table = new Table(engine, cols, n, { owned, ownedBuffers, zones: false });
|
|
482
|
+
return { table, reads, unpacks, temps, bytes: reads.reduce((a, r) => a + r[2], 0) };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Bit-packed columns back to their codes, on every thread (a generator).
|
|
486
|
+
*#unpackG(unpacks) {
|
|
487
|
+
const { engine } = this;
|
|
488
|
+
for (const [words, data, n, plan, width] of unpacks) {
|
|
489
|
+
if (!n) continue;
|
|
490
|
+
if (words === 'seq') { // fixed-step column: rebuilt (on every thread)
|
|
491
|
+
if (!engine.jit) { const d = engine.live(data); for (let j = 0; j < n; j++) d[j] = plan.first + j * plan.step; continue; }
|
|
492
|
+
engine._bitKernels ??= new Map();
|
|
493
|
+
let v = engine._bitKernels.get(`seq|${width}`);
|
|
494
|
+
if (!v) engine._bitKernels.set(`seq|${width}`, (v = engine._register(seqModule(width, engine.isShared))));
|
|
495
|
+
yield* engine._unitsG(v, n, [engine.live(data).byteOffset, plan.first, plan.step]);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (!engine.jit) { unpackBits(engine.live(words), n, plan, engine.live(data)); continue; }
|
|
499
|
+
engine._bitKernels ??= new Map();
|
|
500
|
+
const key = `${width}|${plan.bits}`;
|
|
501
|
+
let v = engine._bitKernels.get(key);
|
|
502
|
+
if (!v) engine._bitKernels.set(key, (v = engine._register(unpackModule(width, plan.bits, engine.isShared))));
|
|
503
|
+
yield* engine._unitsG(v, n, [engine.live(words).byteOffset, engine.live(data).byteOffset, plan.base, plan.missing !== null ? 1 : 0, plan.missing ?? 0]);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The reads in pieces of at most READ_PIECE bytes: [array, file position, bytes, offset in array].
|
|
508
|
+
#pieces(reads) {
|
|
509
|
+
const out = [], size = this.file.pieceBytes ?? READ_PIECE;
|
|
510
|
+
for (const [arr, off, len] of reads) for (let at = 0; at < len; at += size) out.push([arr, off + at, Math.min(size, len - at), at]);
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
_readSync(piece, names) {
|
|
515
|
+
const { file } = this;
|
|
516
|
+
if (!file?.readSync) throw new Error(file ? 'vectro: this dataset is read asynchronously (a File, Blob or URL): use toRowsAsync(), aggAsync(), collectAsync()...' : 'vectro: this dataset is closed');
|
|
517
|
+
const { table, reads, unpacks, temps, bytes } = this.#allocChunk(piece, names);
|
|
518
|
+
const { engine } = this;
|
|
519
|
+
try {
|
|
520
|
+
const pieces = this.#pieces(reads);
|
|
521
|
+
if (file.fd !== undefined && engine.W > 0 && engine.canBlock !== false && pieces.length > 1) { // every thread reads pieces straight into engine memory
|
|
522
|
+
const jobs = engine.alloc(Float64Array, pieces.length * 4);
|
|
523
|
+
try {
|
|
524
|
+
const j = engine.live(jobs);
|
|
525
|
+
pieces.forEach(([arr, off, len, at], u) => { j.set([file.fd, off, len, engine.live(arr).byteOffset + at], u * 4); });
|
|
526
|
+
engine._drive((function* () {
|
|
527
|
+
yield { op: OP.READ, n: pieces.length, arrays: [jobs], F: (J, u) => file.readSync(new Uint8Array(engine.memory.buffer, J[u * 4 + 3], J[u * 4 + 2]), J[u * 4 + 1]) };
|
|
528
|
+
})());
|
|
529
|
+
} finally { engine.free(jobs); }
|
|
530
|
+
} else for (const [arr, off, len] of reads) file.readSync(bytesOf(engine.live(arr), len), off);
|
|
531
|
+
this.engine._drive(this.#unpackG(unpacks));
|
|
532
|
+
} catch (err) { table.dispose(); throw err; } finally { for (const t of temps) this.engine.free(t); }
|
|
533
|
+
if (this.lastRun) this.lastRun.bytesRead += bytes;
|
|
534
|
+
return table;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async _readAsync(piece, names) {
|
|
538
|
+
const { file } = this;
|
|
539
|
+
if (!file) throw new Error('vectro: this dataset is closed');
|
|
540
|
+
const { table, reads, unpacks, temps, bytes } = this.#allocChunk(piece, names);
|
|
541
|
+
try {
|
|
542
|
+
const view = (arr, at, len) => new Uint8Array(this.engine.live(arr).buffer, this.engine.live(arr).byteOffset + at, len);
|
|
543
|
+
if (file.coalesce) { // (a URL: neighbouring reads as one request each)
|
|
544
|
+
const sorted = reads.filter((r) => r[2]).sort((a, b) => a[1] - b[1]), groups = [];
|
|
545
|
+
for (const r of sorted) {
|
|
546
|
+
const g = groups[groups.length - 1];
|
|
547
|
+
if (g && r[1] - g.end <= file.coalesce && r[1] + r[2] - g.start <= (file.pieceBytes ?? READ_PIECE)) { g.items.push(r); g.end = Math.max(g.end, r[1] + r[2]); } else groups.push({ start: r[1], end: r[1] + r[2], items: [r] });
|
|
548
|
+
}
|
|
549
|
+
await Promise.all(groups.map(async (g) => {
|
|
550
|
+
const buf = new Uint8Array(g.end - g.start);
|
|
551
|
+
await file.read(buf, g.start);
|
|
552
|
+
for (const [arr, off, len] of g.items) view(arr, 0, len).set(buf.subarray(off - g.start, off - g.start + len));
|
|
553
|
+
}));
|
|
554
|
+
} else {
|
|
555
|
+
await Promise.all(this.#pieces(reads).map(async ([arr, off, len, at]) => { // (in pieces: several reads at once)
|
|
556
|
+
if (this.engine.isShared) return file.read(view(arr, at, len), off); // straight into engine memory
|
|
557
|
+
const buf = new Uint8Array(len); // (plain memory may move while the read runs)
|
|
558
|
+
await file.read(buf, off);
|
|
559
|
+
view(arr, at, len).set(buf);
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
await this.engine._driveAsync(this.#unpackG(unpacks));
|
|
563
|
+
} catch (err) { table.dispose(); throw err; } finally { for (const t of temps) this.engine.free(t); }
|
|
564
|
+
if (this.lastRun) this.lastRun.bytesRead += bytes;
|
|
565
|
+
return table;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The pieces { i, lo, hi } (rows lo..hi of chunk i) that can match every
|
|
569
|
+
// `column <op> number` condition: chunks by their min / max, then the
|
|
570
|
+
// blocks from the first that can match to the last.
|
|
571
|
+
_piecesFor(conds) {
|
|
572
|
+
const out = [];
|
|
573
|
+
this.meta.chunks.forEach((ch, i) => {
|
|
574
|
+
for (const { col, op, v } of conds) {
|
|
575
|
+
const z = ch.cols[col];
|
|
576
|
+
if (!z) continue;
|
|
577
|
+
if (z.missing === ch.rows) return; // only missing values: no comparison holds
|
|
578
|
+
if (!mayMatch(op, fromJson(z.min), fromJson(z.max), v)) return;
|
|
579
|
+
}
|
|
580
|
+
let first = 0, last = Math.ceil(ch.rows / BLOCK) - 1;
|
|
581
|
+
const zoned = conds.filter((c) => ch.cols[c.col]?.blocks);
|
|
582
|
+
if (zoned.length) {
|
|
583
|
+
const ok = (b) => zoned.every(({ col, op, v }) => { const z = ch.cols[col].blocks; return mayMatch(op, fromJson(z.min[b]), fromJson(z.max[b]), v); });
|
|
584
|
+
while (first <= last && !ok(first)) first++;
|
|
585
|
+
while (last >= first && !ok(last)) last--;
|
|
586
|
+
if (first > last) return;
|
|
587
|
+
}
|
|
588
|
+
out.push({ i, lo: first * BLOCK, hi: Math.min(ch.rows, (last + 1) * BLOCK) });
|
|
589
|
+
});
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---- running plans ----------------------------------------------------------------------
|
|
595
|
+
// A plan runs as a generator of requests: { run: node } (a plan over tables in
|
|
596
|
+
// memory), { read: dataset, i, names } (a chunk as a table), { prefetch: ... }
|
|
597
|
+
// (start reading a chunk; async only).
|
|
598
|
+
|
|
599
|
+
function driveSync(gen) {
|
|
600
|
+
let step = gen.next();
|
|
601
|
+
while (!step.done) {
|
|
602
|
+
const v = step.value;
|
|
603
|
+
let out, err = null;
|
|
604
|
+
try {
|
|
605
|
+
out = v.run ? execute(v.run, true) : v.read ? v.read._readSync(v.piece, v.names) : v.call ? v.call() : undefined; // ({ flush }: nothing to wait for)
|
|
606
|
+
if (typeof out?.then === 'function') throw new Error('vectro: this needs the async calls (toRowsAsync, aggAsync, writeDatasetAsync...)');
|
|
607
|
+
} catch (e) { err = e; }
|
|
608
|
+
step = err ? gen.throw(err) : gen.next(out);
|
|
609
|
+
}
|
|
610
|
+
return step.value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function driveAsync(gen) {
|
|
614
|
+
const pending = new Map(); // chunk -> promise of its table
|
|
615
|
+
try {
|
|
616
|
+
let step = gen.next();
|
|
617
|
+
while (!step.done) {
|
|
618
|
+
const v = step.value;
|
|
619
|
+
let out, err = null;
|
|
620
|
+
try {
|
|
621
|
+
if (v.run) out = await execute(v.run, false);
|
|
622
|
+
else if (v.flush) await v.flush.file?.flush?.(); // (a writer's bytes on disk before more come)
|
|
623
|
+
else if (v.call) out = await v.call();
|
|
624
|
+
else if (v.prefetch) {
|
|
625
|
+
const key = `${v.prefetch.path}\0${v.piece.i}\0${v.piece.lo}`;
|
|
626
|
+
if (!pending.has(key)) { const p = v.prefetch._readAsync(v.piece, v.names); p.catch(() => {}); pending.set(key, p); }
|
|
627
|
+
} else if (v.read) {
|
|
628
|
+
const key = `${v.read.path}\0${v.piece.i}\0${v.piece.lo}`, p = pending.get(key);
|
|
629
|
+
pending.delete(key);
|
|
630
|
+
out = await (p ?? v.read._readAsync(v.piece, v.names));
|
|
631
|
+
}
|
|
632
|
+
} catch (e) { err = e; }
|
|
633
|
+
step = err ? gen.throw(err) : gen.next(out);
|
|
634
|
+
}
|
|
635
|
+
return step.value;
|
|
636
|
+
} finally {
|
|
637
|
+
for (const p of pending.values()) p.then((t) => t.dispose(), () => {});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const BLOCKING = { aggregate: 'group', total: 'total', limit: 'head', sort: 'gather', output: 'collect', window: 'gather' };
|
|
642
|
+
const union = (a, b) => new Set([...a, ...b]);
|
|
643
|
+
const onKeys = (on) => (typeof on === 'string' || Array.isArray(on) ? [[on].flat(), [on].flat()] : [[on?.left].flat(), [on?.right].flat()]);
|
|
644
|
+
|
|
645
|
+
// The joins in a per-chunk plan, marked to return views (scans kept as they are).
|
|
646
|
+
function viewJoins(node) {
|
|
647
|
+
if (node.op === 'scan' || node.op === 'unmatched') return node;
|
|
648
|
+
if (node.op === 'join') return { ...node, opts: { ...node.opts, view: true }, left: viewJoins(node.left) };
|
|
649
|
+
return { ...node, input: viewJoins(node.input) };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// node with `target` (the same object) replaced by `repl`
|
|
653
|
+
function substitute(node, target, repl) {
|
|
654
|
+
if (node === target) return repl;
|
|
655
|
+
if (node.op === 'scan' || node.op === 'unmatched') return node;
|
|
656
|
+
if (node.op === 'join') return { ...node, left: substitute(node.left, target, repl), right: substitute(node.right, target, repl) };
|
|
657
|
+
return { ...node, input: substitute(node.input, target, repl) };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The dataset columns a plan reads (null: all of them). Mirrors what the
|
|
661
|
+
// executor in plan.mjs asks of its input.
|
|
662
|
+
function needs(node, need, ds) {
|
|
663
|
+
switch (node.op) {
|
|
664
|
+
case 'scan': return node.table === ds ? need : undefined;
|
|
665
|
+
case 'unmatched': return undefined;
|
|
666
|
+
case 'filter': return needs(node.input, need && union(need, vars(node.ast)), ds);
|
|
667
|
+
case 'derive':
|
|
668
|
+
if (!need) return needs(node.input, null, ds);
|
|
669
|
+
if (!need.has(node.name)) return needs(node.input, need, ds);
|
|
670
|
+
return needs(node.input, union([...need].filter((n) => n !== node.name), vars(node.ast)), ds);
|
|
671
|
+
case 'select': return needs(node.input, new Set(need ? node.names.filter((n) => need.has(n)) : node.names), ds);
|
|
672
|
+
case 'aggregate': return needs(node.input, new Set([...node.keys, ...Object.values(node.spec).flatMap((a) => [...vars(a)])]), ds);
|
|
673
|
+
case 'total': return needs(node.input, new Set(Object.values(node.spec).flatMap((a) => [...vars(a)])), ds);
|
|
674
|
+
case 'sort': return needs(node.input, need && union(need, node.by.flatMap((b) => [...vars(parse(String(b)))])), ds);
|
|
675
|
+
case 'limit': return needs(node.input, need, ds);
|
|
676
|
+
case 'output': return needs(node.input, null, ds);
|
|
677
|
+
case 'textmap': return needs(node.input, need && new Set([...[...need].filter((n) => n !== node.name), ...node.inputs]), ds);
|
|
678
|
+
case 'window': {
|
|
679
|
+
const made = new Set(node.items.map((it) => it.name));
|
|
680
|
+
const inputs = [...node.partition, ...node.order.map((o) => o.name), ...node.items.map((it) => it.arg).filter(Boolean)];
|
|
681
|
+
return needs(node.input, need && new Set([...[...need].filter((n) => !made.has(n)), ...inputs]), ds);
|
|
682
|
+
}
|
|
683
|
+
case 'join': {
|
|
684
|
+
const { left } = joinNames(node);
|
|
685
|
+
const needL = new Set(onKeys(node.opts.on)[0]);
|
|
686
|
+
for (const n of need ?? namesOf(node)) if (left.has(n)) needL.add(n);
|
|
687
|
+
for (const n of namesOf(node.right)) if (left.has(n)) needL.add(n); // (they decide the names of right columns)
|
|
688
|
+
return needs(node.left, needL, ds);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Give `result` the columns it shares with `temp`, then free temp.
|
|
695
|
+
function adopt(result, temp) {
|
|
696
|
+
if (!temp || result === temp) return;
|
|
697
|
+
if (result?.cols) {
|
|
698
|
+
for (const [name, c] of result.cols) {
|
|
699
|
+
const own = [...temp.cols].find(([n, tc]) => tc.data === c.data && temp.owned.includes(n));
|
|
700
|
+
if (own) { temp.owned = temp.owned.filter((n) => n !== own[0]); if (!result.owned.includes(name)) result.owned.push(name); }
|
|
701
|
+
if (c.strings && temp.ownedBuffers.includes(c.strings)) { temp.ownedBuffers = temp.ownedBuffers.filter((b) => b !== c.strings); result.ownedBuffers.push(c.strings); }
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
temp.dispose();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// A per-chunk result we can keep after the chunk is freed (else a copy).
|
|
708
|
+
function* keep(engine, r, chunk) {
|
|
709
|
+
const own = r !== chunk && r.columnNames.every((n) => r.owned.includes(n)) && [...r.cols.values()].every((c) => !c.strings || r.ownedBuffers.includes(c.strings));
|
|
710
|
+
if (own) return r;
|
|
711
|
+
const copy = copyRows(engine, [[r, 0, r.n]]);
|
|
712
|
+
if (r !== chunk) r.dispose();
|
|
713
|
+
return copy;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// A join whose two sides are both too big for memory (a "grace" hash join):
|
|
717
|
+
// both sides' rows go to partition files by ranges of the (first) join key,
|
|
718
|
+
// with the same split points; then for each range, its right rows are held
|
|
719
|
+
// in memory and its left rows stream past them chunk by chunk, through the
|
|
720
|
+
// rest of the per-chunk plan (acc). Rows come out range by range, not in the
|
|
721
|
+
// left table's order.
|
|
722
|
+
function* partitionedJoinG(J, viewTop, ds, names, acc) {
|
|
723
|
+
const { engine } = ds;
|
|
724
|
+
const [lk, rk] = onKeys(J.opts.on);
|
|
725
|
+
const Rds = findDataset(J.right);
|
|
726
|
+
const count = Math.min(64, Math.max(2, Math.ceil((2 * J.partitioned.rows) / tuning.joinRows)));
|
|
727
|
+
const splitters = (Rds.cols.has(rk[0]) ? zoneSplitters(Rds, rk[0], count) : null) ?? (ds.cols.has(lk[0]) ? zoneSplitters(ds, lk[0], count) : null) ?? [];
|
|
728
|
+
const rs = yield* Spill.create(engine, rk[0], splitters);
|
|
729
|
+
let ls;
|
|
730
|
+
try { ls = yield* Spill.create(engine, lk[0], splitters); } catch (err) { rs.cleanup(); throw err; }
|
|
731
|
+
stats.joinPartitions++;
|
|
732
|
+
try {
|
|
733
|
+
yield* runPlanG({ op: 'output', input: J.right }, rs);
|
|
734
|
+
const keep = namesOf(J.left).filter((n) => names.includes(n) || !ds.cols.has(n)); // (the left columns used, and derived ones)
|
|
735
|
+
yield* runPlanG({ op: 'output', input: { op: 'select', names: keep, input: J.left } }, ls);
|
|
736
|
+
const rp = yield* rs.closeG(), lp = yield* ls.closeG();
|
|
737
|
+
let ran = false;
|
|
738
|
+
for (let j = 0; j < lp.length && !acc.done; j++) {
|
|
739
|
+
if (!lp[j] || (!rp[j] && (J.opts.how ?? 'inner') !== 'left')) continue; // (no rows can come out of this range)
|
|
740
|
+
const R = rp[j] ? yield* readAll(engine, rp[j]) : emptyLike(engine, rs);
|
|
741
|
+
const L = yield* tempDataset(engine, lp[j]);
|
|
742
|
+
try {
|
|
743
|
+
for (let i = 0; i < L.chunkCount && !acc.done; i++) {
|
|
744
|
+
const c = yield { read: L, piece: { i, lo: 0, hi: L.meta.chunks[i].rows }, names: L.columnNames };
|
|
745
|
+
try {
|
|
746
|
+
ds.lastRun.rows += c.n;
|
|
747
|
+
ran = true;
|
|
748
|
+
yield* acc.chunk(substitute(viewTop, J, { ...J, left: { op: 'scan', table: c }, right: { op: 'scan', table: R } }), c);
|
|
749
|
+
} finally { c.dispose(); }
|
|
750
|
+
}
|
|
751
|
+
} finally {
|
|
752
|
+
L.close();
|
|
753
|
+
const builds = engine._joinBuilds?.get(R.cols.get(rk[0])?.data); // (this range's hash table: freed now)
|
|
754
|
+
if (builds) { for (const b of builds.values()) freeBuild(engine, b); engine._joinBuilds.delete(R.cols.get(rk[0]).data); }
|
|
755
|
+
R.dispose();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (!ran) { // no rows on one side: the plan still runs once, on no rows, for the result's shape
|
|
759
|
+
const scan = (function find(n) { return n.op === 'scan' ? n : find(n.op === 'join' ? n.left : n.input); })(J.left);
|
|
760
|
+
const empty = yield { read: ds, piece: { i: -1, lo: 0, hi: 0 }, names };
|
|
761
|
+
const R = emptyLike(engine, rs);
|
|
762
|
+
try { yield* acc.chunk(substitute(substitute(viewTop, J, { ...J, right: { op: 'scan', table: R } }), scan, { op: 'scan', table: empty }), empty); } finally { empty.dispose(); R.dispose(); }
|
|
763
|
+
}
|
|
764
|
+
} finally { rs.cleanup(); ls.cleanup(); }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// A dataset's rows as one table in memory.
|
|
768
|
+
function* readAll(engine, path) {
|
|
769
|
+
const D = yield* tempDataset(engine, path), parts = [];
|
|
770
|
+
try {
|
|
771
|
+
yield* eachChunk(D, function* (t) { parts.push(t); });
|
|
772
|
+
return copyRows(engine, parts.map((t) => [t, 0, t.n]));
|
|
773
|
+
} finally { for (const t of parts) t.dispose(); D.close(); }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// A table with no rows and the columns a spill was given.
|
|
777
|
+
function emptyLike(engine, spill) {
|
|
778
|
+
const w = spill.writers.find((x) => x.meta);
|
|
779
|
+
const cols = new Map(), owned = [];
|
|
780
|
+
for (const c of w.meta.columns) {
|
|
781
|
+
const labels = c.kind === 'category' ? w.labels.get(c.name).dict : null;
|
|
782
|
+
cols.set(c.name, c.kind === 'text' ? { data: engine.alloc(Float64Array, 0), dict: null, codes: null, strings: engine.alloc(Uint8Array, 0) } : { data: engine.alloc(Float64Array, 0), dict: labels, codes: labels && new Map(labels.map((s, i) => [s, i])), date: c.kind === 'date' });
|
|
783
|
+
owned.push(c.name);
|
|
784
|
+
}
|
|
785
|
+
return new Table(engine, cols, 0, { owned, ownedBuffers: [...cols.values()].filter((c) => c.strings).map((c) => c.strings) });
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Every join's right side (the build side) as one plain table, made once:
|
|
789
|
+
// a dataset there is read in full (its matching rows and used columns), a
|
|
790
|
+
// filtered or packed table is collected / decoded, so each chunk's join finds
|
|
791
|
+
// the same table (and its hash table, kept in engine._joinBuilds).
|
|
792
|
+
function* materializeRights(node, temps) {
|
|
793
|
+
if (node.op === 'scan' || node.op === 'unmatched') return node; // (unmatched: a FULL JOIN's right rows, made by its join)
|
|
794
|
+
if (node.op === 'union') { // each input with a dataset in it: read (its matching rows) into memory
|
|
795
|
+
const inputs = [];
|
|
796
|
+
for (const input of node.inputs) {
|
|
797
|
+
if (!findDataset(input)) { inputs.push(yield* materializeRights(input, temps)); continue; }
|
|
798
|
+
const t = yield* runPlanG({ op: 'output', input });
|
|
799
|
+
temps.push(t);
|
|
800
|
+
inputs.push({ op: 'scan', table: t });
|
|
801
|
+
}
|
|
802
|
+
return { ...node, inputs };
|
|
803
|
+
}
|
|
804
|
+
if (node.op === 'join') {
|
|
805
|
+
const left = yield* materializeRights(node.left, temps);
|
|
806
|
+
let right = node.right;
|
|
807
|
+
const L = findDataset(left), Rds = findDataset(right);
|
|
808
|
+
if (L && Rds) { // both on disk: too many right rows to hold? then both sides go to disk by key range
|
|
809
|
+
const [lk, rk] = onKeys(node.opts.on);
|
|
810
|
+
const textKey = L.cols.get(lk[0])?.dict || Rds.cols.get(rk[0])?.dict; // (codes differ between files: not by range)
|
|
811
|
+
const rows = textKey ? 0 : (yield* runPlanG({ op: 'total', spec: { n: parse('count()') }, input: right })).n;
|
|
812
|
+
if (rows > tuning.joinRows) return { ...node, left, right, partitioned: { rows } };
|
|
813
|
+
}
|
|
814
|
+
const plain = right.op === 'scan' && !right.table.isDataset && !right.table.pred && ![...right.table.cols.values()].some((c) => c.enc);
|
|
815
|
+
if (!plain) {
|
|
816
|
+
let t = findDataset(right) ? yield* runPlanG({ op: 'output', input: right }) : yield { run: { op: 'output', input: right } };
|
|
817
|
+
if (t.pred || [...t.cols.values()].some((c) => c.enc) || t.owned.length < t.cols.size) { const copy = copyRows(t.engine, [[t, 0, t.n]]); adopt(null, t); t = copy; }
|
|
818
|
+
temps.push(t);
|
|
819
|
+
right = { op: 'scan', table: t };
|
|
820
|
+
}
|
|
821
|
+
return { ...node, left, right };
|
|
822
|
+
}
|
|
823
|
+
return { ...node, input: yield* materializeRights(node.input, temps) };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// sink: a DatasetWriter that takes the result (matching rows go straight to
|
|
827
|
+
// it, chunk by chunk, so the result needn't fit in memory either).
|
|
828
|
+
export function* runPlanG(plan, sink = null) {
|
|
829
|
+
const temps = [];
|
|
830
|
+
let result;
|
|
831
|
+
try {
|
|
832
|
+
plan = yield* materializeRights(plan, temps);
|
|
833
|
+
const path = [plan];
|
|
834
|
+
for (let n = plan; n.op !== 'scan' && n.op !== 'union'; ) { n = n.op === 'join' ? n.left : n.input; path.push(n); }
|
|
835
|
+
const scan = path[path.length - 1];
|
|
836
|
+
if (!scan.table?.isDataset) result = yield { run: plan };
|
|
837
|
+
else result = yield* runChunksG(plan, path, sink);
|
|
838
|
+
if (sink && result) { // (the result itself was built in memory)
|
|
839
|
+
if (result.cols) sink.append(result);
|
|
840
|
+
else sink.append(Object.fromEntries(Object.entries(result).map(([k, v]) => [k, [v]])));
|
|
841
|
+
}
|
|
842
|
+
return sink ? null : result;
|
|
843
|
+
} finally {
|
|
844
|
+
for (const t of temps) adopt(sink ? null : result, t);
|
|
845
|
+
if (sink && result?.cols) result.dispose();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function* runChunksG(plan, path, sink) {
|
|
850
|
+
const scan = path[path.length - 1], ds = scan.table, { engine } = ds;
|
|
851
|
+
const t0 = performance.now();
|
|
852
|
+
ds.lastRun = { chunks: ds.chunkCount, read: 0, skipped: 0, rows: 0, bytesRead: 0, ms: 0 };
|
|
853
|
+
// the first step above the dataset that needs all rows
|
|
854
|
+
let bi = path.length - 2;
|
|
855
|
+
while (!BLOCKING[path[bi].op]) bi--;
|
|
856
|
+
let B = path[bi], kind = BLOCKING[B.op];
|
|
857
|
+
const below = path.slice(bi + 1, -1); // (the steps between the dataset and B: what each chunk runs)
|
|
858
|
+
if (B.op === 'sort' && bi > 0 && path[bi - 1].op === 'limit' && path[bi - 1].input === B) { B = path[bi - 1]; kind = 'top'; }
|
|
859
|
+
const top = kind === 'top' ? B.input.input : B.input; // what runs on each chunk
|
|
860
|
+
// columns to read, and the chunks the filters right above the dataset allow
|
|
861
|
+
const need = needs(plan, null, ds);
|
|
862
|
+
let names = need ? ds.columnNames.filter((n) => need.has(n)) : ds.columnNames;
|
|
863
|
+
if (!names.length) names = [smallestColumn(ds)]; // (count() still needs the row count)
|
|
864
|
+
const conds = [];
|
|
865
|
+
for (let k = path.length - 2; k >= 0 && path[k].op === 'filter'; k--) {
|
|
866
|
+
let rewritten;
|
|
867
|
+
try { rewritten = toSource(rewriteText(path[k].ast, ds.cols, toSource(path[k].ast))); } catch { continue; } // (the run reports it)
|
|
868
|
+
const round = ds.type === Float32Array ? Math.fround : (v) => v;
|
|
869
|
+
for (const c of conditions(rewritten, ds.cols, {})) conds.push({ ...c, v: round(c.v) });
|
|
870
|
+
}
|
|
871
|
+
const viewTop = viewJoins(top); // (joins on each chunk: unique right keys as views, see joinViewG)
|
|
872
|
+
const pieces = ds._piecesFor(conds);
|
|
873
|
+
ds.lastRun.skipped = ds.chunkCount - pieces.length;
|
|
874
|
+
const list = pieces.length ? pieces : [{ i: -1, lo: 0, hi: 0 }]; // (no rows, but the plan still runs once for the result's shape)
|
|
875
|
+
// columns made below B (names made above it, such as a result's renames, are not: B sees the dataset's)
|
|
876
|
+
const derived = new Set(below.filter((n) => n.op === 'derive' || n.op === 'textmap' || n.op === 'join').flatMap((n) => (n.op === 'join' ? namesOf(n.right) : [n.name])));
|
|
877
|
+
// derived columns that are arithmetic on number columns of the dataset (a top-k cutoff can compare them)
|
|
878
|
+
derived.numeric = new Set(below.filter((n) => n.op === 'derive' && arithmetic(n.ast, ds)).map((n) => n.name));
|
|
879
|
+
const acc = accumulator(kind, B, engine, sink, ds, derived);
|
|
880
|
+
let finalTable = null;
|
|
881
|
+
engine._joinRuns = (engine._joinRuns ?? 0) + 1; // joins keep their right side's build while a dataset runs
|
|
882
|
+
engine._joinBuilds ??= new Map();
|
|
883
|
+
try {
|
|
884
|
+
let pj = viewTop;
|
|
885
|
+
while (pj.op !== 'scan' && !(pj.op === 'join' && pj.partitioned)) pj = pj.op === 'join' ? pj.left : pj.input;
|
|
886
|
+
if (pj.op === 'join') yield* partitionedJoinG(pj, viewTop, ds, names, acc);
|
|
887
|
+
else {
|
|
888
|
+
// top-k: each chunk is narrowed to the blocks that can still beat the
|
|
889
|
+
// k-th row so far (acc.prune), just before it is read or prefetched
|
|
890
|
+
let at = 0, pruned = 0;
|
|
891
|
+
const next = () => { // the next piece to read (null: none left)
|
|
892
|
+
while (at < list.length) {
|
|
893
|
+
const p = list[at++], q = acc.prune && p.i >= 0 ? acc.prune(p) : p;
|
|
894
|
+
if (q) return q;
|
|
895
|
+
pruned++;
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
};
|
|
899
|
+
let piece = next(), k = 0;
|
|
900
|
+
while (piece) {
|
|
901
|
+
if (k++ > 0 && acc.done) break;
|
|
902
|
+
const chunk = yield { read: ds, piece, names };
|
|
903
|
+
// (top-k: no prefetch during the first chunk, so the next one is narrowed by its result)
|
|
904
|
+
const ahead = acc.done || (acc.prune && !acc.ready() && k === 1) ? null : next();
|
|
905
|
+
if (ahead) yield { prefetch: ds, piece: ahead, names };
|
|
906
|
+
ds.lastRun.read += piece.i >= 0 ? 1 : 0;
|
|
907
|
+
ds.lastRun.rows += chunk.n;
|
|
908
|
+
try {
|
|
909
|
+
yield* acc.chunk(substitute(viewTop, scan, { op: 'scan', table: chunk }), chunk);
|
|
910
|
+
} finally {
|
|
911
|
+
chunk.dispose();
|
|
912
|
+
}
|
|
913
|
+
piece = ahead ?? (acc.done ? null : next());
|
|
914
|
+
}
|
|
915
|
+
ds.lastRun.skipped += pruned;
|
|
916
|
+
}
|
|
917
|
+
const merged = yield* acc.finish();
|
|
918
|
+
if (kind === 'total' || merged === null) return merged;
|
|
919
|
+
if (merged.isDataset) { // spilled groups: the rest of the plan reads them from disk
|
|
920
|
+
try { return yield* runPlanG(substitute(plan, B, { op: 'scan', table: merged }), sink); } finally { merged.close(); removeTemp(merged.temporary); }
|
|
921
|
+
}
|
|
922
|
+
finalTable = merged;
|
|
923
|
+
const rest = substitute(plan, kind === 'group' ? B : top, { op: 'scan', table: merged });
|
|
924
|
+
const result = yield { run: rest };
|
|
925
|
+
adopt(result, merged);
|
|
926
|
+
finalTable = null;
|
|
927
|
+
return result;
|
|
928
|
+
} finally {
|
|
929
|
+
finalTable?.dispose();
|
|
930
|
+
acc.dispose();
|
|
931
|
+
if (--engine._joinRuns === 0) {
|
|
932
|
+
for (const builds of engine._joinBuilds.values()) for (const b of builds.values()) freeBuild(engine, b);
|
|
933
|
+
engine._joinBuilds = null;
|
|
934
|
+
}
|
|
935
|
+
ds.lastRun.ms = performance.now() - t0;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function smallestColumn(ds) {
|
|
940
|
+
const ch = ds.meta.chunks[0];
|
|
941
|
+
if (!ch) return ds.columnNames[0];
|
|
942
|
+
return ds.columnNames.filter((n) => !ds.cols.get(n).strings).sort((a, b) => (ch.cols[a]?.len ?? 0) - (ch.cols[b]?.len ?? 0))[0] ?? ds.columnNames[0];
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// How per-chunk results are kept and merged, per kind of step:
|
|
946
|
+
// chunk(P, table): run the step on one chunk's plan P (may yield runs)
|
|
947
|
+
// finish(): the merged result (a table, or totals for 'total')
|
|
948
|
+
function accumulator(kind, B, engine, sink, ds, derived) {
|
|
949
|
+
if (kind === 'group' || kind === 'total') return groupAccumulator(kind, B, engine, ds, derived);
|
|
950
|
+
if (kind === 'gather' && sink && B.op === 'sort') { const first = firstSortKey(B.by[0], ds, derived); if (first && /^[A-Za-z_]\w*$/.test(first.expr)) return sortSpillAccumulator(B, engine, ds, derived, first); }
|
|
951
|
+
const parts = [];
|
|
952
|
+
let rows = 0;
|
|
953
|
+
// top-k: once k rows are kept, a row whose first key is worse than the
|
|
954
|
+
// k-th can't make it, so later chunks get that cutoff as a filter (ties pass)
|
|
955
|
+
const k = B.n + (B.offset ?? 0);
|
|
956
|
+
const first = kind === 'top' && k > 0 ? firstSortKey(B.input.by[0], ds, derived, true) : null;
|
|
957
|
+
let cutoff = null;
|
|
958
|
+
// the leading sort keys that are plain number columns of the dataset (their
|
|
959
|
+
// min / max per block are in the footer), and the k-th row's values of them
|
|
960
|
+
const zkeys = [];
|
|
961
|
+
if (first) for (const by of B.input.by) {
|
|
962
|
+
const key = firstSortKey(by, ds, derived, true);
|
|
963
|
+
const c = key && /^[A-Za-z_]\w*$/.test(key.expr) && !derived.has(key.expr) && ds.cols.get(key.expr);
|
|
964
|
+
if (!c || c.dict || c.strings) break;
|
|
965
|
+
zkeys.push(key);
|
|
966
|
+
}
|
|
967
|
+
// the leading sort keys that sort by value (the k-th row's values of them are found)
|
|
968
|
+
const ckeys = [];
|
|
969
|
+
if (first) for (const by of B.input.by) { const key = firstSortKey(by, ds, derived, true); if (!key) break; ckeys.push(key); }
|
|
970
|
+
let bound = null, dense = false, cutFirst = null;
|
|
971
|
+
const acc = {
|
|
972
|
+
done: false,
|
|
973
|
+
ready: () => bound !== null,
|
|
974
|
+
// piece narrowed to the blocks that may hold a row at least as good as
|
|
975
|
+
// the k-th so far (from the first such block to the last; null: none): a
|
|
976
|
+
// block whose max (desc) / min (asc) of the first key is worse can't, nor
|
|
977
|
+
// one that at best ties it there and is worse on the next key, and so on
|
|
978
|
+
// (missing values sort last: never better)
|
|
979
|
+
prune: zkeys.length ? (piece) => {
|
|
980
|
+
if (!bound) return piece;
|
|
981
|
+
const ch = ds.meta.chunks[piece.i];
|
|
982
|
+
const b0 = Math.floor(piece.lo / BLOCK), b1 = Math.ceil(piece.hi / BLOCK);
|
|
983
|
+
const zone = (z, b, which) => fromJson(z.blocks && b1 - b0 > 0 ? z.blocks[which][b] : z[which]);
|
|
984
|
+
const may = (b) => {
|
|
985
|
+
for (let j = 0; j < zkeys.length; j++) {
|
|
986
|
+
const z = ch.cols[zkeys[j].expr], v = bound[j];
|
|
987
|
+
if (!z || v !== v) return true;
|
|
988
|
+
if (z.missing === ch.rows) return false; // (only missing values: last)
|
|
989
|
+
const lo = zone(z, b, 'min'), hi = zone(z, b, 'max');
|
|
990
|
+
if (zkeys[j].desc ? hi > v : lo < v) return true; // may be better on this key
|
|
991
|
+
if (zkeys[j].desc ? hi < v : lo > v) return false; // worse on this key
|
|
992
|
+
if (!(lo <= v && v <= hi)) return false; // (an all-missing block)
|
|
993
|
+
// at best a tie on this key: the next key decides
|
|
994
|
+
}
|
|
995
|
+
return true; // (ties with the k-th pass: the merge picks)
|
|
996
|
+
};
|
|
997
|
+
let first = b0, last = b1 - 1;
|
|
998
|
+
while (first <= last && !may(first)) first++;
|
|
999
|
+
while (last > first && !may(last)) last--;
|
|
1000
|
+
if (first > last) return null;
|
|
1001
|
+
return { i: piece.i, lo: Math.max(piece.lo, first * BLOCK), hi: Math.min(piece.hi, (last + 1) * BLOCK) };
|
|
1002
|
+
} : null,
|
|
1003
|
+
*chunk(P, chunk) {
|
|
1004
|
+
let node;
|
|
1005
|
+
if (kind === 'top') {
|
|
1006
|
+
node = { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: P } } };
|
|
1007
|
+
if (cutoff !== null) { // only rows at least as good as the k-th so far: keep them all (the merge picks the best)
|
|
1008
|
+
const F = { op: 'filter', ast: cutFirst, input: P };
|
|
1009
|
+
node = dense ? { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: F } } } : { op: 'output', input: F };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
else if (kind === 'head') node = { op: 'output', input: { op: 'limit', n: B.n + (B.offset ?? 0) - rows, offset: 0, input: P } };
|
|
1013
|
+
else node = { op: 'output', input: P };
|
|
1014
|
+
let r = yield { run: node };
|
|
1015
|
+
if (kind === 'top' && cutoff !== null && !dense && r.n > Math.max(8 * k, 1 << 14)) {
|
|
1016
|
+
// many rows beat the cutoff (e.g. newest first over time-ordered chunks):
|
|
1017
|
+
// the best k of them now, and top-k straight away on the chunks after
|
|
1018
|
+
dense = true;
|
|
1019
|
+
const own = yield* keep(engine, r, chunk);
|
|
1020
|
+
const best = yield { run: { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: { op: 'scan', table: own } } } } };
|
|
1021
|
+
r = yield* keep(engine, best, own);
|
|
1022
|
+
if (r !== own) own.dispose();
|
|
1023
|
+
}
|
|
1024
|
+
if (sink && kind === 'collect') { // straight to the dataset being written (or to spill partitions)
|
|
1025
|
+
if (sink.appendG) { yield* sink.appendG(yield* keep(engine, r, chunk)); return; }
|
|
1026
|
+
try { sink.append(r); } finally { if (r !== chunk) r.dispose(); }
|
|
1027
|
+
yield { flush: sink };
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
parts.push(yield* keep(engine, r, chunk));
|
|
1031
|
+
rows += r.n;
|
|
1032
|
+
if (kind === 'head' && rows >= B.n + (B.offset ?? 0)) acc.done = true;
|
|
1033
|
+
if (kind === 'top' && (parts.length >= 16 || (first && rows >= k && parts.length > 1))) { // keep the best k of what we have
|
|
1034
|
+
const all = merge();
|
|
1035
|
+
const best = yield { run: { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: { op: 'scan', table: all } } } } };
|
|
1036
|
+
parts.push(yield* keep(engine, best, all));
|
|
1037
|
+
all.dispose();
|
|
1038
|
+
rows = parts[0].n;
|
|
1039
|
+
}
|
|
1040
|
+
if (first && rows >= k && parts.length === 1) { // the k-th row's first key
|
|
1041
|
+
const spec = {};
|
|
1042
|
+
ckeys.forEach((c, j) => { spec[`c${j}`] = parse(`min(${c.expr})`); });
|
|
1043
|
+
const r = yield { run: { op: 'total', spec, input: { op: 'limit', n: 1, offset: k - 1, input: { op: 'scan', table: parts[0] } } } };
|
|
1044
|
+
const num = (x) => (x instanceof Date ? x.getTime() : typeof x === 'number' ? x : NaN);
|
|
1045
|
+
const vals = ckeys.map((_, j) => num(r[`c${j}`]));
|
|
1046
|
+
if (vals[0] === vals[0]) {
|
|
1047
|
+
cutoff = vals[0];
|
|
1048
|
+
cutFirst = parse(`(${first.expr}) ${first.desc ? '>=' : '<='} ${toSource({ t: 'num', v: cutoff })}`);
|
|
1049
|
+
}
|
|
1050
|
+
if (zkeys.length) bound = vals.slice(0, zkeys.length);
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
*finish() {
|
|
1054
|
+
if (sink && kind === 'collect') return null;
|
|
1055
|
+
return merge();
|
|
1056
|
+
},
|
|
1057
|
+
dispose() { for (const p of parts.splice(0)) p.dispose(); },
|
|
1058
|
+
};
|
|
1059
|
+
// all parts as one table (parts emptied)
|
|
1060
|
+
const merge = () => {
|
|
1061
|
+
const ps = parts.splice(0);
|
|
1062
|
+
if (ps.length === 1) return ps[0];
|
|
1063
|
+
try { return copyRows(engine, ps.map((p) => [p, 0, p.n])); } finally { for (const p of ps) p.dispose(); }
|
|
1064
|
+
};
|
|
1065
|
+
return acc;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ---- spilling: partial results on disk, split by key ranges --------------------
|
|
1069
|
+
// Rows go to partition files by their first key: partition j holds the keys
|
|
1070
|
+
// in [splitters[j-1], splitters[j]), and the last one the missing keys, so
|
|
1071
|
+
// partitions in order hold the keys in order, and each can be finished on
|
|
1072
|
+
// its own.
|
|
1073
|
+
const PART = '__vectro_part';
|
|
1074
|
+
class Spill {
|
|
1075
|
+
// (made with yield* Spill.create(...): the files may open asynchronously)
|
|
1076
|
+
constructor(engine, key, splitters, dir, paths, writers) {
|
|
1077
|
+
const P = splitters.length + 2;
|
|
1078
|
+
Object.assign(this, { engine, dir, paths, writers, rows: 0 });
|
|
1079
|
+
this.partAst = parse(`(${key}) != (${key}) ? ${P - 1} : (0${splitters.map((v) => ` + ((${key}) >= ${toSource({ t: 'num', v })})`).join('')})`);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
static *create(engine, key, splitters) {
|
|
1083
|
+
const store = temp();
|
|
1084
|
+
const dir = yield { call: () => store.dir('vectro-spill-') };
|
|
1085
|
+
const paths = Array.from({ length: splitters.length + 2 }, (_, j) => store.join(dir, `part-${j}.vectro`));
|
|
1086
|
+
const writers = [];
|
|
1087
|
+
try { for (const p of paths) writers.push(yield* tempWriter(engine, p, { rowsPerChunk: 1 << 18 })); } catch (err) { for (const w of writers) w.abort(); removeTemp(dir); throw err; }
|
|
1088
|
+
return new Spill(engine, key, splitters, dir, paths, writers);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
*appendG(t) { yield* this.add(t); } // (as a sink: runPlanG writes a query's rows here)
|
|
1092
|
+
|
|
1093
|
+
// t's rows, each to its partition (t is freed)
|
|
1094
|
+
*add(t) {
|
|
1095
|
+
try {
|
|
1096
|
+
if (!t.n) return;
|
|
1097
|
+
this.rows += t.n;
|
|
1098
|
+
const S = yield { run: { op: 'output', input: { op: 'sort', by: [PART], input: { op: 'derive', name: PART, ast: this.partAst, input: { op: 'scan', table: t } } } } };
|
|
1099
|
+
const touched = new Set();
|
|
1100
|
+
try {
|
|
1101
|
+
const part = this.engine.live(S.cols.get(PART).data), view = S.select(...t.columnNames);
|
|
1102
|
+
for (let a = 0; a < S.n; ) {
|
|
1103
|
+
const j = part[a];
|
|
1104
|
+
let b = a;
|
|
1105
|
+
while (b < S.n && part[b] === j) b++;
|
|
1106
|
+
this.writers[j].append(view, a, b);
|
|
1107
|
+
touched.add(this.writers[j]);
|
|
1108
|
+
a = b;
|
|
1109
|
+
}
|
|
1110
|
+
} finally { S.dispose(); }
|
|
1111
|
+
for (const w of touched) yield { flush: w }; // (browser files: written before more comes)
|
|
1112
|
+
} finally { t.dispose(); }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// the partition files in key order (null: no rows), closed
|
|
1116
|
+
*closeG() {
|
|
1117
|
+
for (const w of this.writers) { if (!w.meta) w.abort(); else yield* closeWriter(w); }
|
|
1118
|
+
return this.paths.map((p, j) => (this.writers[j].meta ? p : null));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
cleanup() { for (const w of this.writers) w.abort(); removeTemp(this.dir); }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Up to P - 1 split points for a column, from the dataset's min / max per
|
|
1125
|
+
// block (an even spread of the values), or null if they're all the same.
|
|
1126
|
+
function zoneSplitters(ds, name, P) {
|
|
1127
|
+
const vals = [];
|
|
1128
|
+
for (const ch of ds.meta.chunks) {
|
|
1129
|
+
const z = ch.cols[name];
|
|
1130
|
+
if (!z || z.missing === ch.rows) continue;
|
|
1131
|
+
const add = (v) => { v = fromJson(v); if (Number.isFinite(v)) vals.push(v); };
|
|
1132
|
+
if (z.blocks) for (let b = 0; b < z.blocks.min.length; b++) { add(z.blocks.min[b]); add(z.blocks.max[b]); }
|
|
1133
|
+
else { add(z.min); add(z.max); }
|
|
1134
|
+
}
|
|
1135
|
+
return quantiles(vals.sort((a, b) => a - b), P);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function quantiles(sorted, P) {
|
|
1139
|
+
const out = [];
|
|
1140
|
+
for (let i = 1; i < P; i++) {
|
|
1141
|
+
const v = sorted[Math.floor((i * sorted.length) / P)];
|
|
1142
|
+
if (v !== undefined && v > sorted[0] && (!out.length || v > out[out.length - 1])) out.push(v);
|
|
1143
|
+
}
|
|
1144
|
+
return out.length ? out : null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Every chunk of a dataset as tables (all columns), one at a time.
|
|
1148
|
+
function* eachChunk(D, fn) {
|
|
1149
|
+
for (let i = 0; i < D.chunkCount; i++) {
|
|
1150
|
+
const t = yield { read: D, piece: { i, lo: 0, hi: D.meta.chunks[i].rows }, names: D.columnNames };
|
|
1151
|
+
yield* fn(t);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// A full sort whose result goes to a dataset (it needn't fit in memory):
|
|
1156
|
+
// the rows go to partitions by the first sort key, each partition is sorted
|
|
1157
|
+
// in memory, and the partitions are written in order (missing keys last).
|
|
1158
|
+
function sortSpillAccumulator(B, engine, ds, derived, first) {
|
|
1159
|
+
let spill = null, empty = null; // (empty: the result's columns, if no rows match)
|
|
1160
|
+
return {
|
|
1161
|
+
done: false,
|
|
1162
|
+
*chunk(P, chunk) {
|
|
1163
|
+
const t = yield* keep(engine, yield { run: { op: 'output', input: P } }, chunk);
|
|
1164
|
+
if (!t.n && !empty) { empty = t; return; }
|
|
1165
|
+
if (!spill) {
|
|
1166
|
+
const count = Math.min(64, Math.max(2, Math.ceil((2 * ds.length) / tuning.partitionRows)));
|
|
1167
|
+
let splitters = !derived.has(first.expr) && ds.cols.has(first.expr) ? zoneSplitters(ds, first.expr, count) : null;
|
|
1168
|
+
if (!splitters && t.cols.has(first.expr)) { // from the first rows
|
|
1169
|
+
const v = engine.live(t.cols.get(first.expr).data), vals = [];
|
|
1170
|
+
for (let i = 0; i < t.n; i++) if (v[i] === v[i]) vals.push(v[i]);
|
|
1171
|
+
splitters = quantiles(vals.sort((a, b) => a - b), count);
|
|
1172
|
+
}
|
|
1173
|
+
spill = yield* Spill.create(engine, first.expr, splitters ?? []);
|
|
1174
|
+
stats.spills++;
|
|
1175
|
+
}
|
|
1176
|
+
yield* spill.add(t);
|
|
1177
|
+
},
|
|
1178
|
+
*finish() {
|
|
1179
|
+
spill ??= yield* Spill.create(engine, first.expr, []);
|
|
1180
|
+
const paths = yield* spill.closeG();
|
|
1181
|
+
const ordered = first.desc ? [...paths.slice(0, -1).reverse(), paths[paths.length - 1]] : paths;
|
|
1182
|
+
const outPath = temp().join(spill.dir, 'sorted.vectro');
|
|
1183
|
+
const out = yield* tempWriter(engine, outPath);
|
|
1184
|
+
let ok = false;
|
|
1185
|
+
try {
|
|
1186
|
+
for (const p of ordered) if (p) yield* sortPartition(p, 0, out);
|
|
1187
|
+
if (!out.meta) out.append(empty); // (no rows at all)
|
|
1188
|
+
yield* closeWriter(out);
|
|
1189
|
+
ok = true;
|
|
1190
|
+
} finally { if (!ok) out.abort(); }
|
|
1191
|
+
const result = yield* tempDataset(engine, outPath);
|
|
1192
|
+
result.temporary = spill.dir;
|
|
1193
|
+
spill = null;
|
|
1194
|
+
return result;
|
|
1195
|
+
},
|
|
1196
|
+
dispose() { spill?.cleanup(); empty?.dispose(); },
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
function* sortPartition(path, depth, out) {
|
|
1200
|
+
const D = yield* tempDataset(engine, path);
|
|
1201
|
+
try {
|
|
1202
|
+
const splitters = D.length > tuning.partitionRows && depth < 4 ? zoneSplitters(D, first.expr, 16) : null;
|
|
1203
|
+
if (splitters) { // split again
|
|
1204
|
+
const sub = yield* Spill.create(engine, first.expr, splitters);
|
|
1205
|
+
stats.resplits++;
|
|
1206
|
+
try {
|
|
1207
|
+
yield* eachChunk(D, (t) => sub.add(t));
|
|
1208
|
+
const ps = yield* sub.closeG();
|
|
1209
|
+
for (const p of first.desc ? [...ps.slice(0, -1).reverse(), ps[ps.length - 1]] : ps) if (p) yield* sortPartition(p, depth + 1, out);
|
|
1210
|
+
} finally { sub.cleanup(); }
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const parts = [];
|
|
1214
|
+
let all;
|
|
1215
|
+
try {
|
|
1216
|
+
yield* eachChunk(D, function* (t) { parts.push(t); });
|
|
1217
|
+
all = copyRows(engine, parts.map((t) => [t, 0, t.n]));
|
|
1218
|
+
} finally { for (const t of parts) t.dispose(); }
|
|
1219
|
+
let sorted;
|
|
1220
|
+
try { sorted = yield* keep(engine, yield { run: { op: 'output', input: { op: 'sort', by: B.by, input: { op: 'scan', table: all } } } }, all); } finally { all.dispose(); }
|
|
1221
|
+
try { out.append(sorted); } finally { sorted.dispose(); }
|
|
1222
|
+
yield { flush: out };
|
|
1223
|
+
} finally { D.close(); }
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// The first sort key as an expression and its direction, if it sorts by
|
|
1228
|
+
// value (a text column sorts by label: no cutoff then).
|
|
1229
|
+
// (numeric: a derived column that is arithmetic on number columns counts as a value too)
|
|
1230
|
+
function firstSortKey(by, ds, derived, numeric = false) {
|
|
1231
|
+
const src = String(by).trim();
|
|
1232
|
+
const desc = src.startsWith('-');
|
|
1233
|
+
const expr = desc ? src.slice(1).trim() : src;
|
|
1234
|
+
const ident = /^[A-Za-z_]\w*$/.test(expr);
|
|
1235
|
+
if (ident && !derived.has(expr) && (ds.cols.get(expr)?.dict || ds.cols.get(expr)?.strings)) return null;
|
|
1236
|
+
if (ident && derived.has(expr) && !(numeric && derived.numeric?.has(expr))) return null; // (a join's text column, or a derived label: be safe)
|
|
1237
|
+
return { expr, desc };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// numbers, number columns of ds, + - * / and minus only
|
|
1241
|
+
function arithmetic(ast, ds) {
|
|
1242
|
+
if (ast.t === 'num') return true;
|
|
1243
|
+
if (ast.t === 'var') { const c = ds.cols.get(ast.name); return !!c && !c.dict && !c.strings; }
|
|
1244
|
+
if (ast.t === 'un') return ast.op === '-' && arithmetic(ast.x, ds);
|
|
1245
|
+
if (ast.t === 'bin') return ['+', '-', '*', '/'].includes(ast.op) && arithmetic(ast.l, ds) && arithmetic(ast.r, ds);
|
|
1246
|
+
return false;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// totals and groups: each aggregation as partial values per chunk, merged
|
|
1250
|
+
// sum, count -> added; min, max -> min, max (missing skipped); mean -> a
|
|
1251
|
+
// sum and a count of the values present; countDistinct / approxDistinct ->
|
|
1252
|
+
// the distinct (key, value) pairs, merged as groups, counted at the end
|
|
1253
|
+
// (exact on datasets).
|
|
1254
|
+
const DV = '__vectro_dv';
|
|
1255
|
+
function groupAccumulator(kind, B, engine, ds, derived) {
|
|
1256
|
+
const keys = kind === 'group' ? B.keys : [];
|
|
1257
|
+
const partial = {}, merge = {}, outs = [], distinct = [];
|
|
1258
|
+
Object.entries(B.spec).forEach(([out, ast], j) => {
|
|
1259
|
+
const p = `__vectro_p${j}`;
|
|
1260
|
+
if (ast.fn === 'countDistinct' || ast.fn === 'approxDistinct') {
|
|
1261
|
+
// (a category column: its missing values, '', are not counted)
|
|
1262
|
+
const a0 = ast.args[0], blankable = a0.t === 'var' && !derived.has(a0.name) && ds.cols.get(a0.name)?.dict?.includes('');
|
|
1263
|
+
const arg = blankable ? parse(`${a0.name} != '' ? ${a0.name} : 0 / 0`) : a0;
|
|
1264
|
+
distinct.push({ out, arg, parts: [], rows: 0, last: 0, bitmap: bitmapPlan(ds, derived, keys, a0) });
|
|
1265
|
+
outs.push({ out, distinct: distinct.length - 1 });
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (ast.fn === 'mean') {
|
|
1269
|
+
const src = toSource(ast.args[0]);
|
|
1270
|
+
partial[`${p}s`] = { t: 'call', fn: 'sum', args: [ast.args[0]] };
|
|
1271
|
+
partial[`${p}n`] = parse(`count((${src}) == (${src}))`);
|
|
1272
|
+
merge[`${p}s`] = parse(`sum(${p}s)`);
|
|
1273
|
+
merge[`${p}n`] = parse(`sum(${p}n)`);
|
|
1274
|
+
outs.push({ out, mean: p });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
partial[p] = ast;
|
|
1278
|
+
merge[p] = parse(`${ast.fn === 'count' ? 'sum' : ast.fn}(${p})`);
|
|
1279
|
+
outs.push({ out, col: p });
|
|
1280
|
+
});
|
|
1281
|
+
if (!Object.keys(partial).length) { partial.__vectro_rows = parse('count()'); merge.__vectro_rows = parse('sum(__vectro_rows)'); }
|
|
1282
|
+
const main = { parts: [], rows: 0, last: 0 };
|
|
1283
|
+
const dense = kind === 'group' ? densePlan(ds, derived, keys, partial, engine) : null;
|
|
1284
|
+
// too many groups to keep: partial groups go to disk by key range
|
|
1285
|
+
const canSpill = kind === 'group' && !dense && !distinct.length;
|
|
1286
|
+
let spill = null, chunksDone = 0;
|
|
1287
|
+
let totals = null; // kind 'total': partial values so far
|
|
1288
|
+
const combine = (fn, a, b) => (fn === 'min' ? (b < a || a !== a ? b : a) : fn === 'max' ? (b > a || a !== a ? b : a) : a + b);
|
|
1289
|
+
const mergeFn = Object.fromEntries(Object.entries(merge).map(([k, a]) => [k, a.fn]));
|
|
1290
|
+
|
|
1291
|
+
// grouped partial tables of one kind, merged again when they pile up
|
|
1292
|
+
function* compact(state, groupKeys, spec, force) {
|
|
1293
|
+
if (!force && (state.parts.length < 2 || state.rows < Math.max(tuning.mergeRows, 2 * state.last))) return;
|
|
1294
|
+
if (state.parts.length < 2 && force) return;
|
|
1295
|
+
const ps = state.parts.splice(0);
|
|
1296
|
+
let all;
|
|
1297
|
+
try { all = copyRows(engine, ps.map((p) => [p, 0, p.n])); } finally { for (const p of ps) p.dispose(); }
|
|
1298
|
+
try {
|
|
1299
|
+
const g = yield { run: { op: 'output', input: { op: 'aggregate', keys: groupKeys, spec, input: { op: 'scan', table: all } } } };
|
|
1300
|
+
state.parts.push(yield* keep(engine, g, all));
|
|
1301
|
+
} finally { all.dispose(); }
|
|
1302
|
+
state.rows = state.last = state.parts[0].n;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const distinctKeys = [...keys, DV];
|
|
1306
|
+
const distinctMerge = { __vectro_dn: parse('sum(__vectro_dn)') };
|
|
1307
|
+
return {
|
|
1308
|
+
done: false,
|
|
1309
|
+
*chunk(P, chunk) {
|
|
1310
|
+
if (kind === 'total') {
|
|
1311
|
+
const r = yield { run: { op: 'total', input: P, spec: partial } };
|
|
1312
|
+
totals = totals ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, combine(mergeFn[k], totals[k], v)])) : r;
|
|
1313
|
+
} else if (dense?.kernel) { // the matching rows added into the running totals, on every thread
|
|
1314
|
+
yield { run: { op: 'groupInto', input: P, group: dense.code, K: dense.groups, sources: dense.sources, state: dense.state } };
|
|
1315
|
+
} else if (dense) { // group numbers and values of the matching rows, added into the arrays
|
|
1316
|
+
let node = P;
|
|
1317
|
+
node = { op: 'derive', name: DV, ast: dense.code, input: node };
|
|
1318
|
+
for (const it of dense.items) node = { op: 'derive', name: it.e, ast: it.ast, input: node };
|
|
1319
|
+
const r = yield { run: { op: 'output', input: { op: 'select', names: [DV, ...dense.items.map((it) => it.e)], input: node } } };
|
|
1320
|
+
try {
|
|
1321
|
+
denseAdd(engine.live(r.cols.get(DV).data), dense.items.map((it) => engine.live(r.cols.get(it.e).data)), dense.items.map((it) => it.fn), dense.accs, dense.seen, r.n);
|
|
1322
|
+
} finally { if (r !== chunk) r.dispose(); }
|
|
1323
|
+
} else {
|
|
1324
|
+
const r = yield { run: { op: 'output', input: { op: 'aggregate', keys, spec: partial, input: P } } };
|
|
1325
|
+
const kept = yield* keep(engine, r, chunk);
|
|
1326
|
+
main.parts.push(kept);
|
|
1327
|
+
main.rows += kept.n;
|
|
1328
|
+
chunksDone++;
|
|
1329
|
+
if (!spill) yield* compact(main, keys, merge, false);
|
|
1330
|
+
if (canSpill && !spill && main.rows > tuning.spillRows) {
|
|
1331
|
+
const estimate = (main.rows * Math.max(1, ds.chunkCount)) / chunksDone;
|
|
1332
|
+
const P = Math.min(64, Math.max(4, Math.ceil((2 * estimate) / tuning.partitionRows)));
|
|
1333
|
+
const k0 = keys[0];
|
|
1334
|
+
let splitters = !derived.has(k0) && ds.cols.has(k0) ? zoneSplitters(ds, k0, P) : null;
|
|
1335
|
+
if (!splitters) { // from the groups so far (they are sorted by key)
|
|
1336
|
+
const g = main.parts[0], v = engine.live(g.cols.get(k0).data);
|
|
1337
|
+
const vals = [];
|
|
1338
|
+
for (let i = 0; i < g.n; i++) if (v[i] === v[i]) vals.push(v[i]);
|
|
1339
|
+
splitters = quantiles(vals.sort((a, b) => a - b), P);
|
|
1340
|
+
}
|
|
1341
|
+
if (splitters) { spill = yield* Spill.create(engine, k0, splitters); stats.spills++; }
|
|
1342
|
+
}
|
|
1343
|
+
if (spill) { for (const t of main.parts.splice(0)) yield* spill.add(t); main.rows = 0; }
|
|
1344
|
+
}
|
|
1345
|
+
for (const d of distinct) {
|
|
1346
|
+
if (d.bitmap) { // one code per matching row: its group and value, as a bit
|
|
1347
|
+
const r = yield { run: { op: 'output', input: { op: 'select', names: [DV], input: { op: 'derive', name: DV, ast: d.bitmap.code, input: P } } } };
|
|
1348
|
+
try { setBits(d.bitmap.bits, engine.live(r.cols.get(DV).data), r.n); } finally { if (r !== chunk) r.dispose(); }
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
const r = yield { run: { op: 'output', input: { op: 'aggregate', keys: distinctKeys, spec: { __vectro_dn: parse('count()') }, input: { op: 'derive', name: DV, ast: d.arg, input: P } } } };
|
|
1352
|
+
const kept = yield* keep(engine, r, chunk);
|
|
1353
|
+
d.parts.push(kept);
|
|
1354
|
+
d.rows += kept.n;
|
|
1355
|
+
yield* compact(d, distinctKeys, distinctMerge, false);
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1358
|
+
*finish() {
|
|
1359
|
+
// distinct counts: per group, the distinct values present
|
|
1360
|
+
const counts = [];
|
|
1361
|
+
for (const d of distinct) {
|
|
1362
|
+
if (d.bitmap) { counts.push(d.bitmap); continue; } // (counted below, per group)
|
|
1363
|
+
yield* compact(d, distinctKeys, distinctMerge, true);
|
|
1364
|
+
const pairs = d.parts[0];
|
|
1365
|
+
if (kind === 'total') counts.push((yield { run: { op: 'total', input: { op: 'scan', table: pairs }, spec: { c: parse(`count(${DV})`) } } }).c);
|
|
1366
|
+
else counts.push(yield* keep(engine, yield { run: { op: 'output', input: { op: 'aggregate', keys, spec: { c: parse(`count(${DV})`) }, input: { op: 'scan', table: pairs } } } }, pairs));
|
|
1367
|
+
}
|
|
1368
|
+
if (kind === 'total') {
|
|
1369
|
+
const out = {};
|
|
1370
|
+
const value = (c) => (c.bits ? countBits(c.bits, 0, c.range) - blankBit(c, 0) : c);
|
|
1371
|
+
for (const o of outs) out[o.out] = o.distinct !== undefined ? value(counts[o.distinct]) : o.mean ? totals[`${o.mean}s`] / totals[`${o.mean}n`] : totals[o.col];
|
|
1372
|
+
return out;
|
|
1373
|
+
}
|
|
1374
|
+
if (spill) return yield* finishSpilled();
|
|
1375
|
+
yield* compact(main, keys, merge, true);
|
|
1376
|
+
return finalize(dense ? denseTable(engine, dense, keys, ds) : main.parts.pop(), counts);
|
|
1377
|
+
},
|
|
1378
|
+
dispose() {
|
|
1379
|
+
if (dense?.state.acc) { engine.free(dense.state.acc); dense.state.acc = null; }
|
|
1380
|
+
for (const p of main.parts.splice(0)) p.dispose();
|
|
1381
|
+
for (const d of distinct) for (const p of d.parts.splice(0)) p.dispose();
|
|
1382
|
+
spill?.cleanup();
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// Each partition's groups merged and finished, in key order, into a
|
|
1387
|
+
// dataset of the result (the rest of the plan reads it chunk by chunk).
|
|
1388
|
+
// A partition too big to merge in memory is split again by its first key.
|
|
1389
|
+
function* finishSpilled() {
|
|
1390
|
+
const paths = yield* spill.closeG();
|
|
1391
|
+
const outPath = temp().join(spill.dir, 'groups.vectro');
|
|
1392
|
+
const out = yield* tempWriter(engine, outPath);
|
|
1393
|
+
let ok = false;
|
|
1394
|
+
try {
|
|
1395
|
+
for (const p of paths) if (p) yield* mergePartition(p, 0, out);
|
|
1396
|
+
yield* closeWriter(out);
|
|
1397
|
+
ok = true;
|
|
1398
|
+
} finally { if (!ok) out.abort(); }
|
|
1399
|
+
const result = yield* tempDataset(engine, outPath);
|
|
1400
|
+
const dir = spill.dir;
|
|
1401
|
+
spill = null;
|
|
1402
|
+
result.temporary = dir; // (deleted after the rest of the plan ran)
|
|
1403
|
+
return result;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function* mergePartition(path, depth, out) {
|
|
1407
|
+
const D = yield* tempDataset(engine, path);
|
|
1408
|
+
try {
|
|
1409
|
+
if (!D.length) return;
|
|
1410
|
+
const splitters = D.length > tuning.partitionRows && depth < 4 ? zoneSplitters(D, keys[0], 16) : null;
|
|
1411
|
+
if (splitters) { // split again
|
|
1412
|
+
const sub = yield* Spill.create(engine, keys[0], splitters);
|
|
1413
|
+
stats.resplits++;
|
|
1414
|
+
try {
|
|
1415
|
+
yield* eachChunk(D, (t) => sub.add(t));
|
|
1416
|
+
for (const p of yield* sub.closeG()) if (p) yield* mergePartition(p, depth + 1, out);
|
|
1417
|
+
} finally { sub.cleanup(); }
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const parts = [];
|
|
1421
|
+
let all;
|
|
1422
|
+
try {
|
|
1423
|
+
yield* eachChunk(D, function* (t) { parts.push(t); });
|
|
1424
|
+
all = copyRows(engine, parts.map((t) => [t, 0, t.n]));
|
|
1425
|
+
} finally { for (const t of parts) t.dispose(); }
|
|
1426
|
+
let G;
|
|
1427
|
+
try { G = yield* keep(engine, yield { run: { op: 'output', input: { op: 'aggregate', keys, spec: merge, input: { op: 'scan', table: all } } } }, all); } finally { all.dispose(); }
|
|
1428
|
+
const F = finalize(G, []);
|
|
1429
|
+
try { out.append(F); } finally { F.dispose(); }
|
|
1430
|
+
yield { flush: out };
|
|
1431
|
+
} finally { D.close(); }
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Merged partial groups G -> the result: keys, then each aggregation.
|
|
1435
|
+
function finalize(G, counts) {
|
|
1436
|
+
try {
|
|
1437
|
+
const cols = new Map(), owned = [];
|
|
1438
|
+
const take = (t, name) => { t.owned = t.owned.filter((n) => n !== name); return t.cols.get(name); };
|
|
1439
|
+
for (const k of keys) { cols.set(k, take(G, k)); owned.push(k); }
|
|
1440
|
+
for (const o of outs) {
|
|
1441
|
+
if (o.col) cols.set(o.out, take(G, o.col));
|
|
1442
|
+
else if (o.mean) {
|
|
1443
|
+
const data = engine.alloc(Float64Array, G.n);
|
|
1444
|
+
if (G.n) engine.eval('s / n', { s: G.cols.get(`${o.mean}s`).data, n: G.cols.get(`${o.mean}n`).data }, data);
|
|
1445
|
+
cols.set(o.out, { data: engine.live(data), dict: null, codes: null });
|
|
1446
|
+
} else if (counts[o.distinct].bits) { // each group's bits
|
|
1447
|
+
const bm = counts[o.distinct], data = engine.alloc(Float64Array, G.n);
|
|
1448
|
+
const keyData = keys.map((k) => engine.live(G.cols.get(k).data)), out = engine.live(data);
|
|
1449
|
+
for (let r = 0; r < G.n; r++) {
|
|
1450
|
+
let g = 0;
|
|
1451
|
+
for (let k = 0; k < keys.length; k++) g += (keyData[k][r] - bm.mins[k]) * bm.strides[k];
|
|
1452
|
+
out[r] = countBits(bm.bits, g * bm.range, (g + 1) * bm.range) - blankBit(bm, g);
|
|
1453
|
+
}
|
|
1454
|
+
cols.set(o.out, { data: engine.live(data), dict: null, codes: null });
|
|
1455
|
+
} else {
|
|
1456
|
+
const C = counts[o.distinct];
|
|
1457
|
+
if (C.n !== G.n) throw new Error(`vectro: internal: ${C.n} distinct-count groups for ${G.n} groups`);
|
|
1458
|
+
cols.set(o.out, take(C, 'c'));
|
|
1459
|
+
}
|
|
1460
|
+
owned.push(o.out);
|
|
1461
|
+
}
|
|
1462
|
+
for (const c of cols.values()) c.data = engine.live(c.data);
|
|
1463
|
+
return new Table(engine, cols, G.n, { owned });
|
|
1464
|
+
} finally {
|
|
1465
|
+
G.dispose();
|
|
1466
|
+
for (const C of counts) C.dispose?.();
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Exact distinct counts as one bitmap: when the counted column and the group
|
|
1472
|
+
// keys are dataset columns of whole numbers (or labels) with known ranges,
|
|
1473
|
+
// bit (group * range + value - min) marks that a group has that value.
|
|
1474
|
+
// Null when it doesn't apply (the distinct pairs are merged instead).
|
|
1475
|
+
const MAX_BITS = 2 ** 31; // 256 MB
|
|
1476
|
+
function bitmapPlan(ds, derived, keys, arg) {
|
|
1477
|
+
if (arg.t !== 'var') return null;
|
|
1478
|
+
const v = wholeRange(ds, derived, arg.name), g = v && denseKeys(ds, derived, keys);
|
|
1479
|
+
if (!g || g.groups * v[1] > MAX_BITS) return null;
|
|
1480
|
+
const blank = ds.cols.get(arg.name).dict?.indexOf('') ?? -1; // (the blank label's code: missing text)
|
|
1481
|
+
return { code: parse(`(${g.src}) * ${v[1]} + ((${arg.name}) - (${v[0]}))`), range: v[1], blank, mins: g.mins, strides: g.strides, bits: new Uint32Array(Math.ceil((g.groups * v[1]) / 32)) };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// [min, size, missing] of a dataset column's values when they are whole
|
|
1485
|
+
// numbers one apart (counts, IDs, codes) or labels, from the chunk
|
|
1486
|
+
// statistics; else null.
|
|
1487
|
+
function wholeRange(ds, derived, name) {
|
|
1488
|
+
if (!ds.cols.has(name) || derived.has(name)) return null;
|
|
1489
|
+
const base = ds.cols.get(name);
|
|
1490
|
+
if (base.strings) return null;
|
|
1491
|
+
if (base.dict) return [0, Math.max(1, base.dict.length), 0];
|
|
1492
|
+
let lo = Infinity, hi = -Infinity, missing = 0;
|
|
1493
|
+
for (const ch of ds.meta.chunks) {
|
|
1494
|
+
const z = ch.cols[name];
|
|
1495
|
+
missing += z.missing;
|
|
1496
|
+
if (z.missing === ch.rows) continue;
|
|
1497
|
+
if (!z.enc || z.enc.div || z.enc.mul !== 1) return null;
|
|
1498
|
+
lo = Math.min(lo, fromJson(z.min));
|
|
1499
|
+
hi = Math.max(hi, fromJson(z.max));
|
|
1500
|
+
}
|
|
1501
|
+
return lo === Infinity ? [0, 1, missing] : [lo, hi - lo + 1, missing];
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Group keys as one dense group number: (k1 - min1) * stride1 + ..., in key
|
|
1505
|
+
// order (labels in dictionary order). Null unless every key qualifies.
|
|
1506
|
+
function denseKeys(ds, derived, keys) {
|
|
1507
|
+
const mins = [], sizes = [];
|
|
1508
|
+
for (const k of keys) {
|
|
1509
|
+
const r = wholeRange(ds, derived, k);
|
|
1510
|
+
if (!r || r[2]) return null; // (missing keys form a group of their own: not here)
|
|
1511
|
+
mins.push(r[0]);
|
|
1512
|
+
sizes.push(r[1]);
|
|
1513
|
+
}
|
|
1514
|
+
const groups = sizes.reduce((a, b) => a * b, 1);
|
|
1515
|
+
const strides = sizes.map((_, k) => sizes.slice(k + 1).reduce((a, b) => a * b, 1));
|
|
1516
|
+
let src = '0';
|
|
1517
|
+
keys.forEach((k, j) => { src = `${src} + ((${k}) - (${mins[j]})) * ${strides[j]}`; });
|
|
1518
|
+
return { src, groups, mins, sizes, strides };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Many groups with dense keys: each chunk's values are added straight into
|
|
1522
|
+
// one array per partial value, indexed by group number (no partial tables
|
|
1523
|
+
// to merge). Null when it doesn't apply or the groups are few (the group
|
|
1524
|
+
// kernels are faster there).
|
|
1525
|
+
// With compiled kernels the rows are added on every thread (each thread its
|
|
1526
|
+
// own range of groups, see Table._groupInto); else in plain JS.
|
|
1527
|
+
const DENSE_MIN = 1 << 16, DENSE_MAX = 1 << 24;
|
|
1528
|
+
function densePlan(ds, derived, keys, partial, engine) {
|
|
1529
|
+
const g = keys.length && denseKeys(ds, derived, keys);
|
|
1530
|
+
if (!g || g.groups < (tuning.denseMin ?? DENSE_MIN) || g.groups > DENSE_MAX) return null;
|
|
1531
|
+
const names = Object.keys(partial);
|
|
1532
|
+
const items = names.map((name, j) => {
|
|
1533
|
+
const { fn, args } = partial[name], arg = args[0], a = arg && toSource(arg);
|
|
1534
|
+
const e = `__vectro_e${j}`;
|
|
1535
|
+
if (fn === 'count') return { name, fn: 'add', e, ast: arg ? parse(isMask(arg) ? `(${a}) ? 1 : 0` : `(${a}) == (${a}) ? 1 : 0`) : parse('1') };
|
|
1536
|
+
return { name, fn, e, ast: arg }; // sum, min, max
|
|
1537
|
+
});
|
|
1538
|
+
// the kernel's reductions: rows per group first, then each item (min / max with a count of values present)
|
|
1539
|
+
const sources = ['count(1)'], at = (src) => { let i = sources.indexOf(src); if (i < 0) { i = sources.length; sources.push(src); } return i; };
|
|
1540
|
+
for (const it of items) {
|
|
1541
|
+
const a = toSource(it.ast), present = `(${a}) == (${a})`;
|
|
1542
|
+
if (it.fn === 'add') it.slot = a === '1' ? 0 : at(`sum(${a})`);
|
|
1543
|
+
else if (it.fn === 'sum') it.slot = at(`sum(${present} ? (${a}) : 0)`);
|
|
1544
|
+
else { it.slot = at(`${it.fn}(${present} ? (${a}) : ${it.fn === 'min' ? 'Infinity' : '-Infinity'})`); it.presentSlot = at(`sum(${present} ? 1 : 0)`); }
|
|
1545
|
+
}
|
|
1546
|
+
const kernel = engine.jit && sources.length <= 8;
|
|
1547
|
+
if (g.groups * (kernel ? sources.length : names.length + 1) * 8 > 2 ** 30) return null;
|
|
1548
|
+
return {
|
|
1549
|
+
...g, items, code: parse(g.src), kernel, sources: sources.map((x) => parse(x)), state: { acc: null },
|
|
1550
|
+
seen: kernel ? null : new Float64Array(g.groups),
|
|
1551
|
+
accs: kernel ? null : items.map((it) => new Float64Array(g.groups).fill(it.fn === 'min' || it.fn === 'max' ? NaN : 0)),
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// The groups that have rows, in group-number (= key) order, as a table of
|
|
1556
|
+
// the keys and the partial values (as merged partial tables would be).
|
|
1557
|
+
function denseTable(engine, dense, keys, ds) {
|
|
1558
|
+
const { items, mins, sizes, strides, groups: K } = dense;
|
|
1559
|
+
let { seen, accs } = dense;
|
|
1560
|
+
if (dense.kernel) { // (the kernel's totals: per item its slot; min / max are missing where no value was present)
|
|
1561
|
+
const acc = dense.state.acc ? engine.live(dense.state.acc) : new Float64Array(K * dense.sources.length);
|
|
1562
|
+
const slot = (j) => acc.subarray(j * K, (j + 1) * K);
|
|
1563
|
+
seen = slot(0);
|
|
1564
|
+
accs = items.map((it) => {
|
|
1565
|
+
const v = slot(it.slot);
|
|
1566
|
+
if (it.presentSlot === undefined) return v;
|
|
1567
|
+
const p = slot(it.presentSlot), out = new Float64Array(K);
|
|
1568
|
+
for (let g = 0; g < K; g++) out[g] = p[g] ? v[g] : NaN;
|
|
1569
|
+
return out;
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
let n = 0;
|
|
1573
|
+
for (let g = 0; g < seen.length; g++) if (seen[g]) n++;
|
|
1574
|
+
const cols = new Map(), owned = [];
|
|
1575
|
+
const add = (name, desc) => { cols.set(name, desc); owned.push(name); };
|
|
1576
|
+
keys.forEach((k) => { const base = ds.cols.get(k); add(k, { data: engine.alloc(Float64Array, n), dict: base.dict, codes: base.codes, date: base.date }); });
|
|
1577
|
+
items.forEach((it) => add(it.name, { data: engine.alloc(Float64Array, n), dict: null, codes: null }));
|
|
1578
|
+
const keyOut = keys.map((k) => engine.live(cols.get(k).data)), accOut = items.map((it) => engine.live(cols.get(it.name).data));
|
|
1579
|
+
for (let g = 0, r = 0; g < seen.length; g++) {
|
|
1580
|
+
if (!seen[g]) continue;
|
|
1581
|
+
for (let k = 0; k < keys.length; k++) keyOut[k][r] = mins[k] + (Math.floor(g / strides[k]) % sizes[k]);
|
|
1582
|
+
for (let j = 0; j < items.length; j++) accOut[j][r] = accs[j][g];
|
|
1583
|
+
r++;
|
|
1584
|
+
}
|
|
1585
|
+
for (const c of cols.values()) c.data = engine.live(c.data);
|
|
1586
|
+
return new Table(engine, cols, n, { owned });
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// (a plain function: the hot loop)
|
|
1590
|
+
function denseAdd(gs, values, fns, accs, seen, n) {
|
|
1591
|
+
for (let i = 0; i < n; i++) seen[gs[i]]++;
|
|
1592
|
+
for (let j = 0; j < values.length; j++) {
|
|
1593
|
+
const v = values[j], acc = accs[j], fn = fns[j];
|
|
1594
|
+
if (fn === 'add') for (let i = 0; i < n; i++) acc[gs[i]] += v[i];
|
|
1595
|
+
else if (fn === 'sum') for (let i = 0; i < n; i++) { const x = v[i]; if (x === x) acc[gs[i]] += x; }
|
|
1596
|
+
else if (fn === 'min') for (let i = 0; i < n; i++) { const x = v[i], g = gs[i], a = acc[g]; if (x < a || (a !== a && x === x)) acc[g] = x; }
|
|
1597
|
+
else for (let i = 0; i < n; i++) { const x = v[i], g = gs[i], a = acc[g]; if (x > a || (a !== a && x === x)) acc[g] = x; }
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function setBits(bits, codes, n) {
|
|
1602
|
+
for (let i = 0; i < n; i++) {
|
|
1603
|
+
const c = codes[i];
|
|
1604
|
+
if (c === c) bits[c >>> 5] |= 1 << (c & 31); // (missing values: NaN, not counted)
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// 1 when group g of a bitmap saw the blank label (missing text: not counted), else 0
|
|
1609
|
+
function blankBit(bm, g) {
|
|
1610
|
+
if (bm.blank < 0) return 0;
|
|
1611
|
+
const i = g * bm.range + bm.blank;
|
|
1612
|
+
return (bm.bits[i >>> 5] >>> (i & 31)) & 1;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function countBits(bits, from, to) {
|
|
1616
|
+
let n = 0;
|
|
1617
|
+
const pop = (x) => { x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); return (((x + (x >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24; };
|
|
1618
|
+
for (let b = from; b < to; ) {
|
|
1619
|
+
const w = b >>> 5, off = b & 31, take = Math.min(32 - off, to - b);
|
|
1620
|
+
const mask = take === 32 ? 0xffffffff : ((1 << take) - 1) << off;
|
|
1621
|
+
n += pop((bits[w] & mask) >>> 0);
|
|
1622
|
+
b += take;
|
|
1623
|
+
}
|
|
1624
|
+
return n;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// ---- CSV of any size -> dataset ------------------------------------------------------------
|
|
1628
|
+
// Read in pieces (pieceBytes, cut at row ends), each parsed on every thread
|
|
1629
|
+
// with the column types found in the first piece. Returns the writer's summary.
|
|
1630
|
+
export function csvToDataset(engine, csvPath, outPath, opts = {}) {
|
|
1631
|
+
const file = need('csvToDataset').openRead(csvPath);
|
|
1632
|
+
try {
|
|
1633
|
+
const writer = new DatasetWriter(engine, outPath, opts);
|
|
1634
|
+
const gen = csvPiecesG(engine, file.size, writer, opts);
|
|
1635
|
+
let step = gen.next();
|
|
1636
|
+
while (!step.done) {
|
|
1637
|
+
const v = step.value;
|
|
1638
|
+
let out, err = null;
|
|
1639
|
+
try { out = v.read ? file.readSync(v.read, v.pos) : v.parse ? loadBytes(engine, v.parse, v.opts) : undefined; } catch (e) { err = e; }
|
|
1640
|
+
step = err ? gen.throw(err) : gen.next(out);
|
|
1641
|
+
}
|
|
1642
|
+
return writer.close();
|
|
1643
|
+
} finally { file.close(); }
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// The same without blocking: reads with async I/O, parses on the worker
|
|
1647
|
+
// threads. csv: a path (Node), a URL, or a File / Blob; out: a path (Node) or
|
|
1648
|
+
// a writable file object (browsers: see files-browser.mjs).
|
|
1649
|
+
export async function csvToDatasetAsync(engine, csv, out, opts = {}) {
|
|
1650
|
+
const file = typeof csv === 'string' && files && !/^https?:/i.test(csv) ? files.openRead(csv)
|
|
1651
|
+
: typeof csv === 'string' ? await urlFile(csv) : typeof Blob === 'function' && csv instanceof Blob ? blobFile(csv) : csv;
|
|
1652
|
+
try {
|
|
1653
|
+
const writer = await createDatasetAsync(engine, out, opts);
|
|
1654
|
+
try {
|
|
1655
|
+
const gen = csvPiecesG(engine, file.size, writer, opts);
|
|
1656
|
+
let step = gen.next();
|
|
1657
|
+
while (!step.done) {
|
|
1658
|
+
const v = step.value;
|
|
1659
|
+
let got, err = null;
|
|
1660
|
+
try { got = v.read ? await file.read(v.read, v.pos) : v.parse ? await loadBytesAsync(engine, v.parse, v.opts) : await writer.file.flush?.(); } catch (e) { err = e; }
|
|
1661
|
+
step = err ? gen.throw(err) : gen.next(got);
|
|
1662
|
+
}
|
|
1663
|
+
} catch (err) { writer.abort(); throw err; }
|
|
1664
|
+
return await writer.closeAsync();
|
|
1665
|
+
} finally { file.close(); }
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// Yields { read: bytes, pos } (fill bytes from the file at pos), { parse:
|
|
1669
|
+
// engine bytes, opts } (a table) and { flush } (wait for the writer); appends
|
|
1670
|
+
// each piece's rows to the writer (aborted on an error).
|
|
1671
|
+
// opts.onProgress({ bytes, size, rows }) is called after each piece.
|
|
1672
|
+
function* csvPiecesG(engine, size, writer, { pieceBytes = 64 << 20, header = true, delimiter = ',', types = {}, sampleRows, rowsPerChunk, bitPack, append, onProgress, ...rest } = {}) {
|
|
1673
|
+
try {
|
|
1674
|
+
let pos = 0, carry = new Uint8Array(0), head = new Uint8Array(0), kinds = null;
|
|
1675
|
+
while (true) {
|
|
1676
|
+
const want = Math.min(pieceBytes, size - pos);
|
|
1677
|
+
const buf = new Uint8Array(carry.length + want);
|
|
1678
|
+
buf.set(carry, 0);
|
|
1679
|
+
yield { read: buf.subarray(carry.length), pos };
|
|
1680
|
+
pos += want;
|
|
1681
|
+
const last = pos >= size;
|
|
1682
|
+
let start = 0;
|
|
1683
|
+
if (!kinds) { // the first piece: column names and types
|
|
1684
|
+
const info = inspectCSV(buf, { delimiter, header, types, sampleRows });
|
|
1685
|
+
kinds = Object.fromEntries(info.names.map((n, i) => [n, info.kinds[i]]));
|
|
1686
|
+
head = header ? buf.slice(0, info.start) : new Uint8Array(0);
|
|
1687
|
+
start = info.start;
|
|
1688
|
+
}
|
|
1689
|
+
const end = last ? buf.length : rowsEnd(buf, start);
|
|
1690
|
+
if (end > start || (!writer.meta && last)) { // (a header and no rows: still the columns)
|
|
1691
|
+
const rows = end > start ? buf.subarray(start, end) : new Uint8Array(0);
|
|
1692
|
+
const bytes = engine.alloc(Uint8Array, head.length + rows.length);
|
|
1693
|
+
bytes.set(head, 0);
|
|
1694
|
+
bytes.set(rows, head.length);
|
|
1695
|
+
const t = yield { parse: bytes, opts: { delimiter, header, types: kinds, ...rest } };
|
|
1696
|
+
try { writer.append(t); } finally { t.dispose(); }
|
|
1697
|
+
yield { flush: writer };
|
|
1698
|
+
onProgress?.({ bytes: pos, size, rows: writer.meta.rows + (writer.pending?.n ?? 0) });
|
|
1699
|
+
}
|
|
1700
|
+
if (last) break;
|
|
1701
|
+
carry = buf.slice(Math.max(start, end));
|
|
1702
|
+
}
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
writer.abort();
|
|
1705
|
+
throw err;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Where the last complete row of buf[start..] ends (quotes may hold newlines).
|
|
1710
|
+
function rowsEnd(buf, start) {
|
|
1711
|
+
const NL = 10, QUOTE = 34;
|
|
1712
|
+
if (buf.indexOf(QUOTE, start) === -1) return buf.lastIndexOf(NL) + 1;
|
|
1713
|
+
let inQuotes = false, end = start;
|
|
1714
|
+
for (let i = start; i < buf.length; i++) {
|
|
1715
|
+
const c = buf[i];
|
|
1716
|
+
if (c === QUOTE) inQuotes = !inQuotes;
|
|
1717
|
+
else if (c === NL && !inQuotes) end = i + 1;
|
|
1718
|
+
}
|
|
1719
|
+
return end;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Write a table, a query or a dataset as a dataset. A query over a dataset
|
|
1723
|
+
// that ends in matching rows is written chunk by chunk.
|
|
1724
|
+
export function writeDataset(engine, path, source, opts) {
|
|
1725
|
+
const writer = new DatasetWriter(engine, path, opts);
|
|
1726
|
+
try {
|
|
1727
|
+
if (source instanceof Query || source?.isDataset) {
|
|
1728
|
+
const plan = optimize({ op: 'output', input: (source.isDataset ? source.lazy() : source).node });
|
|
1729
|
+
if (findDataset(plan)) driveSync(runPlanG(plan, writer));
|
|
1730
|
+
else { const t = execute(plan, true); try { writer.append(t); } finally { t.dispose(); } }
|
|
1731
|
+
} else writer.append(source);
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
writer.abort();
|
|
1734
|
+
throw err;
|
|
1735
|
+
}
|
|
1736
|
+
return writer.close();
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// A writer for a path (Node) or a writable file object (browsers), reading
|
|
1740
|
+
// the footer first when appending to a file that has one.
|
|
1741
|
+
export async function createDatasetAsync(engine, target, opts = {}) {
|
|
1742
|
+
const file = typeof target === 'string' ? need('writing a dataset').openWrite(target, { append: opts.append }) : target;
|
|
1743
|
+
let footer = null;
|
|
1744
|
+
if (file.existed) try { footer = file.readSync ? readFooter(file) : await readFooterAsync(file); } catch (err) { file.close(); throw err; }
|
|
1745
|
+
return new DatasetWriter(engine, file, { ...opts, footer });
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// writeDataset without blocking: queries run with the async calls, and the
|
|
1749
|
+
// writer's bytes are on disk before more are made.
|
|
1750
|
+
export async function writeDatasetAsync(engine, target, source, opts) {
|
|
1751
|
+
const writer = await createDatasetAsync(engine, target, opts);
|
|
1752
|
+
try {
|
|
1753
|
+
if (source instanceof Query || source?.isDataset) {
|
|
1754
|
+
const plan = optimize({ op: 'output', input: (source.isDataset ? source.lazy() : source).node });
|
|
1755
|
+
if (findDataset(plan)) await driveAsync(runPlanG(plan, writer));
|
|
1756
|
+
else { const t = await execute(plan, false); try { await writer.appendAsync(t); } finally { t.dispose(); } }
|
|
1757
|
+
} else await writer.appendAsync(source);
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
writer.abort();
|
|
1760
|
+
throw err;
|
|
1761
|
+
}
|
|
1762
|
+
return writer.closeAsync();
|
|
1763
|
+
}
|