@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
package/dist/src/duck/cancel.js
CHANGED
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
* running`: a cancel of a statement already past never reaches the next one, a cancel that lands before its statement
|
|
13
13
|
* starts still does, and a statement being drained after its cancel stays cancelled whatever the next one's number.
|
|
14
14
|
* Dependency-free: the listener's thread loads it without DuckDB.
|
|
15
|
+
*
|
|
16
|
+
* ONE CELL MORE, THE WORKER'S BEAT: a statement that calls a Mason function holds the worker's event loop for a thousand
|
|
17
|
+
* calls at a time (minutes over wide documents), so no message can tell whether its JavaScript runs. Every call, and every
|
|
18
|
+
* row of a call, adds to the cell, and the executor's watch reads it (src/duck/executor.ts SILENT_MS).
|
|
15
19
|
*/
|
|
16
20
|
/** how many wire sessions may hold a DuckDB connection at once (a slot each) */
|
|
17
21
|
export const CANCEL_SLOTS = 4096;
|
|
18
22
|
/** a fresh table (the executor makes one and hands it to its worker) */
|
|
19
|
-
export function cancelTable() { return new Int32Array(new SharedArrayBuffer(CANCEL_SLOTS * 2 * 4)); }
|
|
23
|
+
export function cancelTable() { return new Int32Array(new SharedArrayBuffer((CANCEL_SLOTS * 2 + 1) * 4)); }
|
|
20
24
|
const cancelledAt = (slot) => slot * 2;
|
|
21
25
|
const runningAt = (slot) => slot * 2 + 1;
|
|
26
|
+
const BEAT_AT = CANCEL_SLOTS * 2;
|
|
27
|
+
/** the worker's JavaScript is running a Mason function (the cell is only ever compared, so its wrap is nothing) */
|
|
28
|
+
export function beat(t) { Atomics.add(t, BEAT_AT, 1); }
|
|
29
|
+
/** the beat as it stands: what the watch compares with its last look */
|
|
30
|
+
export function beats(t) { return Atomics.load(t, BEAT_AT); }
|
|
22
31
|
/** a slot handed to a new session: nothing cancelled, nothing running */
|
|
23
32
|
export function resetSlot(t, slot) { Atomics.store(t, cancelledAt(slot), 0); Atomics.store(t, runningAt(slot), 0); }
|
|
24
33
|
/** statement `seq` of the session in `slot` is cancelled (and every one before it): monotonic, from either thread */
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { MASON_SCHEMA } from "./store.js";
|
|
2
|
+
import { Batches, History, Streak } from "./history.js";
|
|
3
|
+
export const EVENTS_TABLE = `${MASON_SCHEMA}.sync_events`;
|
|
4
|
+
export const EVENTS_DDL = `CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (happened_at TIMESTAMPTZ, seq BIGINT, kind VARCHAR, table_name VARCHAR, message VARCHAR)`;
|
|
5
|
+
/**
|
|
6
|
+
* WHAT AN EVENT IS, one word each: `copy_failed`, `poll_failed`, `catch_up_failed` (the one poll that brings a new copy
|
|
7
|
+
* current), `stream_failed` (the stream stopped, or refused a table: `table` says which), `source_unreachable` (the
|
|
8
|
+
* database could not be attached), `restart` (the supervisor started the run again after it died) — each a failure;
|
|
9
|
+
* `copied` (a copy finished and was swapped in), `recovered` (a table whose poll, catch-up or stream failed is
|
|
10
|
+
* current again; of no table, the stream applying changes or the database attached again after it failed) and
|
|
11
|
+
* `synced` (a sync asked by hand — mason sync, the page's Sync now — and what it did), and `sync_changed` (a table's
|
|
12
|
+
* `sync:` in the sources file changed while it is copied: from what, to what, and how its copy is caught up). A scheduled poll that succeeds
|
|
13
|
+
* is no event: one every few seconds would bury the failures; its last result is the table's last sync.
|
|
14
|
+
*/
|
|
15
|
+
export const EVENT_KINDS = ["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "restart", "copied", "recovered", "synced", "sync_changed"];
|
|
16
|
+
const FAILURES = new Set(["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "restart"]);
|
|
17
|
+
/** whether an event of this kind is a failure: the one place that says it (the reader's filter, `failed`) */
|
|
18
|
+
export const isFailure = (kind) => FAILURES.has(kind);
|
|
19
|
+
const FLUSH_MS = 1000;
|
|
20
|
+
const BATCH_ROWS = 500;
|
|
21
|
+
/** a failure said word for word again within this is the event already written (unless it was over between: `record`) */
|
|
22
|
+
export const REPEAT_MS = 60 * 60 * 1000;
|
|
23
|
+
export class SyncEvents {
|
|
24
|
+
next = 0;
|
|
25
|
+
lost = new Streak();
|
|
26
|
+
closed = false;
|
|
27
|
+
/** each failure's last event, by kind and table: a repeat within REPEAT_MS is not written again */
|
|
28
|
+
lastFailure = new Map();
|
|
29
|
+
history;
|
|
30
|
+
batches;
|
|
31
|
+
log;
|
|
32
|
+
options;
|
|
33
|
+
days;
|
|
34
|
+
constructor(history, log, options) {
|
|
35
|
+
this.history = history;
|
|
36
|
+
this.log = log;
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.days = options.days;
|
|
39
|
+
this.batches = new Batches({ rows: BATCH_ROWS, ms: options.flushMs ?? FLUSH_MS, write: (rows) => this.write(rows) });
|
|
40
|
+
}
|
|
41
|
+
/** The events of a store: their table made, the events past the retention deleted, and a trim every hour. */
|
|
42
|
+
static async open(store, log, options) {
|
|
43
|
+
const history = await History.open(store, [EVENTS_DDL]);
|
|
44
|
+
const events = new SyncEvents(history, log, options);
|
|
45
|
+
events.next = Number((await history.reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${EVENTS_TABLE}`)).getRowsJS()[0][0]);
|
|
46
|
+
await history.keepTrimmed(() => events.trim(), (e) => log(`sync events: deleting the events older than ${events.days} days failed: ${e.message}`));
|
|
47
|
+
return events;
|
|
48
|
+
}
|
|
49
|
+
now() { return this.options.now?.() ?? Date.now(); }
|
|
50
|
+
/** One event, written with the next batch. Never throws: the sync that records it goes on whatever happens here. */
|
|
51
|
+
record(e) {
|
|
52
|
+
if (this.closed) {
|
|
53
|
+
this.log(`sync events: an event after they closed, not recorded (${e.kind}${e.table ? ` ${e.table}` : ""}: ${e.message.split("\n")[0]})`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const now = this.now();
|
|
57
|
+
// a failure over (its table current again or copied anew; of no table, the whole sync's) is said again when it recurs
|
|
58
|
+
if (e.kind === "recovered" || e.kind === "copied") {
|
|
59
|
+
for (const k of [...this.lastFailure.keys()])
|
|
60
|
+
if (k.endsWith(`\0${e.table ?? ""}`))
|
|
61
|
+
this.lastFailure.delete(k);
|
|
62
|
+
}
|
|
63
|
+
else if (isFailure(e.kind)) {
|
|
64
|
+
const key = `${e.kind}\0${e.table ?? ""}`;
|
|
65
|
+
const last = this.lastFailure.get(key);
|
|
66
|
+
if (last !== undefined && last.message === e.message && now - last.at < REPEAT_MS)
|
|
67
|
+
return;
|
|
68
|
+
this.lastFailure.set(key, { message: e.message, at: now });
|
|
69
|
+
}
|
|
70
|
+
this.batches.push({ at: new Date(now), seq: this.next++, e });
|
|
71
|
+
}
|
|
72
|
+
/** Writes what is waiting, after any flush in flight. Resolves whether it was written or not (a failure is said). */
|
|
73
|
+
flush() { return this.batches.flush(); }
|
|
74
|
+
async write(rows) {
|
|
75
|
+
const sql = `INSERT INTO ${EVENTS_TABLE} (happened_at, seq, kind, table_name, message) VALUES ` +
|
|
76
|
+
rows.map((_, i) => `($${i * 5 + 1}::TIMESTAMPTZ, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${i * 5 + 5})`).join(", ");
|
|
77
|
+
const values = rows.flatMap((r) => [r.at.toISOString(), r.seq, r.e.kind, r.e.table, r.e.message]);
|
|
78
|
+
try {
|
|
79
|
+
if (this.options.insert !== undefined)
|
|
80
|
+
await this.options.insert(sql, values);
|
|
81
|
+
else
|
|
82
|
+
await this.history.writer.run(sql, values);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
// Tolerated, narrowly, as the request log's: the sync's own state and log line hold the event, and a record that
|
|
86
|
+
// cannot be written must not stop the sync. SAID, once per streak with what it cost
|
|
87
|
+
this.lost.failed(rows.length, () => this.log(`sync events: could not record ${rows.length} event(s) in ${EVENTS_TABLE} (${rows[0].e.kind}${rows[0].e.table ? ` ${rows[0].e.table}` : ""}): ` +
|
|
88
|
+
`${e.message}; said once until a write succeeds (the log has each one)`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.lost.succeeded((n) => this.log(`sync events: recording again; ${n} event(s) were not recorded (the log has them)`));
|
|
92
|
+
}
|
|
93
|
+
/** Deletes the events older than the retention; the count is said when there were any. */
|
|
94
|
+
async trim() {
|
|
95
|
+
const r = await this.history.writer.runAndReadAll(`DELETE FROM ${EVENTS_TABLE} WHERE happened_at < now() - INTERVAL (${this.days}) DAY`);
|
|
96
|
+
const n = Number(r.getRowsJS()[0]?.[0] ?? 0);
|
|
97
|
+
if (n > 0)
|
|
98
|
+
this.log(`sync events: deleted ${n} event(s) older than ${this.days} days (MASON_HISTORY_DAYS)`);
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
/** The newest `limit` events, newest LAST; `since`: at or after it; `failed`: the failures only. Committed ones only. */
|
|
102
|
+
async recent(o) {
|
|
103
|
+
const where = [];
|
|
104
|
+
const values = [o.limit];
|
|
105
|
+
if (o.failed)
|
|
106
|
+
where.push(`kind IN (${[...FAILURES].map((k) => `'${k}'`).join(", ")})`);
|
|
107
|
+
if (o.since !== undefined) {
|
|
108
|
+
values.push(o.since);
|
|
109
|
+
where.push(`happened_at >= $${values.length}::TIMESTAMPTZ`);
|
|
110
|
+
}
|
|
111
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM (SELECT epoch_ms(happened_at) AS ms, seq, kind, table_name, message FROM ${EVENTS_TABLE}` +
|
|
112
|
+
`${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY happened_at DESC, seq DESC LIMIT $1) ORDER BY ms, seq`, values)).getRowsJS();
|
|
113
|
+
return rows.map(([ms, , kind, table, message]) => eventOf(new Date(Number(ms)).toISOString(), kind, table, message));
|
|
114
|
+
}
|
|
115
|
+
/** Writes what is waiting and closes the events' connections (mason stop). */
|
|
116
|
+
async close() {
|
|
117
|
+
this.history.stopTrimming();
|
|
118
|
+
await this.flush();
|
|
119
|
+
this.closed = true;
|
|
120
|
+
this.history.closeConnections();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const eventOf = (at, kind, table, message) => ({ at, kind, failed: isFailure(kind), table, message });
|
|
124
|
+
/**
|
|
125
|
+
* THE EVENTS WITH THE RUN'S RESTARTS (src/cli/supervise.ts `.mason/restarts.json`), as ONE list newest last, asked as
|
|
126
|
+
* `recent` is (the newest `limit`, since, the failures only — a restart is one), within the retention as the events are.
|
|
127
|
+
*/
|
|
128
|
+
export function withRestarts(events, restarts, o) {
|
|
129
|
+
const from = Math.max(o.since === undefined ? -Infinity : Date.parse(o.since), (o.now ?? Date.now()) - o.days * 86_400_000);
|
|
130
|
+
const said = restarts.filter((r) => Date.parse(r.at) >= from)
|
|
131
|
+
.map((r) => eventOf(new Date(r.at).toISOString(), "restart", null, `the run stopped (${r.reason}) after ${r.upSeconds} s up, and its supervisor started it again`));
|
|
132
|
+
// a stable sort: an event and a restart of one millisecond keep the events' order, the restart after
|
|
133
|
+
return [...events, ...said].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).slice(-o.limit);
|
|
134
|
+
}
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* — each with its id, and says its log lines as messages.
|
|
7
7
|
*
|
|
8
8
|
* THIS thread never waits for the executor (src/duck/store.ts "HOW A STATEMENT IS CANCELLED"): a result or a
|
|
9
|
-
* connection is closed only once DuckDB
|
|
9
|
+
* connection is closed only once DuckDB ended what ran on it. A request is answered asynchronously, so the turns between
|
|
10
10
|
* a statement's function calls serve the others; a statement's cancel does not need one (the cancel table,
|
|
11
11
|
* src/duck/cancel.ts).
|
|
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";
|
|
@@ -48,7 +48,7 @@ const abortWith = (c, reason) => {
|
|
|
48
48
|
/** a connection's call, held in `connCalls` until it settles */
|
|
49
49
|
function onConn(n, f) {
|
|
50
50
|
if (stopping)
|
|
51
|
-
return Promise.reject(new Error("the
|
|
51
|
+
return Promise.reject(new Error("the engine is closing: no statement starts on it any more"));
|
|
52
52
|
const p = f(need(conns, n, "connection"));
|
|
53
53
|
const calls = connCalls.get(n) ?? new Set();
|
|
54
54
|
connCalls.set(n, calls);
|
|
@@ -71,7 +71,7 @@ function closeRunner(n) {
|
|
|
71
71
|
const need = (m, n, what) => {
|
|
72
72
|
const v = m.get(n);
|
|
73
73
|
if (v === undefined)
|
|
74
|
-
throw new Error(`no ${what} ${n} on the
|
|
74
|
+
throw new Error(`no ${what} ${n} on the engine's thread (Mason bug: report it)`);
|
|
75
75
|
return v;
|
|
76
76
|
};
|
|
77
77
|
async function answer(m) {
|
|
@@ -87,7 +87,7 @@ async function answer(m) {
|
|
|
87
87
|
}
|
|
88
88
|
case "connect": {
|
|
89
89
|
if (instance === null)
|
|
90
|
-
throw new Error("the
|
|
90
|
+
throw new Error("the engine is not open (Mason bug: report it)");
|
|
91
91
|
const n = nextConn++;
|
|
92
92
|
conns.set(n, await instance.connect());
|
|
93
93
|
return { value: n };
|
|
@@ -114,13 +114,22 @@ async function answer(m) {
|
|
|
114
114
|
conns.get(Number(m.conn))?.interrupt();
|
|
115
115
|
return undefined;
|
|
116
116
|
case "conn.close": {
|
|
117
|
-
const
|
|
118
|
-
|
|
117
|
+
const n = Number(m.conn);
|
|
118
|
+
// NEVER CLOSED UNDER A CALL OF ITS OWN: the close is synchronous here and waits for that call's tasks, and a task
|
|
119
|
+
// inside a Mason function waits for this thread. The close says nobody wants the call's answer, so it is
|
|
120
|
+
// interrupted, and its failure (a call's own promise, handled by whoever asked) waited for off this thread
|
|
121
|
+
const calls = [...(connCalls.get(n) ?? [])];
|
|
122
|
+
if (calls.length > 0) {
|
|
123
|
+
conns.get(n)?.interrupt();
|
|
124
|
+
await Promise.allSettled(calls);
|
|
125
|
+
}
|
|
126
|
+
const c = conns.get(n);
|
|
127
|
+
conns.delete(n);
|
|
119
128
|
try {
|
|
120
129
|
c?.closeSync();
|
|
121
130
|
}
|
|
122
131
|
catch (e) {
|
|
123
|
-
log(`closing a connection of the
|
|
132
|
+
log(`closing a connection of the engine failed: ${e.message}`);
|
|
124
133
|
}
|
|
125
134
|
return undefined;
|
|
126
135
|
}
|
|
@@ -128,9 +137,10 @@ async function answer(m) {
|
|
|
128
137
|
registerLibcOrder(need(conns, 0, "connection"), m.order);
|
|
129
138
|
return { value: null };
|
|
130
139
|
case "calls": return { value: Object.fromEntries(FUNCTION_CALLS) };
|
|
140
|
+
case "function rows": return { value: Object.fromEntries(FUNCTION_ROWS) };
|
|
131
141
|
case "runner.open": {
|
|
132
142
|
if (instance === null)
|
|
133
|
-
throw new Error("the
|
|
143
|
+
throw new Error("the engine is not open (Mason bug: report it)");
|
|
134
144
|
const n = nextRunner++;
|
|
135
145
|
runners.set(n, await SessionRunner.open(instance, log, Number(m.slot), cancels));
|
|
136
146
|
return { value: n };
|
|
@@ -188,10 +198,14 @@ async function answer(m) {
|
|
|
188
198
|
return { value: { bytes, ends }, transfer: [bytes] };
|
|
189
199
|
}
|
|
190
200
|
case "answer.close": {
|
|
201
|
+
// answered once DuckDB handed the answer back (its drain, src/duck/store.ts): until then the main thread holds the
|
|
202
|
+
// process open, which must never end while DuckDB calls into this thread (src/duck/executor.ts "NOTHING ENDS")
|
|
191
203
|
const k = key(Number(m.runner), Number(m.seq));
|
|
192
204
|
statements.get(k)?.answer?.close();
|
|
193
205
|
statements.delete(k);
|
|
194
|
-
|
|
206
|
+
await (runners.get(Number(m.runner)) ?? null)?.idle();
|
|
207
|
+
await closing.get(Number(m.runner));
|
|
208
|
+
return { value: null };
|
|
195
209
|
}
|
|
196
210
|
case "runner.explain": {
|
|
197
211
|
const runner = need(runners, Number(m.runner), "session");
|
|
@@ -241,7 +255,7 @@ async function answer(m) {
|
|
|
241
255
|
return { value: null };
|
|
242
256
|
}
|
|
243
257
|
}
|
|
244
|
-
throw new Error(`the
|
|
258
|
+
throw new Error(`the engine's thread was asked ${JSON.stringify(m.op)}, which it does not know (Mason bug: report it)`);
|
|
245
259
|
}
|
|
246
260
|
port.on("message", (m) => {
|
|
247
261
|
// the executor's watch (src/duck/executor.ts SILENT_MS): answered at once, by a thread whose loop turns
|