@mason-data/cli 0.6.2 → 0.7.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 +71 -12
- package/dist/src/api/postgres/catalog.js +36 -1
- package/dist/src/api/postgres/server.js +133 -32
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cli/background.js +29 -5
- package/dist/src/cli/command.js +4 -3
- package/dist/src/cli/commands.js +259 -127
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +113 -20
- package/dist/src/cli/doctor.js +6 -3
- package/dist/src/cli/guides.js +32 -8
- package/dist/src/cli/init.js +58 -18
- package/dist/src/cli/machine.js +8 -3
- package/dist/src/cli/main.js +2 -2
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/preflight.js +16 -7
- package/dist/src/cli/project.js +5 -3
- package/dist/src/cli/registry.js +8 -5
- package/dist/src/cli/run-server.js +29 -106
- package/dist/src/cli/serve.js +6 -1
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +6 -4
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +7 -2
- package/dist/src/duck/executor.js +67 -26
- package/dist/src/duck/explain.js +128 -9
- package/dist/src/duck/functions.js +60 -5
- package/dist/src/duck/gaps.js +9 -4
- 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 +206 -39
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +21 -8
- package/dist/src/duck/rewrite.js +1168 -68
- package/dist/src/duck/store.js +132 -56
- package/dist/src/duck/sync.js +90 -25
- package/dist/src/duck/verified.js +6 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +11 -0
- package/dist/src/web/app.js +167 -12
- package/dist/src/web/index.html +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/** pg_proc's count(any) */
|
|
2
|
+
const COUNT_ANY = 2147;
|
|
3
|
+
const RTE_RELATION = 0;
|
|
4
|
+
/** JoinType: INNER, LEFT, FULL, RIGHT (nodes.h); the planner's SEMI / ANTI never reach a parse tree */
|
|
5
|
+
const JOINS = new Set([0, 1, 2, 3]);
|
|
6
|
+
export const columnKey = (relid, attno) => `${relid}.${attno}`;
|
|
7
|
+
const isNode = (v) => v !== null && v !== undefined && typeof v === "object" && !Array.isArray(v);
|
|
8
|
+
const num = (v) => typeof v === "string" && /^-?\d+$/.test(v) ? Number(v) : null;
|
|
9
|
+
const list = (v) => Array.isArray(v) ? v : [];
|
|
10
|
+
/** whether an AGGREF is a count(DISTINCT …) of one argument: the only aggregate this rewrite touches */
|
|
11
|
+
const isDistinctCount = (a) => a._node === "AGGREF" && num(a.aggfnoid) === COUNT_ANY && list(a.aggdistinct).length === 1;
|
|
12
|
+
/**
|
|
13
|
+
* The tables a statement's count(DISTINCT …) may be decided over: every base table of its queries, by the catalog's
|
|
14
|
+
* OID, when it holds such a count at all (none: the plan asks the catalog nothing). Read before the decision, so the
|
|
15
|
+
* catalog is asked once per statement planned.
|
|
16
|
+
*/
|
|
17
|
+
export function distinctCountTables(tree) {
|
|
18
|
+
let counts = false;
|
|
19
|
+
const relids = new Set();
|
|
20
|
+
const visit = (v) => {
|
|
21
|
+
if (Array.isArray(v)) {
|
|
22
|
+
v.forEach(visit);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!isNode(v))
|
|
26
|
+
return;
|
|
27
|
+
if (isDistinctCount(v))
|
|
28
|
+
counts = true;
|
|
29
|
+
if (v._node === "RANGETBLENTRY" && num(v.rtekind) === RTE_RELATION) {
|
|
30
|
+
const r = num(v.relid);
|
|
31
|
+
if (r !== null && r > 0)
|
|
32
|
+
relids.add(r);
|
|
33
|
+
}
|
|
34
|
+
for (const [k, x] of Object.entries(v))
|
|
35
|
+
if (k !== "_node")
|
|
36
|
+
visit(x);
|
|
37
|
+
};
|
|
38
|
+
visit(tree);
|
|
39
|
+
return counts ? [...relids].sort((a, b) => a - b) : [];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The catalog Postgres's single-column PRIMARY KEYS of `relids` (never another unique index: the module's doc), with
|
|
43
|
+
* each one's collation and the equality operators of its btree family. A primary key is valid, unique and over a plain
|
|
44
|
+
* column; the conditions are written out all the same, so the rule is this query's and not a reader's memory.
|
|
45
|
+
*/
|
|
46
|
+
export function uniqueColumnsSql(relids) {
|
|
47
|
+
return "SELECT i.indrelid::text, i.indkey[0]::text, i.indcollation[0]::text, coalesce(string_agg(o.amopopr::text, ' ' ORDER BY o.amopopr), '') " +
|
|
48
|
+
"FROM pg_index i JOIN pg_opclass c ON c.oid = i.indclass[0] JOIN pg_am m ON m.oid = c.opcmethod AND m.amname = 'btree' " +
|
|
49
|
+
"LEFT JOIN pg_amop o ON o.amopfamily = c.opcfamily AND o.amopmethod = c.opcmethod AND o.amopstrategy = 3 " +
|
|
50
|
+
`WHERE i.indrelid IN (${relids.map((r) => String(Math.trunc(r))).join(", ")}) AND i.indisprimary AND i.indisunique AND i.indisvalid AND i.indnatts = 1 AND i.indnkeyatts = 1 ` +
|
|
51
|
+
"AND i.indkey[0] > 0 AND i.indpred IS NULL AND i.indexprs IS NULL GROUP BY 1, 2, 3";
|
|
52
|
+
}
|
|
53
|
+
/** `uniqueColumnsSql`'s rows, as the decision reads them (a table has one primary key; the merge is for a second row
|
|
54
|
+
* of one column, which no catalog writes) */
|
|
55
|
+
export function uniqueColumnsOf(rows) {
|
|
56
|
+
const out = new Map();
|
|
57
|
+
for (const [relid, attno, collation, ops] of rows) {
|
|
58
|
+
const key = columnKey(Number(relid), Number(attno));
|
|
59
|
+
const equalities = new Set((ops ?? "").split(" ").filter((x) => x !== "").map(Number));
|
|
60
|
+
const had = out.get(key);
|
|
61
|
+
// two rows for one column under two collations (none today): either's equality, under its own collation, is the key's;
|
|
62
|
+
// kept as the first (the decision then asks that one's), which is conservative
|
|
63
|
+
if (had === undefined)
|
|
64
|
+
out.set(key, { collation: Number(collation ?? 0), equalities });
|
|
65
|
+
else if (had.collation === Number(collation ?? 0))
|
|
66
|
+
out.set(key, { collation: had.collation, equalities: new Set([...had.equalities, ...equalities]) });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** a VAR of this query's own FROM (varlevelsup 0), bare or under a binary-compatible relabel (a varchar read as text) */
|
|
71
|
+
function ownVar(v) {
|
|
72
|
+
if (!isNode(v))
|
|
73
|
+
return null;
|
|
74
|
+
if (v._node === "RELABELTYPE")
|
|
75
|
+
return ownVar(v.arg);
|
|
76
|
+
return v._node === "VAR" && (num(v.varlevelsup) ?? 0) === 0 && (num(v.varattno) ?? 0) >= 1 ? v : null;
|
|
77
|
+
}
|
|
78
|
+
/** the rtindexes a join tree's node holds (its tables', and its joins' own: a USING join's column is the join's) */
|
|
79
|
+
function indexesOf(n) {
|
|
80
|
+
const out = new Set();
|
|
81
|
+
const visit = (v) => {
|
|
82
|
+
if (!isNode(v))
|
|
83
|
+
return;
|
|
84
|
+
if (v._node === "RANGETBLREF") {
|
|
85
|
+
const i = num(v.rtindex);
|
|
86
|
+
if (i !== null)
|
|
87
|
+
out.add(i);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (v._node === "JOINEXPR") {
|
|
91
|
+
const i = num(v.rtindex);
|
|
92
|
+
if (i !== null)
|
|
93
|
+
out.add(i);
|
|
94
|
+
visit(v.larg);
|
|
95
|
+
visit(v.rarg);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
visit(n);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** a conjunction's conjuncts (outfuncs.c writes BOOLEXPR's boolop as `and`, `or`, `not`), or the one condition */
|
|
102
|
+
function conjuncts(q) {
|
|
103
|
+
if (!isNode(q))
|
|
104
|
+
return [];
|
|
105
|
+
if (q._node === "BOOLEXPR" && q.boolop === "and")
|
|
106
|
+
return list(q.args).flatMap((a) => conjuncts(a));
|
|
107
|
+
return [q];
|
|
108
|
+
}
|
|
109
|
+
/** the base table a FROM's RANGETBLREF names: its RANGETBLENTRY, when it is one (a relation, not a subquery, a CTE, a
|
|
110
|
+
* function, VALUES or a join) */
|
|
111
|
+
function baseTable(rtable, ref) {
|
|
112
|
+
if (!isNode(ref) || ref._node !== "RANGETBLREF")
|
|
113
|
+
return null;
|
|
114
|
+
const rtindex = num(ref.rtindex);
|
|
115
|
+
const rte = rtindex === null ? undefined : rtable[rtindex - 1];
|
|
116
|
+
if (!isNode(rte) || num(rte.rtekind) !== RTE_RELATION || rte.relkind !== "r")
|
|
117
|
+
return null;
|
|
118
|
+
const relid = num(rte.relid);
|
|
119
|
+
return rtindex !== null && relid !== null && relid > 0 ? { rtindex, relid } : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether a join's condition matches each row of its `side` with at most ONE row of `other`, a base table: a conjunct
|
|
123
|
+
* that is an equality, by the operator of `other`'s single-column primary key and under its collation, of that
|
|
124
|
+
* column (bare) with a column of `side` (bare).
|
|
125
|
+
*/
|
|
126
|
+
function atMostOne(quals, side, other, unique) {
|
|
127
|
+
return conjuncts(quals).some((c) => {
|
|
128
|
+
if (c._node !== "OPEXPR" || list(c.args).length !== 2)
|
|
129
|
+
return false;
|
|
130
|
+
const [a, b] = list(c.args).map(ownVar);
|
|
131
|
+
if (a == null || b == null)
|
|
132
|
+
return false;
|
|
133
|
+
for (const [mine, theirs] of [[a, b], [b, a]]) {
|
|
134
|
+
if (num(theirs.varno) !== other.rtindex || !side.has(num(mine.varno) ?? -1))
|
|
135
|
+
continue;
|
|
136
|
+
const u = unique.get(columnKey(other.relid, num(theirs.varattno)));
|
|
137
|
+
const collation = num(c.inputcollid) ?? 0;
|
|
138
|
+
if (u !== undefined && u.equalities.has(num(c.opno) ?? -1) && (collation === 0 || collation === u.collation))
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether the FROM of `query` holds each row of the table at `rtindex` at most once: its one item, that table or a
|
|
146
|
+
* join tree whose every join on the way down to it joins one base table on that table's primary key (`atMostOne`).
|
|
147
|
+
*/
|
|
148
|
+
export function notMultiplied(query, rtindex, unique) {
|
|
149
|
+
const jt = query.jointree;
|
|
150
|
+
if (!isNode(jt))
|
|
151
|
+
return false;
|
|
152
|
+
const from = list(jt.fromlist);
|
|
153
|
+
if (from.length !== 1)
|
|
154
|
+
return false;
|
|
155
|
+
const rtable = list(query.rtable);
|
|
156
|
+
const down = (n) => {
|
|
157
|
+
if (!isNode(n))
|
|
158
|
+
return false;
|
|
159
|
+
if (n._node === "RANGETBLREF")
|
|
160
|
+
return num(n.rtindex) === rtindex;
|
|
161
|
+
if (n._node !== "JOINEXPR" || !JOINS.has(num(n.jointype) ?? -1) || !isNode(n.larg) || !isNode(n.rarg))
|
|
162
|
+
return false;
|
|
163
|
+
const left = indexesOf(n.larg);
|
|
164
|
+
const [side, otherRef] = left.has(rtindex) ? [n.larg, n.rarg] : indexesOf(n.rarg).has(rtindex) ? [n.rarg, n.larg] : [null, null];
|
|
165
|
+
if (side === null)
|
|
166
|
+
return false;
|
|
167
|
+
const other = baseTable(rtable, otherRef);
|
|
168
|
+
return other !== null && atMostOne(n.quals, indexesOf(side), other, unique) && down(side);
|
|
169
|
+
};
|
|
170
|
+
return down(from[0]);
|
|
171
|
+
}
|
|
172
|
+
/** Whether this AGGREF of `query` is a count(DISTINCT x) whose DISTINCT changes nothing (the module's doc). */
|
|
173
|
+
function distinctOfKey(query, a, unique) {
|
|
174
|
+
if (!isDistinctCount(a) || (num(a.agglevelsup) ?? 0) !== 0 || a.aggkind !== "n" || a.aggstar === "true" || list(a.aggorder).length > 0)
|
|
175
|
+
return false;
|
|
176
|
+
const args = list(a.args);
|
|
177
|
+
const arg = args.length === 1 && isNode(args[0]) && args[0]._node === "TARGETENTRY" ? args[0].expr : undefined;
|
|
178
|
+
// the column itself: a cast or an expression of it need not be unique (`id / 2`, `id::text` under another equality)
|
|
179
|
+
if (!isNode(arg) || arg._node !== "VAR" || (num(arg.varlevelsup) ?? 0) !== 0)
|
|
180
|
+
return false;
|
|
181
|
+
const varno = num(arg.varno), attno = num(arg.varattno);
|
|
182
|
+
if (varno === null || attno === null || attno < 1)
|
|
183
|
+
return false;
|
|
184
|
+
const table = baseTable(list(query.rtable), { _node: "RANGETBLREF", rtindex: String(varno) });
|
|
185
|
+
if (table === null)
|
|
186
|
+
return false;
|
|
187
|
+
const u = unique.get(columnKey(table.relid, attno));
|
|
188
|
+
const distinct = list(a.aggdistinct)[0];
|
|
189
|
+
const collation = num(a.inputcollid) ?? 0;
|
|
190
|
+
if (u === undefined || !isNode(distinct) || !u.equalities.has(num(distinct.eqop) ?? -1) || (collation !== 0 && collation !== u.collation))
|
|
191
|
+
return false;
|
|
192
|
+
return notMultiplied(query, varno, unique);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* THE DECISION: the offsets (into the analysed text) of every count(DISTINCT x) of the statement whose DISTINCT may be
|
|
196
|
+
* dropped — each AGGREF decided in the query it belongs to (an outer query's aggregate inside a subquery,
|
|
197
|
+
* agglevelsup > 0, is left as it is).
|
|
198
|
+
*/
|
|
199
|
+
export function distinctKeyCounts(tree, unique) {
|
|
200
|
+
const out = new Set();
|
|
201
|
+
if (unique.size === 0)
|
|
202
|
+
return out;
|
|
203
|
+
const visit = (v, query) => {
|
|
204
|
+
if (Array.isArray(v)) {
|
|
205
|
+
for (const x of v)
|
|
206
|
+
visit(x, query);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!isNode(v))
|
|
210
|
+
return;
|
|
211
|
+
if (v._node === "QUERY") {
|
|
212
|
+
for (const [k, x] of Object.entries(v))
|
|
213
|
+
if (k !== "_node")
|
|
214
|
+
visit(x, v);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (query !== null && v._node === "AGGREF" && distinctOfKey(query, v, unique)) {
|
|
218
|
+
const at = num(v.location);
|
|
219
|
+
if (at !== null && at >= 0)
|
|
220
|
+
out.add(at);
|
|
221
|
+
}
|
|
222
|
+
for (const [k, x] of Object.entries(v))
|
|
223
|
+
if (k !== "_node")
|
|
224
|
+
visit(x, query);
|
|
225
|
+
};
|
|
226
|
+
visit(tree, null);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { parentPort, workerData } from "node:worker_threads";
|
|
14
14
|
import { openDuck, SessionRunner } from "./store.js";
|
|
15
|
-
import { FUNCTION_CALLS, registerLibcOrder, useCancelTable } from "./functions.js";
|
|
15
|
+
import { FUNCTION_CALLS, FUNCTION_ROWS, registerLibcOrder, useCancelTable } from "./functions.js";
|
|
16
16
|
import { stopPgEval } from "./pg-eval.js";
|
|
17
17
|
import { StatementError } from "../api/postgres/server.js";
|
|
18
18
|
import { STOPPING, wireError } from "./executor.js";
|
|
@@ -128,6 +128,7 @@ async function answer(m) {
|
|
|
128
128
|
registerLibcOrder(need(conns, 0, "connection"), m.order);
|
|
129
129
|
return { value: null };
|
|
130
130
|
case "calls": return { value: Object.fromEntries(FUNCTION_CALLS) };
|
|
131
|
+
case "function rows": return { value: Object.fromEntries(FUNCTION_ROWS) };
|
|
131
132
|
case "runner.open": {
|
|
132
133
|
if (instance === null)
|
|
133
134
|
throw new Error("the copy is not open (Mason bug: report it)");
|
|
@@ -188,10 +189,14 @@ async function answer(m) {
|
|
|
188
189
|
return { value: { bytes, ends }, transfer: [bytes] };
|
|
189
190
|
}
|
|
190
191
|
case "answer.close": {
|
|
192
|
+
// answered once DuckDB handed the answer back (its drain, src/duck/store.ts): until then the main thread holds the
|
|
193
|
+
// process open, which must never end while DuckDB calls into this thread (src/duck/executor.ts "NOTHING ENDS")
|
|
191
194
|
const k = key(Number(m.runner), Number(m.seq));
|
|
192
195
|
statements.get(k)?.answer?.close();
|
|
193
196
|
statements.delete(k);
|
|
194
|
-
|
|
197
|
+
await (runners.get(Number(m.runner)) ?? null)?.idle();
|
|
198
|
+
await closing.get(Number(m.runner));
|
|
199
|
+
return { value: null };
|
|
195
200
|
}
|
|
196
201
|
case "runner.explain": {
|
|
197
202
|
const runner = need(runners, Number(m.runner), "session");
|
|
@@ -164,10 +164,23 @@ function plainValues(values) {
|
|
|
164
164
|
* worker"); a deadlock is for ever, so a longer bound only says it later.
|
|
165
165
|
* - THE CLOSE IS BOUNDED: `close` gives the worker's shutdown `CLOSE_GRACE_MS` (a calling statement stops at its next
|
|
166
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
|
|
167
|
+
* before SIGKILL and `mason stop`'s 15 s), then declares it stopped the same way and resolves; the thread is ended
|
|
168
|
+
* once its shutdown answers (below).
|
|
168
169
|
* What neither can do: end a thread blocked inside native code. Such a process does not exit by itself, nor on
|
|
169
170
|
* process.exit (measured); its supervisor's SIGKILL does. The copy is not opened again in this process
|
|
170
171
|
* (`abandoned`): a second instance of the file would lose writes.
|
|
172
|
+
*
|
|
173
|
+
* NOTHING ENDS THE WORKER'S JAVASCRIPT UNDER A PENDING CALL OF DuckDB'S (a test file under load aborted at its exit,
|
|
174
|
+
* `libc++abi: terminating due to uncaught exception of type Napi::Error`: its close outlasted the grace, and the
|
|
175
|
+
* executor TERMINATED the worker while DuckDB's threads were inside a Mason function call — the node API's callback met
|
|
176
|
+
* a dying environment and threw where nothing catches. Reproduced every time by a close with a 1 ms grace under a
|
|
177
|
+
* calling statement, tests/duck-executor.test.ts). So a worker given up on (the grace, the silence) is told nothing
|
|
178
|
+
* more and asked to SHUT DOWN (`shutDown`: posted once, its answer awaited outside `pending`), and is ended only once
|
|
179
|
+
* that shutdown has answered — DuckDB's statements stopped, its connections and instance closed — or it exits by
|
|
180
|
+
* itself; until then it stays REFERENCED, so the process cannot end with the worker mid-call either (a process
|
|
181
|
+
* exiting tears its workers down the same way). A drained answer is a request for the same reason (`answer.close`):
|
|
182
|
+
* DuckDB may call into the worker after the listener let go of the statement. A thread that never answers its
|
|
183
|
+
* shutdown is the deadlock above: its supervisor's SIGKILL.
|
|
171
184
|
*/
|
|
172
185
|
export const PING_MS = 5_000;
|
|
173
186
|
export const SILENT_MS = 60_000;
|
|
@@ -192,6 +205,11 @@ export class Executor {
|
|
|
192
205
|
/** why the worker was given up on without exiting (`close`'s bound, the silence), once it was: its instance may
|
|
193
206
|
* still hold the file, which this process must never open again */
|
|
194
207
|
abandoned = null;
|
|
208
|
+
/** the shutdown, once posted (`shutDown`): settled when the worker answered it, or exited */
|
|
209
|
+
shutdown = null;
|
|
210
|
+
shutdownId = 0;
|
|
211
|
+
shutdownAnswered = () => undefined;
|
|
212
|
+
hasExited = false;
|
|
195
213
|
constructor(o) {
|
|
196
214
|
this.silentMs = o.silentMs ?? SILENT_MS;
|
|
197
215
|
this.log = o.log;
|
|
@@ -207,6 +225,12 @@ export class Executor {
|
|
|
207
225
|
this.log(m.log);
|
|
208
226
|
return;
|
|
209
227
|
}
|
|
228
|
+
if (m.id === this.shutdownId) {
|
|
229
|
+
if (!m.ok)
|
|
230
|
+
this.log(`closing the copy failed: ${errorOf(m.error).message}`);
|
|
231
|
+
this.shutdownAnswered();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
210
234
|
const p = this.pending.get(m.id);
|
|
211
235
|
if (p === undefined)
|
|
212
236
|
return;
|
|
@@ -221,7 +245,9 @@ export class Executor {
|
|
|
221
245
|
why = e.code === "ERR_WORKER_OUT_OF_MEMORY" ? `its thread ran out of JavaScript heap (${e.message})` : `it failed: ${e.message}`;
|
|
222
246
|
});
|
|
223
247
|
this.exited = new Promise((resolve) => this.worker.once("exit", (code) => {
|
|
248
|
+
this.hasExited = true;
|
|
224
249
|
this.die(this.closing && why === null ? "it was closed" : why ?? `it exited with code ${code}`);
|
|
250
|
+
this.shutdownAnswered();
|
|
225
251
|
resolve();
|
|
226
252
|
}));
|
|
227
253
|
// the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer
|
|
@@ -238,15 +264,34 @@ export class Executor {
|
|
|
238
264
|
}, Math.min(PING_MS, this.silentMs / 4));
|
|
239
265
|
this.watch.unref();
|
|
240
266
|
}
|
|
241
|
-
/** the worker given up on without its exit: every request told so (57P02), the run told, and
|
|
242
|
-
*
|
|
267
|
+
/** the worker given up on without its exit: every request told so (57P02), the run told, and its shutdown asked —
|
|
268
|
+
* NEVER a terminate, which under a pending call of DuckDB's aborts the process (NOTHING ENDS THE WORKER'S
|
|
269
|
+
* JAVASCRIPT above): it is ended once its shutdown answers, and holds the process until then */
|
|
243
270
|
abandon(reason) {
|
|
244
271
|
if (this.gone !== null)
|
|
245
272
|
return;
|
|
246
273
|
this.abandoned = reason;
|
|
247
274
|
this.die(reason, false);
|
|
248
|
-
this.
|
|
249
|
-
|
|
275
|
+
void this.shutDown();
|
|
276
|
+
}
|
|
277
|
+
/** Asks the worker to stop (executor-worker.ts `shutdown`: every statement stopped, every connection and the instance
|
|
278
|
+
* closed once nothing runs on them), once; settles when it answered, or exited. Only then is it terminated: nothing
|
|
279
|
+
* of DuckDB's calls into it any more. The worker stays referenced meanwhile, so the process waits for it. */
|
|
280
|
+
shutDown() {
|
|
281
|
+
if (this.shutdown !== null)
|
|
282
|
+
return this.shutdown;
|
|
283
|
+
this.shutdown = new Promise((resolve) => { this.shutdownAnswered = resolve; });
|
|
284
|
+
if (this.hasExited)
|
|
285
|
+
this.shutdownAnswered();
|
|
286
|
+
else {
|
|
287
|
+
this.shutdownId = this.nextId++;
|
|
288
|
+
this.worker.ref();
|
|
289
|
+
this.worker.postMessage({ op: "shutdown", id: this.shutdownId });
|
|
290
|
+
}
|
|
291
|
+
void this.shutdown.then(async () => { if (!this.hasExited)
|
|
292
|
+
await this.worker.terminate(); })
|
|
293
|
+
.catch((e) => this.log(`ending the copy's thread failed: ${e.message}`));
|
|
294
|
+
return this.shutdown;
|
|
250
295
|
}
|
|
251
296
|
static async start(o) {
|
|
252
297
|
const e = new Executor(o);
|
|
@@ -280,7 +325,8 @@ export class Executor {
|
|
|
280
325
|
}
|
|
281
326
|
settle(id) {
|
|
282
327
|
this.pending.delete(id);
|
|
283
|
-
|
|
328
|
+
// a shutdown in flight holds the process until the worker ends (`shutDown`)
|
|
329
|
+
if (this.pending.size === 0 && this.shutdown === null)
|
|
284
330
|
this.worker.unref();
|
|
285
331
|
}
|
|
286
332
|
/** a request, answered by the worker */
|
|
@@ -295,11 +341,17 @@ export class Executor {
|
|
|
295
341
|
this.worker.postMessage({ ...m, id }, transfer);
|
|
296
342
|
});
|
|
297
343
|
}
|
|
298
|
-
/** a message needing no answer (an interrupt, a close
|
|
344
|
+
/** a message needing no answer (an interrupt, a connection's close); nothing once the worker is gone */
|
|
299
345
|
post(m) {
|
|
300
346
|
if (this.gone === null)
|
|
301
347
|
this.worker.postMessage(m);
|
|
302
348
|
}
|
|
349
|
+
/** a request whose answer nobody awaits (an answer's close: the worker answers once DuckDB handed it back), kept in
|
|
350
|
+
* `pending` so the process does not end while the worker still drains it; a failure is logged, a stop is not one */
|
|
351
|
+
background(m) {
|
|
352
|
+
this.call(m).catch((e) => { if (!(e instanceof ExecutorGone))
|
|
353
|
+
this.log(`${String(m.op)} failed: ${e.message}`); });
|
|
354
|
+
}
|
|
303
355
|
/** a connection of the instance */
|
|
304
356
|
async connect() { return new RemoteConnection(this, await this.call({ op: "connect" })); }
|
|
305
357
|
writer() { return new RemoteConnection(this, 0); }
|
|
@@ -308,6 +360,8 @@ export class Executor {
|
|
|
308
360
|
/** how many vectors each Mason function was called with so far (src/duck/functions.ts FUNCTION_CALLS: the worker's
|
|
309
361
|
* count, the functions being its) — what a test or a measurement reads to know whether a statement reached one */
|
|
310
362
|
async functionCalls() { return new Map(Object.entries(await this.call({ op: "calls" }))); }
|
|
363
|
+
/** how many rows each Mason function was handed so far, across its calls (src/duck/functions.ts FUNCTION_ROWS) */
|
|
364
|
+
async functionRows() { return new Map(Object.entries(await this.call({ op: "function rows" }))); }
|
|
311
365
|
/** a wire session's connection: a slot of the cancel table, its connection on the worker */
|
|
312
366
|
async runner() {
|
|
313
367
|
const slot = this.free.pop();
|
|
@@ -335,25 +389,11 @@ export class Executor {
|
|
|
335
389
|
this.closing = true;
|
|
336
390
|
let timer;
|
|
337
391
|
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"; });
|
|
392
|
+
// the shutdown, then the terminate once it answered (`shutDown`), then the exit — or the worker's own exit
|
|
393
|
+
const closed = (async () => { await this.shutDown(); await this.exited; return "closed"; })();
|
|
354
394
|
try {
|
|
355
395
|
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
|
|
396
|
+
this.log(`the copy'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 DuckDB needs a SIGKILL)`);
|
|
357
397
|
this.abandon(`it did not close within ${graceMs / 1000} s`);
|
|
358
398
|
}
|
|
359
399
|
}
|
|
@@ -482,10 +522,11 @@ class RemoteRunner {
|
|
|
482
522
|
if (ended)
|
|
483
523
|
return;
|
|
484
524
|
end();
|
|
485
|
-
// its calls see the cancel now; the worker cancels the rest (an interrupt) and releases it once DuckDB hands it
|
|
525
|
+
// its calls see the cancel now; the worker cancels the rest (an interrupt) and releases it once DuckDB hands it
|
|
526
|
+
// back — answered then, so the process never ends while DuckDB still calls into the worker for it
|
|
486
527
|
if (calls)
|
|
487
528
|
cancelStatement(this.ex.cancels, this.slot, seq);
|
|
488
|
-
this.ex.
|
|
529
|
+
this.ex.background({ op: "answer.close", runner: this.id, seq });
|
|
489
530
|
},
|
|
490
531
|
};
|
|
491
532
|
}
|
package/dist/src/duck/explain.js
CHANGED
|
@@ -19,21 +19,137 @@ export function planNodes(json) {
|
|
|
19
19
|
return [node];
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
|
+
/** a profile DuckDB's profiler wrote (`enable_profiling = 'json'`): the statement it timed, as it names it (`query.sql`,
|
|
23
|
+
* the text it was given), and its plan. The name is what holds a profile to its statement (src/duck/store.ts
|
|
24
|
+
* `explained`): the file is the last profiled statement's on the connection, whichever that was */
|
|
25
|
+
export function profiled(doc) {
|
|
26
|
+
const d = doc;
|
|
27
|
+
return { sql: typeof d.query?.sql === "string" ? d.query.sql : null, plan: planNodes(d.operator ?? []) };
|
|
28
|
+
}
|
|
29
|
+
/** what EXPLAIN ANALYZE says of a statement DuckDB's profiler timed no operator of */
|
|
30
|
+
export const UNPROFILED = "DuckDB's profiler recorded no operator of this statement: DuckDB 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
31
|
/** DuckDB's operator as DuckDB's own plan prints it (`HASH_GROUP_BY` is `Hash Group By`), a scan `Table Scan` as its
|
|
23
32
|
* profiler names it: DuckDB's EXPLAIN says SEQ_SCAN for the same operator, and Postgres's `Seq Scan` says otherwise */
|
|
24
33
|
export const operatorName = (type) => type.toLowerCase().split("_").map((w) => w === "n" ? "N" : w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
25
|
-
/**
|
|
34
|
+
/** a name as Postgres writes one in a statement: bare when it is lower case letters, digits and `_`, else quoted */
|
|
35
|
+
const quotedName = (s) => /^[a-z_][a-z0-9_$]*$/.test(s) ? s : `"${s.replaceAll('"', '""')}"`;
|
|
36
|
+
/**
|
|
37
|
+
* A TABLE AS POSTGRES WRITES IT, each part quoted where it must be (`public.orders`, `public."Clients"`,
|
|
38
|
+
* `public."x; y"`): the ONE way EXPLAIN names a copy (its line, FORMAT JSON's `Mason.Copies[].Table`), which a statement
|
|
39
|
+
* can read back as it is (`mason mcp`'s tables and describe name tables by it too).
|
|
40
|
+
*/
|
|
41
|
+
export const qualifiedName = (t) => `${quotedName(t.schema)}.${quotedName(t.name)}`;
|
|
42
|
+
/** DuckDB's `<file>.<schema>.<table>`, each part quoted where DuckDB quotes it (`memory.main."x; drop.t"`), as its last
|
|
43
|
+
* two parts: a `.` inside a quoted part is the name's */
|
|
44
|
+
export function duckTableName(text) {
|
|
45
|
+
const parts = [];
|
|
46
|
+
let at = 0;
|
|
47
|
+
while (at <= text.length) {
|
|
48
|
+
if (text[at] === '"') {
|
|
49
|
+
let out = "", end = at + 1;
|
|
50
|
+
for (; end < text.length; end++) {
|
|
51
|
+
if (text[end] === '"') {
|
|
52
|
+
if (text[end + 1] === '"') {
|
|
53
|
+
out += '"';
|
|
54
|
+
end++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
out += text[end];
|
|
60
|
+
}
|
|
61
|
+
parts.push(out);
|
|
62
|
+
at = end + 2; // past the closing quote and the dot
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const dot = text.indexOf(".", at);
|
|
66
|
+
parts.push(text.slice(at, dot < 0 ? undefined : dot));
|
|
67
|
+
at = dot < 0 ? text.length + 1 : dot + 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return parts.length >= 2 ? { schema: parts[parts.length - 2], name: parts[parts.length - 1] } : null;
|
|
71
|
+
}
|
|
72
|
+
/** the copy a scan reads (DuckDB's `<file>.<schema>.<table>`) */
|
|
26
73
|
export const scannedTable = (n) => {
|
|
27
74
|
const t = n.extra.Table;
|
|
28
|
-
return typeof t === "string" ? t
|
|
75
|
+
return typeof t === "string" ? duckTableName(t) : null;
|
|
29
76
|
};
|
|
30
77
|
export function scannedTables(nodes) {
|
|
31
78
|
const out = [];
|
|
32
|
-
const walk = (n) => {
|
|
33
|
-
|
|
79
|
+
const walk = (n) => {
|
|
80
|
+
const t = scannedTable(n);
|
|
81
|
+
if (t !== null && !out.some((o) => o.schema === t.schema && o.name === t.name))
|
|
82
|
+
out.push(t);
|
|
83
|
+
n.children.forEach(walk);
|
|
84
|
+
};
|
|
34
85
|
nodes.forEach(walk);
|
|
35
86
|
return out;
|
|
36
87
|
}
|
|
88
|
+
/** the sync's states as EXPLAIN takes them across to the executor's thread (src/api/postgres/server.ts `duckExplain`):
|
|
89
|
+
* its facts alone, read at the EXPLAIN */
|
|
90
|
+
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 } : {}) }));
|
|
91
|
+
/** an ISO instant as EXPLAIN says one: `2026-10-27 19:58:39 UTC` */
|
|
92
|
+
const utc = (iso) => `${iso.slice(0, 19).replace("T", " ")} UTC`;
|
|
93
|
+
const UNKNOWN = "how it is kept current is unknown: the sync has no record of it";
|
|
94
|
+
/**
|
|
95
|
+
* HOW A COPY IS KEPT CURRENT, in EXPLAIN's words, from the sync's one derivation (src/duck/sync.ts `KeptCurrent`, which
|
|
96
|
+
* `mason status` renders too): a copy not yet copied or whose copy failed says so first; then polled with its last
|
|
97
|
+
* poll, served as copied and skipped by the passes (to be copied again, caught up first, over the poll's row limit),
|
|
98
|
+
* copied again whole, or, in replication mode, its part in the stream, how far the stream has applied and why it is
|
|
99
|
+
* not running. "kept current" is said only of a copy that is (`keptCurrentBy`).
|
|
100
|
+
*/
|
|
101
|
+
export function keptText(f) {
|
|
102
|
+
if (f === null)
|
|
103
|
+
return UNKNOWN;
|
|
104
|
+
if (f.phase === "waiting")
|
|
105
|
+
return "waiting for its copy to start";
|
|
106
|
+
if (f.phase === "copying")
|
|
107
|
+
return `being copied (${count(f.rowsSoFar ?? 0)} rows so far): kept current once copied`;
|
|
108
|
+
if (f.phase === "failed")
|
|
109
|
+
return `its last copy failed: ${(f.error ?? "mason logs says why").split("\n")[0]}`;
|
|
110
|
+
const k = f.kept;
|
|
111
|
+
if (k === undefined)
|
|
112
|
+
return UNKNOWN;
|
|
113
|
+
if (k.by === "poll") {
|
|
114
|
+
const asOf = k.polledAt === null ? "no poll recorded yet" : `as of ${utc(k.polledAt)}`;
|
|
115
|
+
if (k.pending !== undefined)
|
|
116
|
+
return `NOT kept current (${asOf}): ${k.pending}`;
|
|
117
|
+
if (k.unsynced !== undefined)
|
|
118
|
+
return `NOT kept current (${k.unsynced}; ${asOf}): mason sync ${f.source} polls it now`;
|
|
119
|
+
return `kept current by polling, ${k.polledAt === null ? "no poll recorded yet" : `last poll ${utc(k.polledAt)}`}`;
|
|
120
|
+
}
|
|
121
|
+
if (k.by === "recopy")
|
|
122
|
+
return `kept current by copying it again whole, ${k.copiedAt === null ? "no copy recorded yet" : `last copied ${utc(k.copiedAt)}`}`;
|
|
123
|
+
const stream = `logical replication (slot ${k.slot})`;
|
|
124
|
+
const where = `applied through ${k.position}, ${k.lastCommit === null ? "no change applied this run" : `last change committed ${utc(k.lastCommit)}`}`;
|
|
125
|
+
if (k.state === "streamed")
|
|
126
|
+
return k.notRunning === null ? `kept current by ${stream}, ${where}`
|
|
127
|
+
: `streamed by ${stream}, ${where}; NOT kept current now, the stream is not running: ${k.notRunning}`;
|
|
128
|
+
return k.state === "left-out" ? `NOT kept current: left out of ${stream}: ${k.why ?? ""}`
|
|
129
|
+
: `${stream}, not streamed yet: ${k.why ?? ""}`;
|
|
130
|
+
}
|
|
131
|
+
/** what keeps a copy current NOW, as FORMAT JSON's `Kept Current By` says it: null for a copy nothing does (not copied
|
|
132
|
+
* yet, skipped by the passes, over the poll's row limit, not streamed, the stream down), so a reader keying on it is
|
|
133
|
+
* never told of a mode that is not at work on it */
|
|
134
|
+
export function keptCurrentBy(f) {
|
|
135
|
+
const k = f?.phase === "copied" ? f.kept : undefined;
|
|
136
|
+
if (k === undefined)
|
|
137
|
+
return null;
|
|
138
|
+
if (k.by === "poll")
|
|
139
|
+
return k.pending === undefined && k.unsynced === undefined ? "polling" : null;
|
|
140
|
+
if (k.by === "recopy")
|
|
141
|
+
return "copying again";
|
|
142
|
+
return k.state === "streamed" && k.notRunning === null ? "logical replication" : null;
|
|
143
|
+
}
|
|
144
|
+
/** how a copy is kept current, as FORMAT JSON's keys say it (`keptText`'s facts) */
|
|
145
|
+
function keptJson(f) {
|
|
146
|
+
const k = f?.phase === "copied" ? f.kept : undefined;
|
|
147
|
+
return {
|
|
148
|
+
"Kept Current By": keptCurrentBy(f), "Kept Current": keptText(f),
|
|
149
|
+
...(k?.by === "poll" ? { "Last Poll": k.polledAt } : {}),
|
|
150
|
+
...(k?.by === "replication" ? { Slot: k.slot, "Stream State": k.state, "Applied Through": k.position, "Last Change Committed": k.lastCommit, "Stream Not Running": k.notRunning } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
37
153
|
const ms = (x) => x.toFixed(3);
|
|
38
154
|
const count = (n) => n.toLocaleString("en-US");
|
|
39
155
|
const detail = (v) => Array.isArray(v) ? v.join(", ") : v;
|
|
@@ -41,7 +157,7 @@ const detail = (v) => Array.isArray(v) ? v.join(", ") : v;
|
|
|
41
157
|
const VERBOSE_ONLY = new Set(["Projections", "Filters", "Table", "Type"]);
|
|
42
158
|
function copyLine(c) {
|
|
43
159
|
const rows = c.rows === null ? "rows unknown" : `about ${count(c.rows)} rows`;
|
|
44
|
-
return `${c.table} (${rows};
|
|
160
|
+
return `${c.table} (${rows}; ${keptText(c.facts)})`;
|
|
45
161
|
}
|
|
46
162
|
/** EXPLAIN as Postgres's text format: one line a row */
|
|
47
163
|
export function explainText(e, o) {
|
|
@@ -57,14 +173,16 @@ export function explainText(e, o) {
|
|
|
57
173
|
lines.push(` Mason's functions DuckDB calls: ${e.functions.join(", ")}`);
|
|
58
174
|
for (const name of o.ignored)
|
|
59
175
|
lines.push(` Note: ${name} has no DuckDB meaning: ignored`);
|
|
60
|
-
if (e.analyzed
|
|
176
|
+
if (e.analyzed?.profiled === false)
|
|
177
|
+
lines.push(` Note: ${UNPROFILED}`);
|
|
178
|
+
else if (e.analyzed !== undefined && o.timing)
|
|
61
179
|
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
180
|
const walk = (n, depth) => {
|
|
63
181
|
const lead = depth === 0 ? "" : `${" ".repeat(6 * depth - 4)}-> `;
|
|
64
182
|
const on = scannedTable(n);
|
|
65
183
|
const est = o.costs && n.estimate !== null ? ` (rows=${n.estimate})` : "";
|
|
66
184
|
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}`);
|
|
185
|
+
lines.push(`${lead}${operatorName(n.type)}${on === null ? "" : ` on ${qualifiedName(on)}`}${est}${actual}`);
|
|
68
186
|
const pad = " ".repeat(depth === 0 ? 2 : 6 * depth + 2);
|
|
69
187
|
for (const [k, v] of Object.entries(n.extra))
|
|
70
188
|
if (o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k))
|
|
@@ -88,7 +206,7 @@ export function explainJson(e, o) {
|
|
|
88
206
|
const on = scannedTable(n);
|
|
89
207
|
return {
|
|
90
208
|
"Node Type": operatorName(n.type),
|
|
91
|
-
...(on === null ? {} : { "Relation Name": on.
|
|
209
|
+
...(on === null ? {} : { "Relation Name": on.name, Schema: on.schema }),
|
|
92
210
|
...(o.costs && n.estimate !== null ? { "Plan Rows": n.estimate } : {}),
|
|
93
211
|
...(n.actualRows === undefined ? {} : { "Actual Rows": n.actualRows, "Actual Loops": 1, ...(o.timing && n.threadMs !== undefined ? { "DuckDB Thread Time": Number(ms(n.threadMs)) } : {}) }),
|
|
94
212
|
...Object.fromEntries(Object.entries(n.extra).filter(([k]) => o.verbose ? k !== "Table" : !VERBOSE_ONLY.has(k)).map(([k, v]) => [`DuckDB ${k}`, v])),
|
|
@@ -101,11 +219,12 @@ export function explainJson(e, o) {
|
|
|
101
219
|
Plan: roots.length === 1 ? roots[0] : { "Node Type": "DuckDB Plans", Plans: roots },
|
|
102
220
|
Mason: {
|
|
103
221
|
"Answered By": "DuckDB",
|
|
104
|
-
Copies: e.copies.map((c) => ({ Table: c.table, "Estimated Rows": c.rows,
|
|
222
|
+
Copies: e.copies.map((c) => ({ Table: c.table, "Estimated Rows": c.rows, ...keptJson(c.facts) })),
|
|
105
223
|
"Sent To DuckDB": e.sql,
|
|
106
224
|
"Computed After DuckDB": e.computed,
|
|
107
225
|
"Mason Functions DuckDB Calls": e.functions,
|
|
108
226
|
...(o.ignored.length === 0 ? {} : { Ignored: o.ignored.map((n) => `${n} has no DuckDB meaning`) }),
|
|
227
|
+
...(e.analyzed?.profiled === false ? { Note: UNPROFILED } : {}),
|
|
109
228
|
},
|
|
110
229
|
};
|
|
111
230
|
if (e.analyzed !== undefined && o.summary) {
|