@mason-data/cli 0.6.2 → 0.8.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/README.md +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -38,7 +38,7 @@ import { runnable } from "../lib/runnable.js";
|
|
|
38
38
|
import { StatementError } from "../api/postgres/server.js";
|
|
39
39
|
import { EncodeError } from "../api/postgres/encode.js";
|
|
40
40
|
import { NumericError } from "../lib/pg-numeric.js";
|
|
41
|
-
import { CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
|
|
41
|
+
import { beats, CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
|
|
42
42
|
// a cycle (store.ts opens the executor): read at run time only
|
|
43
43
|
import { failureOf } from "./store.js";
|
|
44
44
|
/** A DuckDB type from its `toJson()` (the worker's), as `@duckdb/node-api`'s own classes. */
|
|
@@ -59,7 +59,7 @@ export function typeFromJson(j) {
|
|
|
59
59
|
}
|
|
60
60
|
const simple = SIMPLE_TYPES.get(id);
|
|
61
61
|
if (simple === undefined)
|
|
62
|
-
throw new Error(`a
|
|
62
|
+
throw new Error(`a type of the engine's that Mason cannot carry between threads: ${JSON.stringify(j)} (Mason bug: report it)`);
|
|
63
63
|
return simple(alias);
|
|
64
64
|
}
|
|
65
65
|
/** every type of no parameters, by its id: each class's `create(alias)` */
|
|
@@ -112,11 +112,11 @@ export function valueFromClone(v, t) {
|
|
|
112
112
|
}
|
|
113
113
|
export function wireError(e) {
|
|
114
114
|
if (e instanceof StatementError)
|
|
115
|
-
return { kind: "statement", fields: e.fields, sql: e.sql };
|
|
115
|
+
return { kind: "statement", fields: e.fields, sql: e.sql, gap: e.gap };
|
|
116
116
|
if (e instanceof EncodeError)
|
|
117
|
-
return { kind: "encode", message: e.message, code: e.code };
|
|
117
|
+
return { kind: "encode", message: e.message, code: e.code, gap: e.gap };
|
|
118
118
|
if (e instanceof NumericError)
|
|
119
|
-
return { kind: "numeric", message: e.message, sqlstate: e.sqlstate };
|
|
119
|
+
return { kind: "numeric", message: e.message, sqlstate: e.sqlstate, gap: e.gap };
|
|
120
120
|
return { kind: "error", message: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : "Error" };
|
|
121
121
|
}
|
|
122
122
|
/** the worker is gone: what every request in flight, and every later one, is answered. `stopped`: by the run's own stop
|
|
@@ -131,11 +131,11 @@ export const STOPPING = { code: "57P01", message: "terminating connection due to
|
|
|
131
131
|
detail: "Mason is stopping (mason stop, or a shutdown)", hint: "connect again once Mason runs again (mason status says whether it does)" };
|
|
132
132
|
const errorOf = (e) => {
|
|
133
133
|
if (e.kind === "statement")
|
|
134
|
-
return new StatementError(e.fields, e.sql);
|
|
134
|
+
return new StatementError(e.fields, e.sql, e.gap);
|
|
135
135
|
if (e.kind === "encode")
|
|
136
|
-
return new EncodeError(e.message, e.code);
|
|
136
|
+
return new EncodeError(e.message, e.code, e.gap);
|
|
137
137
|
if (e.kind === "numeric")
|
|
138
|
-
return new NumericError(e.message, e.sqlstate);
|
|
138
|
+
return new NumericError(e.message, e.sqlstate, e.gap);
|
|
139
139
|
const err = new Error(e.message);
|
|
140
140
|
err.name = e.name;
|
|
141
141
|
return err;
|
|
@@ -148,7 +148,7 @@ function plainValues(values) {
|
|
|
148
148
|
if (v === null || typeof v !== "object" || Array.isArray(v) || ArrayBuffer.isView(v) || v instanceof Date)
|
|
149
149
|
continue;
|
|
150
150
|
if (Object.getPrototypeOf(v) !== Object.prototype)
|
|
151
|
-
throw new Error(`a ${v.constructor.name} cannot be sent to
|
|
151
|
+
throw new Error(`a ${v.constructor.name} cannot be sent to the engine's thread: pass its text or a plain value (Mason bug: report it)`);
|
|
152
152
|
}
|
|
153
153
|
return [...values];
|
|
154
154
|
}
|
|
@@ -156,18 +156,36 @@ function plainValues(values) {
|
|
|
156
156
|
* EVERY CALL TO THE WORKER SETTLES (under load the worker deadlocked inside DuckDB, src/duck/store.ts
|
|
157
157
|
* `cleanUp`, and every request to it — the session's statements, the store's close, the test's after-hook — waited for
|
|
158
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
|
|
160
|
-
*
|
|
161
|
-
* later is told 57P02, and the run is told (`onCrash`), as for a crash.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
159
|
+
* - THE WORKER MUST BE HEARD: while a request is pending the executor pings it every `PING_MS`, and a worker of which
|
|
160
|
+
* nothing was heard (no answer, no log line, no pong, no beat) for `SILENT_MS` is declared stopped — every request in
|
|
161
|
+
* flight and later is told 57P02, and the run is told (`onCrash`), as for a crash. THE TIME IS THIS THREAD'S OWN, its
|
|
162
|
+
* ticks counted and never the clock's: a main thread blocked or a process frozen past the bound (a test's spawnSync, a
|
|
163
|
+
* SIGSTOP, a paused container) counted against a healthy worker, whose answer sat unread in the port when the tick
|
|
164
|
+
* fired (docs/DESIGN.md "The watch"). THE BEAT (src/duck/cancel.ts) is the worker's JavaScript running with no turn of
|
|
165
|
+
* its loop: a statement calling a Mason function holds that loop for a thousand calls at a time, 999 × 322 ms over
|
|
166
|
+
* 20 kB documents, and such a statement ran 1,151 s without a pong and answered. What is left is a thread that neither
|
|
167
|
+
* turned its loop nor ran a row of a Mason function for 60 s: blocked inside the engine, since nothing on it waits for
|
|
168
|
+
* the engine's tasks any more (src/duck/store.ts `cleanUp`), or spinning. A deadlock is for ever, so a longer bound
|
|
169
|
+
* only says it later.
|
|
165
170
|
* - THE CLOSE IS BOUNDED: `close` gives the worker's shutdown `CLOSE_GRACE_MS` (a calling statement stops at its next
|
|
166
171
|
* 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
|
|
172
|
+
* before SIGKILL and `mason stop`'s 15 s), then declares it stopped the same way and resolves; the thread is ended
|
|
173
|
+
* once its shutdown answers (below).
|
|
168
174
|
* What neither can do: end a thread blocked inside native code. Such a process does not exit by itself, nor on
|
|
169
175
|
* process.exit (measured); its supervisor's SIGKILL does. The copy is not opened again in this process
|
|
170
176
|
* (`abandoned`): a second instance of the file would lose writes.
|
|
177
|
+
*
|
|
178
|
+
* NOTHING ENDS THE WORKER'S JAVASCRIPT UNDER A PENDING CALL OF DuckDB'S (a test file under load aborted at its exit,
|
|
179
|
+
* `libc++abi: terminating due to uncaught exception of type Napi::Error`: its close outlasted the grace, and the
|
|
180
|
+
* executor TERMINATED the worker while DuckDB's threads were inside a Mason function call — the node API's callback met
|
|
181
|
+
* a dying environment and threw where nothing catches. Reproduced every time by a close with a 1 ms grace under a
|
|
182
|
+
* calling statement, tests/duck-executor.test.ts). So a worker given up on (the grace, the silence) is told nothing
|
|
183
|
+
* more and asked to SHUT DOWN (`shutDown`: posted once, its answer awaited outside `pending`), and is ended only once
|
|
184
|
+
* that shutdown has answered — DuckDB's statements stopped, its connections and instance closed — or it exits by
|
|
185
|
+
* itself; until then it stays REFERENCED, so the process cannot end with the worker mid-call either (a process
|
|
186
|
+
* exiting tears its workers down the same way). A drained answer is a request for the same reason (`answer.close`):
|
|
187
|
+
* DuckDB may call into the worker after the listener let go of the statement. A thread that never answers its
|
|
188
|
+
* shutdown is the deadlock above: its supervisor's SIGKILL.
|
|
171
189
|
*/
|
|
172
190
|
export const PING_MS = 5_000;
|
|
173
191
|
export const SILENT_MS = 60_000;
|
|
@@ -185,13 +203,20 @@ export class Executor {
|
|
|
185
203
|
/** the cancel table, shared with the worker (src/duck/cancel.ts), and its free slots */
|
|
186
204
|
cancels = cancelTable();
|
|
187
205
|
free = Array.from({ length: CANCEL_SLOTS }, (_, i) => CANCEL_SLOTS - 1 - i);
|
|
188
|
-
/**
|
|
189
|
-
|
|
206
|
+
/** pings sent since the worker last said anything or beat: the watch's count, of this thread's own ticks */
|
|
207
|
+
unanswered = 0;
|
|
208
|
+
/** the worker's beat at the watch's last look */
|
|
209
|
+
beat = 0;
|
|
190
210
|
watch;
|
|
191
211
|
silentMs;
|
|
192
212
|
/** why the worker was given up on without exiting (`close`'s bound, the silence), once it was: its instance may
|
|
193
213
|
* still hold the file, which this process must never open again */
|
|
194
214
|
abandoned = null;
|
|
215
|
+
/** the shutdown, once posted (`shutDown`): settled when the worker answered it, or exited */
|
|
216
|
+
shutdown = null;
|
|
217
|
+
shutdownId = 0;
|
|
218
|
+
shutdownAnswered = () => undefined;
|
|
219
|
+
hasExited = false;
|
|
195
220
|
constructor(o) {
|
|
196
221
|
this.silentMs = o.silentMs ?? SILENT_MS;
|
|
197
222
|
this.log = o.log;
|
|
@@ -200,13 +225,19 @@ export class Executor {
|
|
|
200
225
|
// a request is in flight (`call`)
|
|
201
226
|
this.worker.unref();
|
|
202
227
|
this.worker.on("message", (m) => {
|
|
203
|
-
this.
|
|
228
|
+
this.unanswered = 0;
|
|
204
229
|
if ("pong" in m)
|
|
205
230
|
return;
|
|
206
231
|
if ("log" in m) {
|
|
207
232
|
this.log(m.log);
|
|
208
233
|
return;
|
|
209
234
|
}
|
|
235
|
+
if (m.id === this.shutdownId) {
|
|
236
|
+
if (!m.ok)
|
|
237
|
+
this.log(`closing the engine failed: ${errorOf(m.error).message}`);
|
|
238
|
+
this.shutdownAnswered();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
210
241
|
const p = this.pending.get(m.id);
|
|
211
242
|
if (p === undefined)
|
|
212
243
|
return;
|
|
@@ -221,32 +252,59 @@ export class Executor {
|
|
|
221
252
|
why = e.code === "ERR_WORKER_OUT_OF_MEMORY" ? `its thread ran out of JavaScript heap (${e.message})` : `it failed: ${e.message}`;
|
|
222
253
|
});
|
|
223
254
|
this.exited = new Promise((resolve) => this.worker.once("exit", (code) => {
|
|
255
|
+
this.hasExited = true;
|
|
224
256
|
this.die(this.closing && why === null ? "it was closed" : why ?? `it exited with code ${code}`);
|
|
257
|
+
this.shutdownAnswered();
|
|
225
258
|
resolve();
|
|
226
259
|
}));
|
|
227
|
-
// the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer
|
|
260
|
+
// the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer — and never
|
|
261
|
+
// sooner than the bound after its first ping, whatever the moment the request came: the count is checked, then raised
|
|
262
|
+
const tick = Math.min(PING_MS, this.silentMs / 4);
|
|
263
|
+
const bound = Math.ceil(this.silentMs / tick);
|
|
228
264
|
this.watch = setInterval(() => {
|
|
229
|
-
|
|
230
|
-
|
|
265
|
+
const beat = beats(this.cancels);
|
|
266
|
+
if (this.gone !== null || this.pending.size === 0 || beat !== this.beat) {
|
|
267
|
+
this.beat = beat;
|
|
268
|
+
this.unanswered = 0;
|
|
231
269
|
return;
|
|
232
270
|
}
|
|
233
|
-
if (
|
|
234
|
-
this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s with ${this.pending.size} request(s) waiting (stuck inside
|
|
271
|
+
if (this.unanswered >= bound) {
|
|
272
|
+
this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s and ran no Mason function, with ${this.pending.size} request(s) waiting (stuck inside the engine: Mason bug, report it with mason logs)`);
|
|
235
273
|
return;
|
|
236
274
|
}
|
|
275
|
+
this.unanswered++;
|
|
237
276
|
this.worker.postMessage({ op: "ping" });
|
|
238
|
-
},
|
|
277
|
+
}, tick);
|
|
239
278
|
this.watch.unref();
|
|
240
279
|
}
|
|
241
|
-
/** the worker given up on without its exit: every request told so (57P02), the run told, and
|
|
242
|
-
*
|
|
280
|
+
/** the worker given up on without its exit: every request told so (57P02), the run told, and its shutdown asked —
|
|
281
|
+
* NEVER a terminate, which under a pending call of DuckDB's aborts the process (NOTHING ENDS THE WORKER'S
|
|
282
|
+
* JAVASCRIPT above): it is ended once its shutdown answers, and holds the process until then */
|
|
243
283
|
abandon(reason) {
|
|
244
284
|
if (this.gone !== null)
|
|
245
285
|
return;
|
|
246
286
|
this.abandoned = reason;
|
|
247
287
|
this.die(reason, false);
|
|
248
|
-
this.
|
|
249
|
-
|
|
288
|
+
void this.shutDown();
|
|
289
|
+
}
|
|
290
|
+
/** Asks the worker to stop (executor-worker.ts `shutdown`: every statement stopped, every connection and the instance
|
|
291
|
+
* closed once nothing runs on them), once; settles when it answered, or exited. Only then is it terminated: nothing
|
|
292
|
+
* of DuckDB's calls into it any more. The worker stays referenced meanwhile, so the process waits for it. */
|
|
293
|
+
shutDown() {
|
|
294
|
+
if (this.shutdown !== null)
|
|
295
|
+
return this.shutdown;
|
|
296
|
+
this.shutdown = new Promise((resolve) => { this.shutdownAnswered = resolve; });
|
|
297
|
+
if (this.hasExited)
|
|
298
|
+
this.shutdownAnswered();
|
|
299
|
+
else {
|
|
300
|
+
this.shutdownId = this.nextId++;
|
|
301
|
+
this.worker.ref();
|
|
302
|
+
this.worker.postMessage({ op: "shutdown", id: this.shutdownId });
|
|
303
|
+
}
|
|
304
|
+
void this.shutdown.then(async () => { if (!this.hasExited)
|
|
305
|
+
await this.worker.terminate(); })
|
|
306
|
+
.catch((e) => this.log(`ending the engine's thread failed: ${e.message}`));
|
|
307
|
+
return this.shutdown;
|
|
250
308
|
}
|
|
251
309
|
static async start(o) {
|
|
252
310
|
const e = new Executor(o);
|
|
@@ -272,7 +330,7 @@ export class Executor {
|
|
|
272
330
|
p.reject(err);
|
|
273
331
|
}
|
|
274
332
|
if (!stopped) {
|
|
275
|
-
this.log(`the
|
|
333
|
+
this.log(`the engine stopped: ${reason}`);
|
|
276
334
|
if (!this.closing)
|
|
277
335
|
for (const f of this.crashed)
|
|
278
336
|
f(reason);
|
|
@@ -280,7 +338,8 @@ export class Executor {
|
|
|
280
338
|
}
|
|
281
339
|
settle(id) {
|
|
282
340
|
this.pending.delete(id);
|
|
283
|
-
|
|
341
|
+
// a shutdown in flight holds the process until the worker ends (`shutDown`)
|
|
342
|
+
if (this.pending.size === 0 && this.shutdown === null)
|
|
284
343
|
this.worker.unref();
|
|
285
344
|
}
|
|
286
345
|
/** a request, answered by the worker */
|
|
@@ -295,11 +354,17 @@ export class Executor {
|
|
|
295
354
|
this.worker.postMessage({ ...m, id }, transfer);
|
|
296
355
|
});
|
|
297
356
|
}
|
|
298
|
-
/** a message needing no answer (an interrupt, a close
|
|
357
|
+
/** a message needing no answer (an interrupt, a connection's close); nothing once the worker is gone */
|
|
299
358
|
post(m) {
|
|
300
359
|
if (this.gone === null)
|
|
301
360
|
this.worker.postMessage(m);
|
|
302
361
|
}
|
|
362
|
+
/** a request whose answer nobody awaits (an answer's close: the worker answers once DuckDB handed it back), kept in
|
|
363
|
+
* `pending` so the process does not end while the worker still drains it; a failure is logged, a stop is not one */
|
|
364
|
+
background(m) {
|
|
365
|
+
this.call(m).catch((e) => { if (!(e instanceof ExecutorGone))
|
|
366
|
+
this.log(`${String(m.op)} failed: ${e.message}`); });
|
|
367
|
+
}
|
|
303
368
|
/** a connection of the instance */
|
|
304
369
|
async connect() { return new RemoteConnection(this, await this.call({ op: "connect" })); }
|
|
305
370
|
writer() { return new RemoteConnection(this, 0); }
|
|
@@ -308,11 +373,13 @@ export class Executor {
|
|
|
308
373
|
/** how many vectors each Mason function was called with so far (src/duck/functions.ts FUNCTION_CALLS: the worker's
|
|
309
374
|
* count, the functions being its) — what a test or a measurement reads to know whether a statement reached one */
|
|
310
375
|
async functionCalls() { return new Map(Object.entries(await this.call({ op: "calls" }))); }
|
|
376
|
+
/** how many rows each Mason function was handed so far, across its calls (src/duck/functions.ts FUNCTION_ROWS) */
|
|
377
|
+
async functionRows() { return new Map(Object.entries(await this.call({ op: "function rows" }))); }
|
|
311
378
|
/** a wire session's connection: a slot of the cancel table, its connection on the worker */
|
|
312
379
|
async runner() {
|
|
313
380
|
const slot = this.free.pop();
|
|
314
381
|
if (slot === undefined)
|
|
315
|
-
throw new StatementError({ code: "53300", message: `too many sessions are reading the
|
|
382
|
+
throw new StatementError({ code: "53300", message: `too many sessions are reading the synced data at once (${CANCEL_SLOTS})`, hint: "close idle connections (a pool's size), then connect again" });
|
|
316
383
|
resetSlot(this.cancels, slot);
|
|
317
384
|
try {
|
|
318
385
|
return new RemoteRunner(this, await this.call({ op: "runner.open", slot }), slot);
|
|
@@ -335,25 +402,11 @@ export class Executor {
|
|
|
335
402
|
this.closing = true;
|
|
336
403
|
let timer;
|
|
337
404
|
const late = new Promise((resolve) => { timer = setTimeout(() => resolve("late"), graceMs); });
|
|
338
|
-
|
|
339
|
-
|
|
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"; });
|
|
405
|
+
// the shutdown, then the terminate once it answered (`shutDown`), then the exit — or the worker's own exit
|
|
406
|
+
const closed = (async () => { await this.shutDown(); await this.exited; return "closed"; })();
|
|
354
407
|
try {
|
|
355
408
|
if (await Promise.race([closed, late]) === "late") {
|
|
356
|
-
this.log(`the
|
|
409
|
+
this.log(`the engine's thread did not close within ${graceMs / 1000} s: given up on (its sessions are told 57P02); it ends once its shutdown finishes, and the process waits for it (a thread stuck inside the engine needs a SIGKILL)`);
|
|
357
410
|
this.abandon(`it did not close within ${graceMs / 1000} s`);
|
|
358
411
|
}
|
|
359
412
|
}
|
|
@@ -397,7 +450,7 @@ const reasonOf = (signal) => !signal.aborted ? null : signal.reason instanceof S
|
|
|
397
450
|
function goneError(e, sql) {
|
|
398
451
|
if (e.stopped)
|
|
399
452
|
return new StatementError(STOPPING, sql);
|
|
400
|
-
return new StatementError({ code: "57P02", message: "terminating connection because the
|
|
453
|
+
return new StatementError({ code: "57P02", message: "terminating connection because the engine stopped", detail: e.message,
|
|
401
454
|
hint: "Mason starts again by itself: reconnect in a few seconds and repeat the statement (mason status says when it is back)" }, sql);
|
|
402
455
|
}
|
|
403
456
|
/**
|
|
@@ -482,10 +535,11 @@ class RemoteRunner {
|
|
|
482
535
|
if (ended)
|
|
483
536
|
return;
|
|
484
537
|
end();
|
|
485
|
-
// its calls see the cancel now; the worker cancels the rest (an interrupt) and releases it once DuckDB hands it
|
|
538
|
+
// its calls see the cancel now; the worker cancels the rest (an interrupt) and releases it once DuckDB hands it
|
|
539
|
+
// back — answered then, so the process never ends while DuckDB still calls into the worker for it
|
|
486
540
|
if (calls)
|
|
487
541
|
cancelStatement(this.ex.cancels, this.slot, seq);
|
|
488
|
-
this.ex.
|
|
542
|
+
this.ex.background({ op: "answer.close", runner: this.id, seq });
|
|
489
543
|
},
|
|
490
544
|
};
|
|
491
545
|
}
|
package/dist/src/duck/explain.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { agoText, syncText } from "../lib/table-sync.js";
|
|
1
2
|
/** a node of DuckDB's JSON, either form: `name` (EXPLAIN) or `type` with `intermediate_rows` and `timing` (the profile).
|
|
2
3
|
* The profile's RESULT_COLLECTOR and EXECUTE are the result's plumbing, not the plan's: their child stands for them */
|
|
3
4
|
export function planNodes(json) {
|
|
@@ -19,52 +20,179 @@ export function planNodes(json) {
|
|
|
19
20
|
return [node];
|
|
20
21
|
});
|
|
21
22
|
}
|
|
23
|
+
/** a profile DuckDB's profiler wrote (`enable_profiling = 'json'`): the statement it timed, as it names it (`query.sql`,
|
|
24
|
+
* the text it was given), and its plan. The name is what holds a profile to its statement (src/duck/store.ts
|
|
25
|
+
* `explained`): the file is the last profiled statement's on the connection, whichever that was */
|
|
26
|
+
export function profiled(doc) {
|
|
27
|
+
const d = doc;
|
|
28
|
+
return { sql: typeof d.query?.sql === "string" ? d.query.sql : null, plan: planNodes(d.operator ?? []) };
|
|
29
|
+
}
|
|
30
|
+
/** what EXPLAIN ANALYZE says of a statement DuckDB's profiler timed no operator of */
|
|
31
|
+
export const UNPROFILED = "the engine's profiler recorded no operator of this statement: the engine answered it while planning it (as it answers a count(*) of a whole table from the table's row count), so the plan is its EXPLAIN, with the rows it answered";
|
|
22
32
|
/** DuckDB's operator as DuckDB's own plan prints it (`HASH_GROUP_BY` is `Hash Group By`), a scan `Table Scan` as its
|
|
23
33
|
* profiler names it: DuckDB's EXPLAIN says SEQ_SCAN for the same operator, and Postgres's `Seq Scan` says otherwise */
|
|
24
34
|
export const operatorName = (type) => type.toLowerCase().split("_").map((w) => w === "n" ? "N" : w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
25
|
-
/**
|
|
35
|
+
/** a name as Postgres writes one in a statement: bare when it is lower case letters, digits and `_`, else quoted */
|
|
36
|
+
const quotedName = (s) => /^[a-z_][a-z0-9_$]*$/.test(s) ? s : `"${s.replaceAll('"', '""')}"`;
|
|
37
|
+
/**
|
|
38
|
+
* A TABLE AS POSTGRES WRITES IT, each part quoted where it must be (`public.orders`, `public."Clients"`,
|
|
39
|
+
* `public."x; y"`): the ONE way EXPLAIN names a copy (its line, FORMAT JSON's `Mason.Tables[].Table`), which a statement
|
|
40
|
+
* can read back as it is (`mason mcp`'s tables and describe name tables by it too).
|
|
41
|
+
*/
|
|
42
|
+
export const qualifiedName = (t) => `${quotedName(t.schema)}.${quotedName(t.name)}`;
|
|
43
|
+
/** DuckDB's `<file>.<schema>.<table>`, each part quoted where DuckDB quotes it (`memory.main."x; drop.t"`), as its last
|
|
44
|
+
* two parts: a `.` inside a quoted part is the name's */
|
|
45
|
+
export function duckTableName(text) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let at = 0;
|
|
48
|
+
while (at <= text.length) {
|
|
49
|
+
if (text[at] === '"') {
|
|
50
|
+
let out = "", end = at + 1;
|
|
51
|
+
for (; end < text.length; end++) {
|
|
52
|
+
if (text[end] === '"') {
|
|
53
|
+
if (text[end + 1] === '"') {
|
|
54
|
+
out += '"';
|
|
55
|
+
end++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
out += text[end];
|
|
61
|
+
}
|
|
62
|
+
parts.push(out);
|
|
63
|
+
at = end + 2; // past the closing quote and the dot
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const dot = text.indexOf(".", at);
|
|
67
|
+
parts.push(text.slice(at, dot < 0 ? undefined : dot));
|
|
68
|
+
at = dot < 0 ? text.length + 1 : dot + 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return parts.length >= 2 ? { schema: parts[parts.length - 2], name: parts[parts.length - 1] } : null;
|
|
72
|
+
}
|
|
73
|
+
/** the copy a scan reads (DuckDB's `<file>.<schema>.<table>`) */
|
|
26
74
|
export const scannedTable = (n) => {
|
|
27
75
|
const t = n.extra.Table;
|
|
28
|
-
return typeof t === "string" ? t
|
|
76
|
+
return typeof t === "string" ? duckTableName(t) : null;
|
|
29
77
|
};
|
|
30
78
|
export function scannedTables(nodes) {
|
|
31
79
|
const out = [];
|
|
32
|
-
const walk = (n) => {
|
|
33
|
-
|
|
80
|
+
const walk = (n) => {
|
|
81
|
+
const t = scannedTable(n);
|
|
82
|
+
if (t !== null && !out.some((o) => o.schema === t.schema && o.name === t.name))
|
|
83
|
+
out.push(t);
|
|
84
|
+
n.children.forEach(walk);
|
|
85
|
+
};
|
|
34
86
|
nodes.forEach(walk);
|
|
35
87
|
return out;
|
|
36
88
|
}
|
|
89
|
+
/** the sync's states as EXPLAIN takes them across to the executor's thread (src/api/postgres/server.ts `duckExplain`):
|
|
90
|
+
* its facts alone, read at the EXPLAIN */
|
|
91
|
+
export const copyFacts = (states) => states.map((s) => ({ source: s.source, phase: s.phase, error: s.error, ...(s.rowsSoFar !== undefined ? { rowsSoFar: s.rowsSoFar } : {}), ...(s.kept !== undefined ? { kept: s.kept } : {}) }));
|
|
92
|
+
/** an ISO instant as EXPLAIN says one: `2026-10-27 19:58:39 UTC` */
|
|
93
|
+
const utc = (iso) => `${iso.slice(0, 19).replace("T", " ")} UTC`;
|
|
94
|
+
const UNKNOWN = "how it is kept current is unknown: the sync has no record of it";
|
|
95
|
+
/**
|
|
96
|
+
* HOW A COPY IS KEPT CURRENT, in EXPLAIN's words, from the sync's one derivation (src/duck/sync.ts `KeptCurrent`, which
|
|
97
|
+
* `mason status` renders too): a copy not yet copied or whose copy failed says so first; then its sync and its age —
|
|
98
|
+
* `off: last synced 3 days ago (…)` with the command that syncs it, `polled every 30s, last 4 s ago (…)` — or served as
|
|
99
|
+
* copied and skipped by the passes (to be copied again, caught up first), copied again whole, or, in replication mode,
|
|
100
|
+
* its part in the stream, how far the stream has applied and why it is not running. A copy's age is never left out:
|
|
101
|
+
* a BI tool or an agent reading a stale count cannot tell it is one. "kept current" is said only of a copy that is
|
|
102
|
+
* (`keptCurrentBy`). `rows` false says no count of its rows: a filtered table's are every row's, not the role's.
|
|
103
|
+
*/
|
|
104
|
+
export function keptText(f, now = Date.now(), rows = true) {
|
|
105
|
+
if (f === null)
|
|
106
|
+
return UNKNOWN;
|
|
107
|
+
if (f.phase === "waiting")
|
|
108
|
+
return "waiting for its first sync to start";
|
|
109
|
+
if (f.phase === "copying")
|
|
110
|
+
return `being read whole${rows ? ` (${count(f.rowsSoFar ?? 0)} rows so far)` : ""}: kept current once synced`;
|
|
111
|
+
if (f.phase === "failed")
|
|
112
|
+
return `its first sync failed: ${(f.error ?? "mason logs says why").split("\n")[0]}`;
|
|
113
|
+
const k = f.kept;
|
|
114
|
+
if (k === undefined)
|
|
115
|
+
return UNKNOWN;
|
|
116
|
+
if (k.by === "poll") {
|
|
117
|
+
const synced = k.syncedAt === null ? "never synced" : `last synced ${agoText(k.syncedAt, now)} (${utc(k.syncedAt)})`;
|
|
118
|
+
if (k.pending !== undefined)
|
|
119
|
+
return `NOT kept current (${synced}): ${k.pending}`;
|
|
120
|
+
if (k.sync.mode === "off")
|
|
121
|
+
return `off: ${synced}; mason sync ${f.source} syncs it now`;
|
|
122
|
+
return `polled every ${k.sync.every}, ${k.syncedAt === null ? "not polled yet" : `last ${agoText(k.syncedAt, now)} (${utc(k.syncedAt)})`}`;
|
|
123
|
+
}
|
|
124
|
+
if (k.by === "recopy")
|
|
125
|
+
return `kept current by reading it again whole, ${k.copiedAt === null ? "no sync recorded yet" : `last synced ${utc(k.copiedAt)}`}`;
|
|
126
|
+
const stream = `logical replication (slot ${k.slot})`;
|
|
127
|
+
// the copy's age as status says it (replicated, at <lsn>, <age>), then the newest change it applied
|
|
128
|
+
const synced = k.syncedAt === null ? "not synced yet" : `last synced ${agoText(k.syncedAt, now)} (${utc(k.syncedAt)})`;
|
|
129
|
+
const where = `replicated, at ${k.position}, ${synced}, ${k.lastCommit === null ? "no change applied this run" : `last change committed ${utc(k.lastCommit)}`}`;
|
|
130
|
+
if (k.state === "streamed")
|
|
131
|
+
return k.notRunning === null ? `kept current by ${stream}, ${where}`
|
|
132
|
+
: `streamed by ${stream}, ${where}; NOT kept current now, the stream is not running: ${k.notRunning}`;
|
|
133
|
+
return k.state === "left-out" ? `NOT kept current: left out of ${stream}: ${k.why ?? ""}`
|
|
134
|
+
: `${stream}, not streamed yet: ${k.why ?? ""}`;
|
|
135
|
+
}
|
|
136
|
+
/** what keeps a copy current NOW, as FORMAT JSON's `Kept Current By` says it: null for a copy nothing does (not copied
|
|
137
|
+
* yet, skipped by the passes, its sync off, not streamed, the stream down), so a reader keying on it is
|
|
138
|
+
* never told of a mode that is not at work on it */
|
|
139
|
+
export function keptCurrentBy(f) {
|
|
140
|
+
const k = f?.phase === "copied" ? f.kept : undefined;
|
|
141
|
+
if (k === undefined)
|
|
142
|
+
return null;
|
|
143
|
+
if (k.by === "poll")
|
|
144
|
+
return k.pending === undefined && k.sync.mode === "poll" ? "polling" : null;
|
|
145
|
+
if (k.by === "recopy")
|
|
146
|
+
return "copying again";
|
|
147
|
+
return k.state === "streamed" && k.notRunning === null ? "logical replication" : null;
|
|
148
|
+
}
|
|
149
|
+
/** how a copy is kept current, as FORMAT JSON's keys say it (`keptText`'s facts) */
|
|
150
|
+
function keptJson(f, rows) {
|
|
151
|
+
const k = f?.phase === "copied" ? f.kept : undefined;
|
|
152
|
+
return {
|
|
153
|
+
"Kept Current By": keptCurrentBy(f), "Kept Current": keptText(f, undefined, rows),
|
|
154
|
+
...(k?.by === "poll" ? { Sync: syncText(k.sync), "Last Synced": k.syncedAt, "Next Poll": k.nextAt } : {}),
|
|
155
|
+
...(k?.by === "replication" ? { Sync: "replication", Slot: k.slot, "Stream State": k.state, "Applied Through": k.position, "Last Synced": k.syncedAt, "Last Change Committed": k.lastCommit, "Stream Not Running": k.notRunning } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** what EXPLAIN says of a statement that reads a filtered table, in the place of the engine's plan */
|
|
159
|
+
const FILTERED = "the engine's plan is not shown for a statement that reads a table the role filters: it would say what the engine knows of every row; a user with no row filter reads the full plan";
|
|
37
160
|
const ms = (x) => x.toFixed(3);
|
|
38
161
|
const count = (n) => n.toLocaleString("en-US");
|
|
39
162
|
const detail = (v) => Array.isArray(v) ? v.join(", ") : v;
|
|
40
163
|
/** the details a plan line carries without VERBOSE: everything but the projections and filters, which VERBOSE adds */
|
|
41
164
|
const VERBOSE_ONLY = new Set(["Projections", "Filters", "Table", "Type"]);
|
|
165
|
+
/** a filtered table says no row count: the estimate is every row's, which the role does not read */
|
|
42
166
|
function copyLine(c) {
|
|
43
|
-
const rows = c.rows === null ? "rows unknown" : `about ${count(c.rows)} rows`;
|
|
44
|
-
return `${c.table} (${rows}
|
|
167
|
+
const rows = c.filter !== undefined ? "" : c.rows === null ? "rows unknown; " : `about ${count(c.rows)} rows; `;
|
|
168
|
+
return `${c.table} (${rows}${keptText(c.facts, undefined, c.filter === undefined)}${c.filter !== undefined ? `; ${c.filter}` : ""})`;
|
|
45
169
|
}
|
|
46
170
|
/** EXPLAIN as Postgres's text format: one line a row */
|
|
47
171
|
export function explainText(e, o) {
|
|
48
172
|
const lines = [];
|
|
49
|
-
lines.push(e.copies.length === 0 ? "Mason: answered
|
|
50
|
-
: e.copies.length === 1 ? `Mason: answered
|
|
173
|
+
lines.push(e.copies.length === 0 ? "Mason: answered reading no synced table"
|
|
174
|
+
: e.copies.length === 1 ? `Mason: answered from the synced table ${copyLine(e.copies[0])}` : "Mason: answered from the synced tables");
|
|
51
175
|
if (e.copies.length > 1)
|
|
52
176
|
for (const c of e.copies)
|
|
53
177
|
lines.push(` ${copyLine(c)}`);
|
|
54
|
-
lines.push(` Sent to
|
|
55
|
-
lines.push(` Computed by Mason after
|
|
178
|
+
lines.push(` Sent to the engine: ${e.sql}`);
|
|
179
|
+
lines.push(` Computed by Mason after the engine: ${e.computed.length === 0 ? "nothing" : e.computed.join("; ")}`);
|
|
56
180
|
if (e.functions.length > 0)
|
|
57
|
-
lines.push(` Mason's functions
|
|
181
|
+
lines.push(` Mason's functions the engine calls: ${e.functions.join(", ")}`);
|
|
58
182
|
for (const name of o.ignored)
|
|
59
|
-
lines.push(` Note: ${name} has no
|
|
60
|
-
if (e.
|
|
61
|
-
lines.push(
|
|
183
|
+
lines.push(` Note: ${name} has no meaning to the engine: ignored`);
|
|
184
|
+
if (e.filtered === true)
|
|
185
|
+
lines.push(` Note: ${FILTERED}`);
|
|
186
|
+
if (e.analyzed?.profiled === false)
|
|
187
|
+
lines.push(` Note: ${UNPROFILED}`);
|
|
188
|
+
else if (e.analyzed !== undefined && o.timing)
|
|
189
|
+
lines.push(" Note: an operator's thread time is the engine's, summed over the threads that ran it and the operators under it: a parallel one's exceeds the Execution Time");
|
|
62
190
|
const walk = (n, depth) => {
|
|
63
191
|
const lead = depth === 0 ? "" : `${" ".repeat(6 * depth - 4)}-> `;
|
|
64
192
|
const on = scannedTable(n);
|
|
65
193
|
const est = o.costs && n.estimate !== null ? ` (rows=${n.estimate})` : "";
|
|
66
194
|
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}`);
|
|
195
|
+
lines.push(`${lead}${operatorName(n.type)}${on === null ? "" : ` on ${qualifiedName(on)}`}${est}${actual}`);
|
|
68
196
|
const pad = " ".repeat(depth === 0 ? 2 : 6 * depth + 2);
|
|
69
197
|
for (const [k, v] of Object.entries(n.extra))
|
|
70
198
|
if (o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k))
|
|
@@ -75,8 +203,8 @@ export function explainText(e, o) {
|
|
|
75
203
|
e.plan.forEach((n) => walk(n, 0));
|
|
76
204
|
if (e.analyzed !== undefined && o.summary) {
|
|
77
205
|
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)},
|
|
79
|
-
lines.push(`Execution Time: ${ms(x.duckdbMs + x.masonMs)} ms (
|
|
206
|
+
lines.push(`Planning Time: ${ms(p.analysisMs + p.rewriteMs + p.duckdbMs)} ms (Postgres's analysis ${ms(p.analysisMs)}, Mason's rewrite ${ms(p.rewriteMs)}, the engine's planning ${ms(p.duckdbMs)})`);
|
|
207
|
+
lines.push(`Execution Time: ${ms(x.duckdbMs + x.masonMs)} ms (the engine ${ms(x.duckdbMs)}, Mason's conversion and exact numerics ${ms(x.masonMs)})`);
|
|
80
208
|
}
|
|
81
209
|
return lines;
|
|
82
210
|
}
|
|
@@ -88,32 +216,33 @@ export function explainJson(e, o) {
|
|
|
88
216
|
const on = scannedTable(n);
|
|
89
217
|
return {
|
|
90
218
|
"Node Type": operatorName(n.type),
|
|
91
|
-
...(on === null ? {} : { "Relation Name": on.
|
|
219
|
+
...(on === null ? {} : { "Relation Name": on.name, Schema: on.schema }),
|
|
92
220
|
...(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 ? { "
|
|
94
|
-
...Object.fromEntries(Object.entries(n.extra).filter(([k]) => o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k)).map(([k, v]) => [`
|
|
221
|
+
...(n.actualRows === undefined ? {} : { "Actual Rows": n.actualRows, "Actual Loops": 1, ...(o.timing && n.threadMs !== undefined ? { "Engine Thread Time": Number(ms(n.threadMs)) } : {}) }),
|
|
222
|
+
...Object.fromEntries(Object.entries(n.extra).filter(([k]) => o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k)).map(([k, v]) => [`Engine ${k}`, v])),
|
|
95
223
|
...(n.children.length === 0 ? {} : { Plans: n.children.map(node) }),
|
|
96
224
|
};
|
|
97
225
|
};
|
|
98
226
|
// Postgres's plan has one root; DuckDB's has one too (a statement's), kept as a list should it ever have more
|
|
99
227
|
const roots = e.plan.map(node);
|
|
100
228
|
const top = {
|
|
101
|
-
Plan: roots.length === 1 ? roots[0] : { "Node Type": "
|
|
229
|
+
Plan: e.filtered === true ? { "Node Type": "Plan Not Shown" } : roots.length === 1 ? roots[0] : { "Node Type": "Engine Plans", Plans: roots },
|
|
102
230
|
Mason: {
|
|
103
|
-
"Answered By": "
|
|
104
|
-
|
|
105
|
-
"Sent To
|
|
106
|
-
"Computed After
|
|
107
|
-
"Mason Functions
|
|
108
|
-
...(o.ignored.length === 0 ? {} : { Ignored: o.ignored.map((n) => `${n} has no
|
|
231
|
+
"Answered By": "the engine",
|
|
232
|
+
Tables: e.copies.map((c) => ({ Table: c.table, ...(c.filter === undefined ? { "Estimated Rows": c.rows } : {}), ...keptJson(c.facts, c.filter === undefined), ...(c.filter !== undefined ? { "Row Filter": c.filter } : {}) })),
|
|
233
|
+
"Sent To Engine": e.sql,
|
|
234
|
+
"Computed After Engine": e.computed,
|
|
235
|
+
"Mason Functions Engine Calls": e.functions,
|
|
236
|
+
...(o.ignored.length === 0 ? {} : { Ignored: o.ignored.map((n) => `${n} has no meaning to the engine`) }),
|
|
237
|
+
...(e.filtered === true ? { Note: FILTERED } : e.analyzed?.profiled === false ? { Note: UNPROFILED } : {}),
|
|
109
238
|
},
|
|
110
239
|
};
|
|
111
240
|
if (e.analyzed !== undefined && o.summary) {
|
|
112
241
|
const p = e.analyzed.planning, x = e.analyzed.execution;
|
|
113
242
|
top["Planning Time"] = Number(ms(p.analysisMs + p.rewriteMs + p.duckdbMs));
|
|
114
243
|
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)), "
|
|
116
|
-
top.Mason["Execution"] = {
|
|
244
|
+
top.Mason["Planning"] = { "Postgres Analysis": Number(ms(p.analysisMs)), "Mason Rewrite": Number(ms(p.rewriteMs)), "Engine Planning": Number(ms(p.duckdbMs)) };
|
|
245
|
+
top.Mason["Execution"] = { Engine: Number(ms(x.duckdbMs)), "Mason Conversion": Number(ms(x.masonMs)) };
|
|
117
246
|
}
|
|
118
247
|
return JSON.stringify([top], null, 2);
|
|
119
248
|
}
|