@mason-data/cli 0.0.0-stage → 0.6.2
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/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE EXECUTOR WORKER (the test server: a JSON read through a Mason function over 22 M rows took 98 s, and
|
|
3
|
+
* for those 98 s the whole process was silent — the other sessions, the stream, the copy, the commands, even the
|
|
4
|
+
* cancel of a client that had left). Every `__mason_*` function is JavaScript, and DuckDB's executor threads call it
|
|
5
|
+
* on the thread whose Node environment REGISTERED it, one vector (2,048 rows) a call; with several executor threads
|
|
6
|
+
* calling, that thread's event loop runs a thousand calls between two turns of anything else. So DuckDB lives on a
|
|
7
|
+
* thread of its own, this module's worker (src/duck/executor-worker.ts): the instance, every connection, the
|
|
8
|
+
* functions; and this process's main thread — the wire listener, the catalog, the commands, the sync's own logic —
|
|
9
|
+
* never runs a function call.
|
|
10
|
+
*
|
|
11
|
+
* WHY EVERYTHING DuckDB MOVES, the writers' connections included: a DuckDB instance is a Node object of the thread that
|
|
12
|
+
* made it, never handed to another, and `DuckDBInstanceCache` is a cache per JavaScript object (each thread's singleton
|
|
13
|
+
* makes its own), so a worker cannot reach the main thread's instance. A SECOND instance of the same file is not
|
|
14
|
+
* refused inside one process (DuckDB's file lock is per process) and loses writes silently: measured, an
|
|
15
|
+
* instance's committed and checkpointed rows were gone once the other checkpointed (docs/DESIGN.md "The executor
|
|
16
|
+
* worker"). One instance per file, on the worker; the sync, the stream, the request log and the tests reach it through
|
|
17
|
+
* `DuckConnection`, a connection's methods posted to the worker (`RemoteConnection`), and the listener's sessions
|
|
18
|
+
* through `RemoteRunner`.
|
|
19
|
+
*
|
|
20
|
+
* WHAT CROSSES: a statement's text and parameters (structured clone), a materialized result's rows as DuckDB's JS and
|
|
21
|
+
* Json forms, a column's DuckDB type as its JSON (`typeFromJson`), a streamed result's rows ENCODED on the worker (the
|
|
22
|
+
* wire's DataRows, src/duck/store.ts encodeRows) and moved as one transferred buffer a chunk, an error as its SQLSTATE
|
|
23
|
+
* and words (a StatementError's fields) or DuckDB's message. A cancel does NOT cross as a message: the worker may be
|
|
24
|
+
* busy for seconds with a statement's calls, so the listener writes the CANCEL TABLE (src/duck/cancel.ts) the calls
|
|
25
|
+
* read, in memory both threads share; the message that follows only does what the calls cannot (an interrupt of a
|
|
26
|
+
* statement that calls none).
|
|
27
|
+
*
|
|
28
|
+
* A WORKER THAT DIES (V8 out of heap on that thread, an uncaught error) fails every request in flight and every later
|
|
29
|
+
* one: a session's statement is told 57P02 with the reason and to reconnect, and the run is told (`onCrash`) — it exits
|
|
30
|
+
* with the reason, and its supervisor starts it again (src/cli/dev.ts, src/cli/supervise.ts); DuckDB recovers the copy
|
|
31
|
+
* from its write-ahead log. A native abort ends the whole process, as it always did. The worker is not restarted in
|
|
32
|
+
* place: every connection the sync holds would be gone under it.
|
|
33
|
+
*/
|
|
34
|
+
import { Worker } from "node:worker_threads";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import * as duck from "@duckdb/node-api";
|
|
37
|
+
import { runnable } from "../lib/runnable.js";
|
|
38
|
+
import { StatementError } from "../api/postgres/server.js";
|
|
39
|
+
import { EncodeError } from "../api/postgres/encode.js";
|
|
40
|
+
import { NumericError } from "../lib/pg-numeric.js";
|
|
41
|
+
import { CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
|
|
42
|
+
// a cycle (store.ts opens the executor): read at run time only
|
|
43
|
+
import { failureOf } from "./store.js";
|
|
44
|
+
/** A DuckDB type from its `toJson()` (the worker's), as `@duckdb/node-api`'s own classes. */
|
|
45
|
+
export function typeFromJson(j) {
|
|
46
|
+
const o = j;
|
|
47
|
+
const id = Number(o.typeId);
|
|
48
|
+
const alias = typeof o.alias === "string" ? o.alias : undefined;
|
|
49
|
+
const T = duck.DuckDBTypeId;
|
|
50
|
+
switch (id) {
|
|
51
|
+
case T.DECIMAL: return new duck.DuckDBDecimalType(Number(o.width), Number(o.scale), alias);
|
|
52
|
+
case T.ENUM: return new duck.DuckDBEnumType(o.values, Number(o.internalTypeId), alias);
|
|
53
|
+
case T.LIST: return new duck.DuckDBListType(typeFromJson(o.valueType), alias);
|
|
54
|
+
case T.ARRAY: return new duck.DuckDBArrayType(typeFromJson(o.valueType), Number(o.length), alias);
|
|
55
|
+
case T.MAP: return new duck.DuckDBMapType(typeFromJson(o.keyType), typeFromJson(o.valueType), alias);
|
|
56
|
+
case T.STRUCT: return new duck.DuckDBStructType(o.entryNames, o.entryTypes.map(typeFromJson), alias);
|
|
57
|
+
case T.UNION: return new duck.DuckDBUnionType(o.memberTags, o.memberTypes.map(typeFromJson), alias);
|
|
58
|
+
case T.GEOMETRY: return duck.DuckDBGeometryType.create(alias, typeof o.crs === "string" ? o.crs : undefined);
|
|
59
|
+
}
|
|
60
|
+
const simple = SIMPLE_TYPES.get(id);
|
|
61
|
+
if (simple === undefined)
|
|
62
|
+
throw new Error(`a DuckDB type Mason cannot carry between threads: ${JSON.stringify(j)} (Mason bug: report it)`);
|
|
63
|
+
return simple(alias);
|
|
64
|
+
}
|
|
65
|
+
/** every type of no parameters, by its id: each class's `create(alias)` */
|
|
66
|
+
const SIMPLE_TYPES = new Map(Object.values(duck).flatMap((v) => {
|
|
67
|
+
const c = v;
|
|
68
|
+
return typeof v === "function" && typeof c.instance?.typeId === "number" && typeof c.create === "function"
|
|
69
|
+
? [[c.instance.typeId, (alias) => c.create(alias)]] : [];
|
|
70
|
+
}));
|
|
71
|
+
/** the class of a DuckDB value of each type id that has one (the rest are plain: a string, a number, a bigint …) */
|
|
72
|
+
const VALUE_CLASS = new Map([
|
|
73
|
+
[duck.DuckDBTypeId.DATE, duck.DuckDBDateValue.prototype], [duck.DuckDBTypeId.TIME, duck.DuckDBTimeValue.prototype],
|
|
74
|
+
[duck.DuckDBTypeId.TIME_NS, duck.DuckDBTimeNSValue.prototype], [duck.DuckDBTypeId.TIME_TZ, duck.DuckDBTimeTZValue.prototype],
|
|
75
|
+
[duck.DuckDBTypeId.TIMESTAMP, duck.DuckDBTimestampValue.prototype], [duck.DuckDBTypeId.TIMESTAMP_S, duck.DuckDBTimestampSecondsValue.prototype],
|
|
76
|
+
[duck.DuckDBTypeId.TIMESTAMP_MS, duck.DuckDBTimestampMillisecondsValue.prototype], [duck.DuckDBTypeId.TIMESTAMP_NS, duck.DuckDBTimestampNanosecondsValue.prototype],
|
|
77
|
+
[duck.DuckDBTypeId.TIMESTAMP_TZ, duck.DuckDBTimestampTZValue.prototype], [duck.DuckDBTypeId.TIMESTAMP_TZ_NS, duck.DuckDBTimestampTZNanosecondsValue.prototype],
|
|
78
|
+
[duck.DuckDBTypeId.INTERVAL, duck.DuckDBIntervalValue.prototype], [duck.DuckDBTypeId.DECIMAL, duck.DuckDBDecimalValue.prototype],
|
|
79
|
+
[duck.DuckDBTypeId.BLOB, duck.DuckDBBlobValue.prototype], [duck.DuckDBTypeId.UUID, duck.DuckDBUUIDValue.prototype],
|
|
80
|
+
[duck.DuckDBTypeId.BIT, duck.DuckDBBitValue.prototype], [duck.DuckDBTypeId.GEOMETRY, duck.DuckDBGeometryValue.prototype],
|
|
81
|
+
[duck.DuckDBTypeId.LIST, duck.DuckDBListValue.prototype], [duck.DuckDBTypeId.ARRAY, duck.DuckDBArrayValue.prototype],
|
|
82
|
+
[duck.DuckDBTypeId.STRUCT, duck.DuckDBStructValue.prototype], [duck.DuckDBTypeId.MAP, duck.DuckDBMapValue.prototype],
|
|
83
|
+
[duck.DuckDBTypeId.UNION, duck.DuckDBUnionValue.prototype],
|
|
84
|
+
]);
|
|
85
|
+
/** A DuckDB value as structured clone carried it (its fields, every one public, without its class), given its class
|
|
86
|
+
* back — and its parts theirs — by its type. */
|
|
87
|
+
export function valueFromClone(v, t) {
|
|
88
|
+
if (v === null || typeof v !== "object")
|
|
89
|
+
return v;
|
|
90
|
+
if (t.typeId === duck.DuckDBTypeId.VARIANT)
|
|
91
|
+
throw new Error("a VARIANT value cannot be carried between threads (Mason bug: report it)");
|
|
92
|
+
const proto = VALUE_CLASS.get(t.typeId);
|
|
93
|
+
if (proto === undefined)
|
|
94
|
+
return v;
|
|
95
|
+
const o = v;
|
|
96
|
+
if (t instanceof duck.DuckDBListType || t instanceof duck.DuckDBArrayType)
|
|
97
|
+
o.items = o.items.map((x) => valueFromClone(x, t.valueType));
|
|
98
|
+
else if (t instanceof duck.DuckDBStructType) {
|
|
99
|
+
const entries = o.entries;
|
|
100
|
+
t.entryNames.forEach((n, i) => { if (n in entries)
|
|
101
|
+
entries[n] = valueFromClone(entries[n], t.entryTypes[i]); });
|
|
102
|
+
}
|
|
103
|
+
else if (t instanceof duck.DuckDBMapType) {
|
|
104
|
+
o.entries = o.entries.map((e) => ({ key: valueFromClone(e.key, t.keyType), value: valueFromClone(e.value, t.valueType) }));
|
|
105
|
+
}
|
|
106
|
+
else if (t instanceof duck.DuckDBUnionType) {
|
|
107
|
+
const at = t.memberTags.indexOf(String(o.tag));
|
|
108
|
+
if (at >= 0)
|
|
109
|
+
o.value = valueFromClone(o.value, t.memberTypes[at]);
|
|
110
|
+
}
|
|
111
|
+
return Object.setPrototypeOf(o, proto);
|
|
112
|
+
}
|
|
113
|
+
export function wireError(e) {
|
|
114
|
+
if (e instanceof StatementError)
|
|
115
|
+
return { kind: "statement", fields: e.fields, sql: e.sql };
|
|
116
|
+
if (e instanceof EncodeError)
|
|
117
|
+
return { kind: "encode", message: e.message, code: e.code };
|
|
118
|
+
if (e instanceof NumericError)
|
|
119
|
+
return { kind: "numeric", message: e.message, sqlstate: e.sqlstate };
|
|
120
|
+
return { kind: "error", message: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : "Error" };
|
|
121
|
+
}
|
|
122
|
+
/** the worker is gone: what every request in flight, and every later one, is answered. `stopped`: by the run's own stop
|
|
123
|
+
* (`close`), not a crash */
|
|
124
|
+
export class ExecutorGone extends Error {
|
|
125
|
+
stopped;
|
|
126
|
+
constructor(reason, stopped = false) { super(reason); this.stopped = stopped; }
|
|
127
|
+
}
|
|
128
|
+
/** What a session's statement is told when the run stops under it (mason stop, a shutdown): Postgres's own words for a
|
|
129
|
+
* server shutting down (57P01), from the worker's stop (executor-worker.ts `shutdown`) or the closed executor alike */
|
|
130
|
+
export const STOPPING = { code: "57P01", message: "terminating connection due to administrator command",
|
|
131
|
+
detail: "Mason is stopping (mason stop, or a shutdown)", hint: "connect again once Mason runs again (mason status says whether it does)" };
|
|
132
|
+
const errorOf = (e) => {
|
|
133
|
+
if (e.kind === "statement")
|
|
134
|
+
return new StatementError(e.fields, e.sql);
|
|
135
|
+
if (e.kind === "encode")
|
|
136
|
+
return new EncodeError(e.message, e.code);
|
|
137
|
+
if (e.kind === "numeric")
|
|
138
|
+
return new NumericError(e.message, e.sqlstate);
|
|
139
|
+
const err = new Error(e.message);
|
|
140
|
+
err.name = e.name;
|
|
141
|
+
return err;
|
|
142
|
+
};
|
|
143
|
+
/** a value a statement may be given across threads: plain, never a DuckDB value object (its class would be lost) */
|
|
144
|
+
function plainValues(values) {
|
|
145
|
+
if (values === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
for (const v of values) {
|
|
148
|
+
if (v === null || typeof v !== "object" || Array.isArray(v) || ArrayBuffer.isView(v) || v instanceof Date)
|
|
149
|
+
continue;
|
|
150
|
+
if (Object.getPrototypeOf(v) !== Object.prototype)
|
|
151
|
+
throw new Error(`a ${v.constructor.name} cannot be sent to DuckDB's thread: pass its text or a plain value (Mason bug: report it)`);
|
|
152
|
+
}
|
|
153
|
+
return [...values];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* EVERY CALL TO THE WORKER SETTLES (under load the worker deadlocked inside DuckDB, src/duck/store.ts
|
|
157
|
+
* `cleanUp`, and every request to it — the session's statements, the store's close, the test's after-hook — waited for
|
|
158
|
+
* ever, 0 % CPU). Two bounds:
|
|
159
|
+
* - THE WORKER MUST BE HEARD: while a request is pending the executor pings it every `PING_MS`, and a worker that has
|
|
160
|
+
* said nothing (no answer, no log line, no pong) for `SILENT_MS` is declared stopped — every request in flight and
|
|
161
|
+
* later is told 57P02, and the run is told (`onCrash`), as for a crash. Not shorter: a statement calling a Mason
|
|
162
|
+
* function holds the worker's turn for up to a thousand calls at a time (seconds on a loaded machine), and closing a
|
|
163
|
+
* call-free result waits for DuckDB to honour an interrupt (~10 s for one join, docs/DESIGN.md "The executor
|
|
164
|
+
* worker"); a deadlock is for ever, so a longer bound only says it later.
|
|
165
|
+
* - THE CLOSE IS BOUNDED: `close` gives the worker's shutdown `CLOSE_GRACE_MS` (a calling statement stops at its next
|
|
166
|
+
* call, milliseconds; a call-free one at DuckDB's interrupt; and the whole stop must fit inside a platform's 10 s
|
|
167
|
+
* before SIGKILL and `mason stop`'s 15 s), then declares it stopped the same way, asks it to terminate, and resolves.
|
|
168
|
+
* What neither can do: end a thread blocked inside native code. Such a process does not exit by itself, nor on
|
|
169
|
+
* process.exit (measured); its supervisor's SIGKILL does. The copy is not opened again in this process
|
|
170
|
+
* (`abandoned`): a second instance of the file would lose writes.
|
|
171
|
+
*/
|
|
172
|
+
export const PING_MS = 5_000;
|
|
173
|
+
export const SILENT_MS = 60_000;
|
|
174
|
+
export const CLOSE_GRACE_MS = 5_000;
|
|
175
|
+
export class Executor {
|
|
176
|
+
worker;
|
|
177
|
+
log;
|
|
178
|
+
pending = new Map();
|
|
179
|
+
nextId = 1;
|
|
180
|
+
/** why the worker is gone, once it is */
|
|
181
|
+
gone = null;
|
|
182
|
+
closing = false;
|
|
183
|
+
exited;
|
|
184
|
+
crashed = [];
|
|
185
|
+
/** the cancel table, shared with the worker (src/duck/cancel.ts), and its free slots */
|
|
186
|
+
cancels = cancelTable();
|
|
187
|
+
free = Array.from({ length: CANCEL_SLOTS }, (_, i) => CANCEL_SLOTS - 1 - i);
|
|
188
|
+
/** when the worker last said anything (performance.now()) */
|
|
189
|
+
heard = performance.now();
|
|
190
|
+
watch;
|
|
191
|
+
silentMs;
|
|
192
|
+
/** why the worker was given up on without exiting (`close`'s bound, the silence), once it was: its instance may
|
|
193
|
+
* still hold the file, which this process must never open again */
|
|
194
|
+
abandoned = null;
|
|
195
|
+
constructor(o) {
|
|
196
|
+
this.silentMs = o.silentMs ?? SILENT_MS;
|
|
197
|
+
this.log = o.log;
|
|
198
|
+
this.worker = new Worker(runnable(o.workerFile ?? fileURLToPath(new URL("./executor-worker.ts", import.meta.url))), { workerData: { cancels: this.cancels } });
|
|
199
|
+
// the process may end while nothing is asked of it (a script, a test that forgot to close): referenced only while
|
|
200
|
+
// a request is in flight (`call`)
|
|
201
|
+
this.worker.unref();
|
|
202
|
+
this.worker.on("message", (m) => {
|
|
203
|
+
this.heard = performance.now();
|
|
204
|
+
if ("pong" in m)
|
|
205
|
+
return;
|
|
206
|
+
if ("log" in m) {
|
|
207
|
+
this.log(m.log);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const p = this.pending.get(m.id);
|
|
211
|
+
if (p === undefined)
|
|
212
|
+
return;
|
|
213
|
+
this.settle(m.id);
|
|
214
|
+
if (m.ok)
|
|
215
|
+
p.resolve(m.value);
|
|
216
|
+
else
|
|
217
|
+
p.reject(errorOf(m.error));
|
|
218
|
+
});
|
|
219
|
+
let why = null;
|
|
220
|
+
this.worker.on("error", (e) => {
|
|
221
|
+
why = e.code === "ERR_WORKER_OUT_OF_MEMORY" ? `its thread ran out of JavaScript heap (${e.message})` : `it failed: ${e.message}`;
|
|
222
|
+
});
|
|
223
|
+
this.exited = new Promise((resolve) => this.worker.once("exit", (code) => {
|
|
224
|
+
this.die(this.closing && why === null ? "it was closed" : why ?? `it exited with code ${code}`);
|
|
225
|
+
resolve();
|
|
226
|
+
}));
|
|
227
|
+
// the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer
|
|
228
|
+
this.watch = setInterval(() => {
|
|
229
|
+
if (this.gone !== null || this.pending.size === 0) {
|
|
230
|
+
this.heard = performance.now();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (performance.now() - this.heard > this.silentMs) {
|
|
234
|
+
this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s with ${this.pending.size} request(s) waiting (stuck inside DuckDB: Mason bug, report it with mason logs)`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.worker.postMessage({ op: "ping" });
|
|
238
|
+
}, Math.min(PING_MS, this.silentMs / 4));
|
|
239
|
+
this.watch.unref();
|
|
240
|
+
}
|
|
241
|
+
/** the worker given up on without its exit: every request told so (57P02), the run told, and a terminate asked —
|
|
242
|
+
* which a thread blocked inside native code never honours, so it is not waited for */
|
|
243
|
+
abandon(reason) {
|
|
244
|
+
if (this.gone !== null)
|
|
245
|
+
return;
|
|
246
|
+
this.abandoned = reason;
|
|
247
|
+
this.die(reason, false);
|
|
248
|
+
this.worker.unref();
|
|
249
|
+
void this.worker.terminate().catch((e) => this.log(`ending the copy's thread failed: ${e.message}`));
|
|
250
|
+
}
|
|
251
|
+
static async start(o) {
|
|
252
|
+
const e = new Executor(o);
|
|
253
|
+
try {
|
|
254
|
+
await e.call({ op: "open", file: o.file, settings: o.settings });
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
await e.close();
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
return e;
|
|
261
|
+
}
|
|
262
|
+
/** called once if the worker dies while the run uses it (never after `close`) */
|
|
263
|
+
onCrash(f) { this.crashed.push(f); }
|
|
264
|
+
die(reason, stopped = this.closing) {
|
|
265
|
+
if (this.gone !== null)
|
|
266
|
+
return;
|
|
267
|
+
this.gone = reason;
|
|
268
|
+
clearInterval(this.watch);
|
|
269
|
+
const err = new ExecutorGone(reason, stopped);
|
|
270
|
+
for (const [id, p] of [...this.pending]) {
|
|
271
|
+
this.settle(id);
|
|
272
|
+
p.reject(err);
|
|
273
|
+
}
|
|
274
|
+
if (!stopped) {
|
|
275
|
+
this.log(`the copy's engine stopped: ${reason}`);
|
|
276
|
+
if (!this.closing)
|
|
277
|
+
for (const f of this.crashed)
|
|
278
|
+
f(reason);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
settle(id) {
|
|
282
|
+
this.pending.delete(id);
|
|
283
|
+
if (this.pending.size === 0)
|
|
284
|
+
this.worker.unref();
|
|
285
|
+
}
|
|
286
|
+
/** a request, answered by the worker */
|
|
287
|
+
call(m, transfer = []) {
|
|
288
|
+
if (this.gone !== null)
|
|
289
|
+
return Promise.reject(new ExecutorGone(this.gone, this.closing));
|
|
290
|
+
const id = this.nextId++;
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
if (this.pending.size === 0)
|
|
293
|
+
this.worker.ref();
|
|
294
|
+
this.pending.set(id, { resolve: resolve, reject });
|
|
295
|
+
this.worker.postMessage({ ...m, id }, transfer);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/** a message needing no answer (an interrupt, a close of an answer); nothing once the worker is gone */
|
|
299
|
+
post(m) {
|
|
300
|
+
if (this.gone === null)
|
|
301
|
+
this.worker.postMessage(m);
|
|
302
|
+
}
|
|
303
|
+
/** a connection of the instance */
|
|
304
|
+
async connect() { return new RemoteConnection(this, await this.call({ op: "connect" })); }
|
|
305
|
+
writer() { return new RemoteConnection(this, 0); }
|
|
306
|
+
/** the libc order's two functions (src/duck/functions.ts registerLibcOrder), registered on the worker */
|
|
307
|
+
async registerLibcOrder(order) { await this.call({ op: "libc", order }); }
|
|
308
|
+
/** how many vectors each Mason function was called with so far (src/duck/functions.ts FUNCTION_CALLS: the worker's
|
|
309
|
+
* count, the functions being its) — what a test or a measurement reads to know whether a statement reached one */
|
|
310
|
+
async functionCalls() { return new Map(Object.entries(await this.call({ op: "calls" }))); }
|
|
311
|
+
/** a wire session's connection: a slot of the cancel table, its connection on the worker */
|
|
312
|
+
async runner() {
|
|
313
|
+
const slot = this.free.pop();
|
|
314
|
+
if (slot === undefined)
|
|
315
|
+
throw new StatementError({ code: "53300", message: `too many sessions are reading the copy at once (${CANCEL_SLOTS})`, hint: "close idle connections (a pool's size), then connect again" });
|
|
316
|
+
resetSlot(this.cancels, slot);
|
|
317
|
+
try {
|
|
318
|
+
return new RemoteRunner(this, await this.call({ op: "runner.open", slot }), slot);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
this.free.push(slot);
|
|
322
|
+
throw e instanceof ExecutorGone ? goneError(e, "") : e;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
release(slot) { this.free.push(slot); }
|
|
326
|
+
/** Closes every connection and the instance on the worker, then the worker; resolves once it has exited, or once
|
|
327
|
+
* `graceMs` has passed (CLOSE_GRACE_MS says why), whatever the worker does. Never rejects: a failure is said in the
|
|
328
|
+
* log. */
|
|
329
|
+
close(graceMs = CLOSE_GRACE_MS) {
|
|
330
|
+
this.closed ??= this.closeWithin(graceMs);
|
|
331
|
+
return this.closed;
|
|
332
|
+
}
|
|
333
|
+
closed = null;
|
|
334
|
+
async closeWithin(graceMs) {
|
|
335
|
+
this.closing = true;
|
|
336
|
+
let timer;
|
|
337
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve("late"), graceMs); });
|
|
338
|
+
const closed = (async () => {
|
|
339
|
+
if (this.gone === null) {
|
|
340
|
+
try {
|
|
341
|
+
await this.call({ op: "shutdown" });
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
if (!(e instanceof ExecutorGone))
|
|
345
|
+
this.log(`closing the copy failed: ${e.message}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// the worker ends itself after its shutdown: a terminate is the way out when the shutdown failed
|
|
349
|
+
if (this.gone === null)
|
|
350
|
+
await this.worker.terminate();
|
|
351
|
+
await this.exited;
|
|
352
|
+
return "closed";
|
|
353
|
+
})().catch((e) => { this.log(`closing the copy failed: ${e.message}`); return "closed"; });
|
|
354
|
+
try {
|
|
355
|
+
if (await Promise.race([closed, late]) === "late") {
|
|
356
|
+
this.log(`the copy's thread did not close within ${graceMs / 1000} s: given up on (its sessions are told 57P02); the process may need a SIGKILL to end`);
|
|
357
|
+
this.abandon(`it did not close within ${graceMs / 1000} s`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// ------------------------------------------------------------------------------------------------- a connection
|
|
366
|
+
class RemoteConnection {
|
|
367
|
+
ex;
|
|
368
|
+
conn;
|
|
369
|
+
constructor(ex, conn) { this.ex = ex; this.conn = conn; }
|
|
370
|
+
async run(sql, values) {
|
|
371
|
+
return this.ex.call({ op: "conn.run", conn: this.conn, sql, values: plainValues(values) });
|
|
372
|
+
}
|
|
373
|
+
async runAndReadAll(sql, values) {
|
|
374
|
+
const r = await this.ex.call({ op: "conn.read", conn: this.conn, sql, values: plainValues(values) });
|
|
375
|
+
const types = r.types.map(typeFromJson);
|
|
376
|
+
let rows = null;
|
|
377
|
+
return {
|
|
378
|
+
columnType: (i) => types[i],
|
|
379
|
+
columnNames: r.names,
|
|
380
|
+
getRowsJS: () => r.js,
|
|
381
|
+
getRowsJson: () => r.json,
|
|
382
|
+
getRows: () => (rows ??= r.rows.map((row) => row.map((v, i) => valueFromClone(v, types[i])))),
|
|
383
|
+
getRowObjectsJson: () => r.json.map((row) => Object.fromEntries(r.names.map((n, i) => [n, row[i]]))),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async prepare(sql) {
|
|
387
|
+
const r = await this.ex.call({ op: "conn.prepare", conn: this.conn, sql });
|
|
388
|
+
const types = r.types.map(typeFromJson);
|
|
389
|
+
return { columnCount: r.names.length, columnName: (i) => r.names[i], columnType: (i) => types[i], destroySync: () => undefined };
|
|
390
|
+
}
|
|
391
|
+
interrupt() { this.ex.post({ op: "conn.interrupt", conn: this.conn }); }
|
|
392
|
+
closeSync() { this.ex.post({ op: "conn.close", conn: this.conn }); }
|
|
393
|
+
}
|
|
394
|
+
/** the reason an abort carries across: the statement timeout's fields, else none (the client's cancel) */
|
|
395
|
+
const reasonOf = (signal) => !signal.aborted ? null : signal.reason instanceof StatementError ? { fields: signal.reason.fields } : {};
|
|
396
|
+
/** what a session is told when the worker is gone mid-statement */
|
|
397
|
+
function goneError(e, sql) {
|
|
398
|
+
if (e.stopped)
|
|
399
|
+
return new StatementError(STOPPING, sql);
|
|
400
|
+
return new StatementError({ code: "57P02", message: "terminating connection because the copy's engine stopped", detail: e.message,
|
|
401
|
+
hint: "Mason starts again by itself: reconnect in a few seconds and repeat the statement (mason status says when it is back)" }, sql);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* A wire session's connection on the worker (src/duck/store.ts SessionRunner runs there). Each statement has a number;
|
|
405
|
+
* its cancel is written to the cancel table AT ONCE when it calls a Mason function, then posted for the rest. Its
|
|
406
|
+
* failure is read against THIS thread's signal (`failureOf`), so a timeout is said as the timeout whichever reached the
|
|
407
|
+
* worker first, the abort or the cancelled call's error.
|
|
408
|
+
*/
|
|
409
|
+
class RemoteRunner {
|
|
410
|
+
ex;
|
|
411
|
+
id;
|
|
412
|
+
slot;
|
|
413
|
+
seq = 0;
|
|
414
|
+
closed = false;
|
|
415
|
+
constructor(ex, id, slot) { this.ex = ex; this.id = id; this.slot = slot; }
|
|
416
|
+
/** a statement's abort, from the moment it is asked until `end`: written to the cancel table at once when the plan
|
|
417
|
+
* says it calls a Mason function (`PlannedSql.functions`, never read off its text), then posted for the rest */
|
|
418
|
+
watch(seq, sql, signal) {
|
|
419
|
+
const calls = sql.functions.length > 0;
|
|
420
|
+
const abort = () => {
|
|
421
|
+
if (calls)
|
|
422
|
+
cancelStatement(this.ex.cancels, this.slot, seq);
|
|
423
|
+
this.ex.post({ op: "runner.abort", runner: this.id, seq, reason: reasonOf(signal) });
|
|
424
|
+
};
|
|
425
|
+
signal.addEventListener("abort", abort);
|
|
426
|
+
if (signal.aborted && calls)
|
|
427
|
+
cancelStatement(this.ex.cancels, this.slot, seq);
|
|
428
|
+
return () => signal.removeEventListener("abort", abort);
|
|
429
|
+
}
|
|
430
|
+
failure(signal, e, sql) {
|
|
431
|
+
if (e instanceof ExecutorGone)
|
|
432
|
+
return goneError(e, sql.text);
|
|
433
|
+
// a cancelled statement's failure read under THIS signal (the same function as the worker's, src/duck/store.ts),
|
|
434
|
+
// since the cancelled call's error may reach the worker before the abort does; any other failure as the worker
|
|
435
|
+
// threw it (an EncodeError is the listener's to word)
|
|
436
|
+
return signal.aborted ? failureOf(signal, e, sql.text) : e;
|
|
437
|
+
}
|
|
438
|
+
async run(sql, params, session, signal, fields = null, exact = []) {
|
|
439
|
+
const seq = ++this.seq;
|
|
440
|
+
const unwatch = this.watch(seq, sql, signal);
|
|
441
|
+
let columns;
|
|
442
|
+
try {
|
|
443
|
+
columns = await this.ex.call({ op: "runner.run", runner: this.id, seq, sql, params, session, fields, exact, aborted: reasonOf(signal) });
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
unwatch();
|
|
447
|
+
throw this.failure(signal, e, sql);
|
|
448
|
+
}
|
|
449
|
+
let ended = false;
|
|
450
|
+
const end = () => { if (!ended) {
|
|
451
|
+
ended = true;
|
|
452
|
+
unwatch();
|
|
453
|
+
} };
|
|
454
|
+
const calls = sql.functions.length > 0;
|
|
455
|
+
return {
|
|
456
|
+
columns,
|
|
457
|
+
fetch: async (formats, s) => {
|
|
458
|
+
if (ended)
|
|
459
|
+
return null;
|
|
460
|
+
let got;
|
|
461
|
+
try {
|
|
462
|
+
got = await this.ex.call({ op: "answer.fetch", runner: this.id, seq, formats, timeZone: s.timeZone });
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
end();
|
|
466
|
+
throw this.failure(signal, e, sql);
|
|
467
|
+
}
|
|
468
|
+
// A STATEMENT WHOSE CANCEL WAS ASKED IS NEVER "ok" (a review finding): a cancel or a timeout that landed
|
|
469
|
+
// after its last function call, or after DuckDB computed its last chunk, found nothing left to stop, and the
|
|
470
|
+
// result came back whole — the client is told 57014 all the same, as THIS thread's signal says it (the timeout's
|
|
471
|
+
// words, or the cancel's)
|
|
472
|
+
if (got === null) {
|
|
473
|
+
end();
|
|
474
|
+
if (signal.aborted)
|
|
475
|
+
throw failureOf(signal, null, sql.text);
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const all = Buffer.from(got.bytes);
|
|
479
|
+
return got.ends.map((to, i) => all.subarray(i === 0 ? 0 : got.ends[i - 1], to));
|
|
480
|
+
},
|
|
481
|
+
close: () => {
|
|
482
|
+
if (ended)
|
|
483
|
+
return;
|
|
484
|
+
end();
|
|
485
|
+
// its calls see the cancel now; the worker cancels the rest (an interrupt) and releases it once DuckDB hands it back
|
|
486
|
+
if (calls)
|
|
487
|
+
cancelStatement(this.ex.cancels, this.slot, seq);
|
|
488
|
+
this.ex.post({ op: "answer.close", runner: this.id, seq });
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
async explain(sql, params, session, signal, how) {
|
|
493
|
+
const seq = ++this.seq;
|
|
494
|
+
const unwatch = this.watch(seq, sql, signal);
|
|
495
|
+
let lines;
|
|
496
|
+
try {
|
|
497
|
+
lines = await this.ex.call({ op: "runner.explain", runner: this.id, seq, sql, params, session, how, aborted: reasonOf(signal) });
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
throw this.failure(signal, e, sql);
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
unwatch();
|
|
504
|
+
}
|
|
505
|
+
// as `fetch`'s end: an EXPLAIN ANALYZE whose cancel was asked is never answered
|
|
506
|
+
if (signal.aborted)
|
|
507
|
+
throw failureOf(signal, null, sql.text);
|
|
508
|
+
return lines;
|
|
509
|
+
}
|
|
510
|
+
/** closes the connection once its answers are released (the worker waits for them: never under the executor) */
|
|
511
|
+
async close() {
|
|
512
|
+
if (this.closed)
|
|
513
|
+
return;
|
|
514
|
+
this.closed = true;
|
|
515
|
+
try {
|
|
516
|
+
await this.ex.call({ op: "runner.close", runner: this.id });
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
if (!(e instanceof ExecutorGone))
|
|
520
|
+
throw e;
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
this.ex.release(this.slot);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** a node of DuckDB's JSON, either form: `name` (EXPLAIN) or `type` with `intermediate_rows` and `timing` (the profile).
|
|
2
|
+
* The profile's RESULT_COLLECTOR and EXECUTE are the result's plumbing, not the plan's: their child stands for them */
|
|
3
|
+
export function planNodes(json) {
|
|
4
|
+
const list = Array.isArray(json) ? json : [json];
|
|
5
|
+
return list.flatMap((n) => {
|
|
6
|
+
const b = n;
|
|
7
|
+
const type = String(b.name ?? b.type ?? "");
|
|
8
|
+
const children = planNodes((b.children ?? []));
|
|
9
|
+
if (type === "RESULT_COLLECTOR" || type === "EXECUTE" || type === "EXPLAIN_ANALYZE")
|
|
10
|
+
return children;
|
|
11
|
+
const extra = { ...(b.extra_info ?? {}) };
|
|
12
|
+
const est = extra["Estimated Cardinality"];
|
|
13
|
+
delete extra["Estimated Cardinality"];
|
|
14
|
+
const node = { type: type === "SEQ_SCAN" ? "TABLE_SCAN" : type, extra, estimate: typeof est === "string" && /^\d+$/.test(est) ? Number(est) : null, children };
|
|
15
|
+
if (typeof b.intermediate_rows === "number") {
|
|
16
|
+
node.actualRows = b.intermediate_rows;
|
|
17
|
+
node.threadMs = Number(b.timing ?? 0) * 1000 + children.reduce((s, c) => s + (c.threadMs ?? 0), 0);
|
|
18
|
+
}
|
|
19
|
+
return [node];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** DuckDB's operator as DuckDB's own plan prints it (`HASH_GROUP_BY` is `Hash Group By`), a scan `Table Scan` as its
|
|
23
|
+
* profiler names it: DuckDB's EXPLAIN says SEQ_SCAN for the same operator, and Postgres's `Seq Scan` says otherwise */
|
|
24
|
+
export const operatorName = (type) => type.toLowerCase().split("_").map((w) => w === "n" ? "N" : w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
25
|
+
/** the copy a scan reads, as Postgres names a table (DuckDB's `<file>.<schema>.<table>`) */
|
|
26
|
+
export const scannedTable = (n) => {
|
|
27
|
+
const t = n.extra.Table;
|
|
28
|
+
return typeof t === "string" ? t.split(".").slice(-2).join(".") : null;
|
|
29
|
+
};
|
|
30
|
+
export function scannedTables(nodes) {
|
|
31
|
+
const out = [];
|
|
32
|
+
const walk = (n) => { const t = scannedTable(n); if (t !== null && !out.includes(t))
|
|
33
|
+
out.push(t); n.children.forEach(walk); };
|
|
34
|
+
nodes.forEach(walk);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
const ms = (x) => x.toFixed(3);
|
|
38
|
+
const count = (n) => n.toLocaleString("en-US");
|
|
39
|
+
const detail = (v) => Array.isArray(v) ? v.join(", ") : v;
|
|
40
|
+
/** the details a plan line carries without VERBOSE: everything but the projections and filters, which VERBOSE adds */
|
|
41
|
+
const VERBOSE_ONLY = new Set(["Projections", "Filters", "Table", "Type"]);
|
|
42
|
+
function copyLine(c) {
|
|
43
|
+
const rows = c.rows === null ? "rows unknown" : `about ${count(c.rows)} rows`;
|
|
44
|
+
return `${c.table} (${rows}; kept current by polling, ${c.polledAt === null ? "no poll recorded yet" : `last poll ${c.polledAt}`})`;
|
|
45
|
+
}
|
|
46
|
+
/** EXPLAIN as Postgres's text format: one line a row */
|
|
47
|
+
export function explainText(e, o) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
lines.push(e.copies.length === 0 ? "Mason: answered by DuckDB, reading none of its copies"
|
|
50
|
+
: e.copies.length === 1 ? `Mason: answered by DuckDB from its copy of ${copyLine(e.copies[0])}` : "Mason: answered by DuckDB from its copies of");
|
|
51
|
+
if (e.copies.length > 1)
|
|
52
|
+
for (const c of e.copies)
|
|
53
|
+
lines.push(` ${copyLine(c)}`);
|
|
54
|
+
lines.push(` Sent to DuckDB: ${e.sql}`);
|
|
55
|
+
lines.push(` Computed by Mason after DuckDB: ${e.computed.length === 0 ? "nothing" : e.computed.join("; ")}`);
|
|
56
|
+
if (e.functions.length > 0)
|
|
57
|
+
lines.push(` Mason's functions DuckDB calls: ${e.functions.join(", ")}`);
|
|
58
|
+
for (const name of o.ignored)
|
|
59
|
+
lines.push(` Note: ${name} has no DuckDB meaning: ignored`);
|
|
60
|
+
if (e.analyzed !== undefined && o.timing)
|
|
61
|
+
lines.push(" Note: an operator's thread time is DuckDB's, summed over the threads that ran it and the operators under it: a parallel one's exceeds the Execution Time");
|
|
62
|
+
const walk = (n, depth) => {
|
|
63
|
+
const lead = depth === 0 ? "" : `${" ".repeat(6 * depth - 4)}-> `;
|
|
64
|
+
const on = scannedTable(n);
|
|
65
|
+
const est = o.costs && n.estimate !== null ? ` (rows=${n.estimate})` : "";
|
|
66
|
+
const actual = n.actualRows === undefined ? "" : ` (actual rows=${n.actualRows}${o.timing && n.threadMs !== undefined ? `, thread time=${ms(n.threadMs)} ms` : ""})`;
|
|
67
|
+
lines.push(`${lead}${operatorName(n.type)}${on === null ? "" : ` on ${on}`}${est}${actual}`);
|
|
68
|
+
const pad = " ".repeat(depth === 0 ? 2 : 6 * depth + 2);
|
|
69
|
+
for (const [k, v] of Object.entries(n.extra))
|
|
70
|
+
if (o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k))
|
|
71
|
+
lines.push(`${pad}${k}: ${detail(v)}`);
|
|
72
|
+
for (const c of n.children)
|
|
73
|
+
walk(c, depth + 1);
|
|
74
|
+
};
|
|
75
|
+
e.plan.forEach((n) => walk(n, 0));
|
|
76
|
+
if (e.analyzed !== undefined && o.summary) {
|
|
77
|
+
const p = e.analyzed.planning, x = e.analyzed.execution;
|
|
78
|
+
lines.push(`Planning Time: ${ms(p.analysisMs + p.rewriteMs + p.duckdbMs)} ms (Postgres's analysis ${ms(p.analysisMs)}, Mason's rewrite ${ms(p.rewriteMs)}, DuckDB's planning ${ms(p.duckdbMs)})`);
|
|
79
|
+
lines.push(`Execution Time: ${ms(x.duckdbMs + x.masonMs)} ms (DuckDB ${ms(x.duckdbMs)}, Mason's conversion and exact numerics ${ms(x.masonMs)})`);
|
|
80
|
+
}
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
/** EXPLAIN as Postgres's JSON format: `[{ "Plan": { "Node Type", "Plans", "Plan Rows", "Actual Rows" … } }]` where the
|
|
84
|
+
* meaning is Postgres's, DuckDB's operator names, and what is DuckDB's own in keys of their own — its thread time is
|
|
85
|
+
* `DuckDB Thread Time`, never `Actual Total Time`, which a plan viewer draws as elapsed */
|
|
86
|
+
export function explainJson(e, o) {
|
|
87
|
+
const node = (n) => {
|
|
88
|
+
const on = scannedTable(n);
|
|
89
|
+
return {
|
|
90
|
+
"Node Type": operatorName(n.type),
|
|
91
|
+
...(on === null ? {} : { "Relation Name": on.split(".")[1], Schema: on.split(".")[0] }),
|
|
92
|
+
...(o.costs && n.estimate !== null ? { "Plan Rows": n.estimate } : {}),
|
|
93
|
+
...(n.actualRows === undefined ? {} : { "Actual Rows": n.actualRows, "Actual Loops": 1, ...(o.timing && n.threadMs !== undefined ? { "DuckDB Thread Time": Number(ms(n.threadMs)) } : {}) }),
|
|
94
|
+
...Object.fromEntries(Object.entries(n.extra).filter(([k]) => o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k)).map(([k, v]) => [`DuckDB ${k}`, v])),
|
|
95
|
+
...(n.children.length === 0 ? {} : { Plans: n.children.map(node) }),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
// Postgres's plan has one root; DuckDB's has one too (a statement's), kept as a list should it ever have more
|
|
99
|
+
const roots = e.plan.map(node);
|
|
100
|
+
const top = {
|
|
101
|
+
Plan: roots.length === 1 ? roots[0] : { "Node Type": "DuckDB Plans", Plans: roots },
|
|
102
|
+
Mason: {
|
|
103
|
+
"Answered By": "DuckDB",
|
|
104
|
+
Copies: e.copies.map((c) => ({ Table: c.table, "Estimated Rows": c.rows, "Kept Current By": "polling", "Last Poll": c.polledAt })),
|
|
105
|
+
"Sent To DuckDB": e.sql,
|
|
106
|
+
"Computed After DuckDB": e.computed,
|
|
107
|
+
"Mason Functions DuckDB Calls": e.functions,
|
|
108
|
+
...(o.ignored.length === 0 ? {} : { Ignored: o.ignored.map((n) => `${n} has no DuckDB meaning`) }),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
if (e.analyzed !== undefined && o.summary) {
|
|
112
|
+
const p = e.analyzed.planning, x = e.analyzed.execution;
|
|
113
|
+
top["Planning Time"] = Number(ms(p.analysisMs + p.rewriteMs + p.duckdbMs));
|
|
114
|
+
top["Execution Time"] = Number(ms(x.duckdbMs + x.masonMs));
|
|
115
|
+
top.Mason["Planning"] = { "Postgres Analysis": Number(ms(p.analysisMs)), "Mason Rewrite": Number(ms(p.rewriteMs)), "DuckDB Planning": Number(ms(p.duckdbMs)) };
|
|
116
|
+
top.Mason["Execution"] = { DuckDB: Number(ms(x.duckdbMs)), "Mason Conversion": Number(ms(x.masonMs)) };
|
|
117
|
+
}
|
|
118
|
+
return JSON.stringify([top], null, 2);
|
|
119
|
+
}
|