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