@mason-data/cli 0.7.0 → 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 +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- 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 +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- 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 +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- 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 +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- 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 +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- 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 +3 -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/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- 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/cli/version.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* What this Mason is, and where that was read (what a person running it can act on follows from it: `mason serve`'s pin
|
|
3
|
+
* says the image to run, or the npm line): the package's version when installed; else the image's (`MASON_VERSION`,
|
|
4
|
+
* stamped at build by images-publish.yml: `v0.6.1` for a release, said `0.6.1` as the package says it, or `main-<sha>`);
|
|
5
|
+
* a checkout runs as "dev".
|
|
5
6
|
*/
|
|
6
7
|
import * as fs from "node:fs";
|
|
7
8
|
import * as path from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
export function
|
|
10
|
+
export function runningVersion() {
|
|
10
11
|
// src/cli/ in a checkout (the repo's package.json, which is not this one), dist/src/cli/ in the package
|
|
11
12
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
for (const p of [path.join(here, "..", "..", "package.json"), path.join(here, "..", "..", "..", "package.json")]) {
|
|
@@ -14,10 +15,11 @@ export function version() {
|
|
|
14
15
|
continue;
|
|
15
16
|
const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
16
17
|
if (pkg.name === "@mason-data/cli" && pkg.version)
|
|
17
|
-
return pkg.version;
|
|
18
|
+
return { text: pkg.version, from: "package" };
|
|
18
19
|
}
|
|
19
20
|
const image = process.env.MASON_VERSION?.trim();
|
|
20
21
|
if (image)
|
|
21
|
-
return /^v\d+\.\d+\.\d+$/.test(image) ? image.slice(1) : image;
|
|
22
|
-
return "dev";
|
|
22
|
+
return { text: /^v\d+\.\d+\.\d+$/.test(image) ? image.slice(1) : image, from: "image" };
|
|
23
|
+
return { text: "dev", from: "checkout" };
|
|
23
24
|
}
|
|
25
|
+
export const version = () => runningVersion().text;
|
package/dist/src/demo/seed.js
CHANGED
|
@@ -45,6 +45,9 @@ export const DEMO_TABLES = ["customers", "products", "orders", "order_items"];
|
|
|
45
45
|
export const DEMO_TABLE_TO_ADD = "order_items";
|
|
46
46
|
/** the tables `mason init demo` writes to the sources file: the database's, but the one left to add */
|
|
47
47
|
export const DEMO_ADDED_TABLES = DEMO_TABLES.filter((t) => t !== DEMO_TABLE_TO_ADD);
|
|
48
|
+
/** how the demo's tables are kept current (the sources file's top `sync:`, src/lib/table-sync.ts): polled every 5 s,
|
|
49
|
+
* so the demo shows a copy keeping up with its writer — where a project `mason init` makes is `off` */
|
|
50
|
+
export const DEMO_SYNC = { mode: "poll", every: "5s", seconds: 5 };
|
|
48
51
|
/** an order's status moves along this path (`changeSql`); `cancelled` is a side exit */
|
|
49
52
|
export const ORDER_STATUSES = ["pending", "paid", "shipped", "delivered"];
|
|
50
53
|
const ROWS_PER_INSERT = 1_000;
|
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,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,7 +6,7 @@
|
|
|
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
|
*/
|
|
@@ -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
|
}
|
|
@@ -131,7 +140,7 @@ async function answer(m) {
|
|
|
131
140
|
case "function rows": return { value: Object.fromEntries(FUNCTION_ROWS) };
|
|
132
141
|
case "runner.open": {
|
|
133
142
|
if (instance === null)
|
|
134
|
-
throw new Error("the
|
|
143
|
+
throw new Error("the engine is not open (Mason bug: report it)");
|
|
135
144
|
const n = nextRunner++;
|
|
136
145
|
runners.set(n, await SessionRunner.open(instance, log, Number(m.slot), cancels));
|
|
137
146
|
return { value: n };
|
|
@@ -246,7 +255,7 @@ async function answer(m) {
|
|
|
246
255
|
return { value: null };
|
|
247
256
|
}
|
|
248
257
|
}
|
|
249
|
-
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)`);
|
|
250
259
|
}
|
|
251
260
|
port.on("message", (m) => {
|
|
252
261
|
// the executor's watch (src/duck/executor.ts SILENT_MS): answered at once, by a thread whose loop turns
|
|
@@ -38,7 +38,7 @@ import { runnable } from "../lib/runnable.js";
|
|
|
38
38
|
import { StatementError } from "../api/postgres/server.js";
|
|
39
39
|
import { EncodeError } from "../api/postgres/encode.js";
|
|
40
40
|
import { NumericError } from "../lib/pg-numeric.js";
|
|
41
|
-
import { CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
|
|
41
|
+
import { beats, CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
|
|
42
42
|
// a cycle (store.ts opens the executor): read at run time only
|
|
43
43
|
import { failureOf } from "./store.js";
|
|
44
44
|
/** A DuckDB type from its `toJson()` (the worker's), as `@duckdb/node-api`'s own classes. */
|
|
@@ -59,7 +59,7 @@ export function typeFromJson(j) {
|
|
|
59
59
|
}
|
|
60
60
|
const simple = SIMPLE_TYPES.get(id);
|
|
61
61
|
if (simple === undefined)
|
|
62
|
-
throw new Error(`a
|
|
62
|
+
throw new Error(`a type of the engine's that Mason cannot carry between threads: ${JSON.stringify(j)} (Mason bug: report it)`);
|
|
63
63
|
return simple(alias);
|
|
64
64
|
}
|
|
65
65
|
/** every type of no parameters, by its id: each class's `create(alias)` */
|
|
@@ -112,11 +112,11 @@ export function valueFromClone(v, t) {
|
|
|
112
112
|
}
|
|
113
113
|
export function wireError(e) {
|
|
114
114
|
if (e instanceof StatementError)
|
|
115
|
-
return { kind: "statement", fields: e.fields, sql: e.sql };
|
|
115
|
+
return { kind: "statement", fields: e.fields, sql: e.sql, gap: e.gap };
|
|
116
116
|
if (e instanceof EncodeError)
|
|
117
|
-
return { kind: "encode", message: e.message, code: e.code };
|
|
117
|
+
return { kind: "encode", message: e.message, code: e.code, gap: e.gap };
|
|
118
118
|
if (e instanceof NumericError)
|
|
119
|
-
return { kind: "numeric", message: e.message, sqlstate: e.sqlstate };
|
|
119
|
+
return { kind: "numeric", message: e.message, sqlstate: e.sqlstate, gap: e.gap };
|
|
120
120
|
return { kind: "error", message: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : "Error" };
|
|
121
121
|
}
|
|
122
122
|
/** the worker is gone: what every request in flight, and every later one, is answered. `stopped`: by the run's own stop
|
|
@@ -131,11 +131,11 @@ export const STOPPING = { code: "57P01", message: "terminating connection due to
|
|
|
131
131
|
detail: "Mason is stopping (mason stop, or a shutdown)", hint: "connect again once Mason runs again (mason status says whether it does)" };
|
|
132
132
|
const errorOf = (e) => {
|
|
133
133
|
if (e.kind === "statement")
|
|
134
|
-
return new StatementError(e.fields, e.sql);
|
|
134
|
+
return new StatementError(e.fields, e.sql, e.gap);
|
|
135
135
|
if (e.kind === "encode")
|
|
136
|
-
return new EncodeError(e.message, e.code);
|
|
136
|
+
return new EncodeError(e.message, e.code, e.gap);
|
|
137
137
|
if (e.kind === "numeric")
|
|
138
|
-
return new NumericError(e.message, e.sqlstate);
|
|
138
|
+
return new NumericError(e.message, e.sqlstate, e.gap);
|
|
139
139
|
const err = new Error(e.message);
|
|
140
140
|
err.name = e.name;
|
|
141
141
|
return err;
|
|
@@ -148,7 +148,7 @@ function plainValues(values) {
|
|
|
148
148
|
if (v === null || typeof v !== "object" || Array.isArray(v) || ArrayBuffer.isView(v) || v instanceof Date)
|
|
149
149
|
continue;
|
|
150
150
|
if (Object.getPrototypeOf(v) !== Object.prototype)
|
|
151
|
-
throw new Error(`a ${v.constructor.name} cannot be sent to
|
|
151
|
+
throw new Error(`a ${v.constructor.name} cannot be sent to the engine's thread: pass its text or a plain value (Mason bug: report it)`);
|
|
152
152
|
}
|
|
153
153
|
return [...values];
|
|
154
154
|
}
|
|
@@ -156,12 +156,17 @@ function plainValues(values) {
|
|
|
156
156
|
* EVERY CALL TO THE WORKER SETTLES (under load the worker deadlocked inside DuckDB, src/duck/store.ts
|
|
157
157
|
* `cleanUp`, and every request to it — the session's statements, the store's close, the test's after-hook — waited for
|
|
158
158
|
* ever, 0 % CPU). Two bounds:
|
|
159
|
-
* - THE WORKER MUST BE HEARD: while a request is pending the executor pings it every `PING_MS`, and a worker
|
|
160
|
-
*
|
|
161
|
-
* later is told 57P02, and the run is told (`onCrash`), as for a crash.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
159
|
+
* - THE WORKER MUST BE HEARD: while a request is pending the executor pings it every `PING_MS`, and a worker of which
|
|
160
|
+
* nothing was heard (no answer, no log line, no pong, no beat) for `SILENT_MS` is declared stopped — every request in
|
|
161
|
+
* flight and later is told 57P02, and the run is told (`onCrash`), as for a crash. THE TIME IS THIS THREAD'S OWN, its
|
|
162
|
+
* ticks counted and never the clock's: a main thread blocked or a process frozen past the bound (a test's spawnSync, a
|
|
163
|
+
* SIGSTOP, a paused container) counted against a healthy worker, whose answer sat unread in the port when the tick
|
|
164
|
+
* fired (docs/DESIGN.md "The watch"). THE BEAT (src/duck/cancel.ts) is the worker's JavaScript running with no turn of
|
|
165
|
+
* its loop: a statement calling a Mason function holds that loop for a thousand calls at a time, 999 × 322 ms over
|
|
166
|
+
* 20 kB documents, and such a statement ran 1,151 s without a pong and answered. What is left is a thread that neither
|
|
167
|
+
* turned its loop nor ran a row of a Mason function for 60 s: blocked inside the engine, since nothing on it waits for
|
|
168
|
+
* the engine's tasks any more (src/duck/store.ts `cleanUp`), or spinning. A deadlock is for ever, so a longer bound
|
|
169
|
+
* only says it later.
|
|
165
170
|
* - THE CLOSE IS BOUNDED: `close` gives the worker's shutdown `CLOSE_GRACE_MS` (a calling statement stops at its next
|
|
166
171
|
* call, milliseconds; a call-free one at DuckDB's interrupt; and the whole stop must fit inside a platform's 10 s
|
|
167
172
|
* before SIGKILL and `mason stop`'s 15 s), then declares it stopped the same way and resolves; the thread is ended
|
|
@@ -198,8 +203,10 @@ export class Executor {
|
|
|
198
203
|
/** the cancel table, shared with the worker (src/duck/cancel.ts), and its free slots */
|
|
199
204
|
cancels = cancelTable();
|
|
200
205
|
free = Array.from({ length: CANCEL_SLOTS }, (_, i) => CANCEL_SLOTS - 1 - i);
|
|
201
|
-
/**
|
|
202
|
-
|
|
206
|
+
/** pings sent since the worker last said anything or beat: the watch's count, of this thread's own ticks */
|
|
207
|
+
unanswered = 0;
|
|
208
|
+
/** the worker's beat at the watch's last look */
|
|
209
|
+
beat = 0;
|
|
203
210
|
watch;
|
|
204
211
|
silentMs;
|
|
205
212
|
/** why the worker was given up on without exiting (`close`'s bound, the silence), once it was: its instance may
|
|
@@ -218,7 +225,7 @@ export class Executor {
|
|
|
218
225
|
// a request is in flight (`call`)
|
|
219
226
|
this.worker.unref();
|
|
220
227
|
this.worker.on("message", (m) => {
|
|
221
|
-
this.
|
|
228
|
+
this.unanswered = 0;
|
|
222
229
|
if ("pong" in m)
|
|
223
230
|
return;
|
|
224
231
|
if ("log" in m) {
|
|
@@ -227,7 +234,7 @@ export class Executor {
|
|
|
227
234
|
}
|
|
228
235
|
if (m.id === this.shutdownId) {
|
|
229
236
|
if (!m.ok)
|
|
230
|
-
this.log(`closing the
|
|
237
|
+
this.log(`closing the engine failed: ${errorOf(m.error).message}`);
|
|
231
238
|
this.shutdownAnswered();
|
|
232
239
|
return;
|
|
233
240
|
}
|
|
@@ -250,18 +257,24 @@ export class Executor {
|
|
|
250
257
|
this.shutdownAnswered();
|
|
251
258
|
resolve();
|
|
252
259
|
}));
|
|
253
|
-
// the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer
|
|
260
|
+
// the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer — and never
|
|
261
|
+
// sooner than the bound after its first ping, whatever the moment the request came: the count is checked, then raised
|
|
262
|
+
const tick = Math.min(PING_MS, this.silentMs / 4);
|
|
263
|
+
const bound = Math.ceil(this.silentMs / tick);
|
|
254
264
|
this.watch = setInterval(() => {
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
const beat = beats(this.cancels);
|
|
266
|
+
if (this.gone !== null || this.pending.size === 0 || beat !== this.beat) {
|
|
267
|
+
this.beat = beat;
|
|
268
|
+
this.unanswered = 0;
|
|
257
269
|
return;
|
|
258
270
|
}
|
|
259
|
-
if (
|
|
260
|
-
this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s with ${this.pending.size} request(s) waiting (stuck inside
|
|
271
|
+
if (this.unanswered >= bound) {
|
|
272
|
+
this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s and ran no Mason function, with ${this.pending.size} request(s) waiting (stuck inside the engine: Mason bug, report it with mason logs)`);
|
|
261
273
|
return;
|
|
262
274
|
}
|
|
275
|
+
this.unanswered++;
|
|
263
276
|
this.worker.postMessage({ op: "ping" });
|
|
264
|
-
},
|
|
277
|
+
}, tick);
|
|
265
278
|
this.watch.unref();
|
|
266
279
|
}
|
|
267
280
|
/** the worker given up on without its exit: every request told so (57P02), the run told, and its shutdown asked —
|
|
@@ -290,7 +303,7 @@ export class Executor {
|
|
|
290
303
|
}
|
|
291
304
|
void this.shutdown.then(async () => { if (!this.hasExited)
|
|
292
305
|
await this.worker.terminate(); })
|
|
293
|
-
.catch((e) => this.log(`ending the
|
|
306
|
+
.catch((e) => this.log(`ending the engine's thread failed: ${e.message}`));
|
|
294
307
|
return this.shutdown;
|
|
295
308
|
}
|
|
296
309
|
static async start(o) {
|
|
@@ -317,7 +330,7 @@ export class Executor {
|
|
|
317
330
|
p.reject(err);
|
|
318
331
|
}
|
|
319
332
|
if (!stopped) {
|
|
320
|
-
this.log(`the
|
|
333
|
+
this.log(`the engine stopped: ${reason}`);
|
|
321
334
|
if (!this.closing)
|
|
322
335
|
for (const f of this.crashed)
|
|
323
336
|
f(reason);
|
|
@@ -366,7 +379,7 @@ export class Executor {
|
|
|
366
379
|
async runner() {
|
|
367
380
|
const slot = this.free.pop();
|
|
368
381
|
if (slot === undefined)
|
|
369
|
-
throw new StatementError({ code: "53300", message: `too many sessions are reading the
|
|
382
|
+
throw new StatementError({ code: "53300", message: `too many sessions are reading the synced data at once (${CANCEL_SLOTS})`, hint: "close idle connections (a pool's size), then connect again" });
|
|
370
383
|
resetSlot(this.cancels, slot);
|
|
371
384
|
try {
|
|
372
385
|
return new RemoteRunner(this, await this.call({ op: "runner.open", slot }), slot);
|
|
@@ -393,7 +406,7 @@ export class Executor {
|
|
|
393
406
|
const closed = (async () => { await this.shutDown(); await this.exited; return "closed"; })();
|
|
394
407
|
try {
|
|
395
408
|
if (await Promise.race([closed, late]) === "late") {
|
|
396
|
-
this.log(`the
|
|
409
|
+
this.log(`the engine's thread did not close within ${graceMs / 1000} s: given up on (its sessions are told 57P02); it ends once its shutdown finishes, and the process waits for it (a thread stuck inside the engine needs a SIGKILL)`);
|
|
397
410
|
this.abandon(`it did not close within ${graceMs / 1000} s`);
|
|
398
411
|
}
|
|
399
412
|
}
|
|
@@ -437,7 +450,7 @@ const reasonOf = (signal) => !signal.aborted ? null : signal.reason instanceof S
|
|
|
437
450
|
function goneError(e, sql) {
|
|
438
451
|
if (e.stopped)
|
|
439
452
|
return new StatementError(STOPPING, sql);
|
|
440
|
-
return new StatementError({ code: "57P02", message: "terminating connection because the
|
|
453
|
+
return new StatementError({ code: "57P02", message: "terminating connection because the engine stopped", detail: e.message,
|
|
441
454
|
hint: "Mason starts again by itself: reconnect in a few seconds and repeat the statement (mason status says when it is back)" }, sql);
|
|
442
455
|
}
|
|
443
456
|
/**
|