@mason-data/cli 0.0.0-stage → 0.6.2
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 +209 -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 +442 -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 +1670 -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 +482 -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 +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -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 +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -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 +388 -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 +296 -0
- package/dist/src/cli/sources.js +562 -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/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -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 +338 -0
- package/dist/src/duck/plan.js +246 -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 +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -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 +995 -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 +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DUCKDB STORE (the prototype, docs/DESIGN.md decision B): ONE DuckDB instance inside the `mason start`
|
|
3
|
+
* run, file-backed at `.mason/duck.db` so the copy survives `mason stop` / `start` (`mason destroy` deletes it). DuckDB
|
|
4
|
+
* allows one writing PROCESS per file, so everything that writes — the copy, the poll — and every wire session run in
|
|
5
|
+
* this one process: the sync on the store's own `writer` connection, each Postgres session on a connection of its own
|
|
6
|
+
* (`runner`), so a session's `SET TimeZone` and search_path are its own, as on Postgres.
|
|
7
|
+
*
|
|
8
|
+
* The instance and every connection live on the EXECUTOR WORKER (src/duck/executor.ts), the thread the
|
|
9
|
+
* Mason functions are called on; `DuckStore` is the main thread's handle on it (`writer` and `instance.connect()` post
|
|
10
|
+
* their statements there), and `SessionRunner` below runs on the worker (src/duck/executor-worker.ts).
|
|
11
|
+
*
|
|
12
|
+
* A copied table lives under the source's own name, `<schema>.<table>` (decision C): a tool's SQL reads `sales.orders`
|
|
13
|
+
* and DuckDB has `sales.orders`. Mason's own bookkeeping is the schema `_mason`, which the Postgres catalog never lists.
|
|
14
|
+
*
|
|
15
|
+
* JSON and ICU (time zones) are built into DuckDB 2.0; the `postgres` extension the copy needs is loaded by the sync
|
|
16
|
+
* (src/duck/sync.ts attach), so the store itself opens offline.
|
|
17
|
+
*/
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
20
|
+
import { DuckDBInstance, version } from "@duckdb/node-api";
|
|
21
|
+
import { Executor } from "./executor.js";
|
|
22
|
+
import { beginStatement, cancelStatement, slotName, statementCancelled } from "./cancel.js";
|
|
23
|
+
import { StatementError } from "../api/postgres/server.js";
|
|
24
|
+
import { duckParameters, missingRelationHint, planDuck, quoteDuckKeywords } from "./plan.js";
|
|
25
|
+
import { VERIFIED } from "./verified.js";
|
|
26
|
+
import { msSinceCall, registerFunctions, SESSION_VARIABLE } from "./functions.js";
|
|
27
|
+
import { duckCollation } from "./rewrite.js";
|
|
28
|
+
import { decodeLibcOrder, encodeLibcOrder } from "./libc-order.js";
|
|
29
|
+
import { dataRow } from "../api/postgres/protocol.js";
|
|
30
|
+
import { cellText, exactColumn, pgTypedColumn, wireColumn } from "./types.js";
|
|
31
|
+
import { gapMark } from "./gaps.js";
|
|
32
|
+
import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
|
|
33
|
+
import { explainJson, explainText, planNodes, scannedTables } from "./explain.js";
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
export const MASON_SCHEMA = "_mason";
|
|
37
|
+
/** the DuckDB the prototype runs (`@duckdb/node-api`'s bundled library) */
|
|
38
|
+
export const DUCKDB_VERSION = version();
|
|
39
|
+
/**
|
|
40
|
+
* THE INSTANCE'S SETTINGS. `late_materialization_max_rows`: a sorted LIMIT of up to this many rows sorts only its keys
|
|
41
|
+
* and the row ids, and reads the other columns for the winners alone. DuckDB's default, 50, is below every BI tool's
|
|
42
|
+
* preview (Postico's click on a table is `ORDER BY "id" ASC LIMIT 1000`, pgAdmin's 1,000, Metabase's 2,000), which
|
|
43
|
+
* then sorted EVERY column of every row: 2.4 s for 23 M rows, 32 s for a wide table, where Postgres reads 1,000 rows of
|
|
44
|
+
* the primary key's index. 10,000 took the first to 36 ms; at 100,000 rows the fetch by row id stops
|
|
45
|
+
* paying (measured even or slower over 5 M narrow rows), so bulk reads keep the plain sort.
|
|
46
|
+
*
|
|
47
|
+
* `force_compression: zstd`: DuckDB's analyzer never picks ZSTD by itself, and stored a copied table's
|
|
48
|
+
* JSON text column Uncompressed (the test server's 194 M-row table: 0.66 GB a million rows, which would have filled the
|
|
49
|
+
* disk at 125 GB, against 0.26 GB forced; a sample of its text columns 4.5× smaller, the integer, UUID and timestamp
|
|
50
|
+
* columns keeping their own methods, BitPacking and RLE, since forcing only touches what ZSTD can store). The cost of
|
|
51
|
+
* reading a ZSTD column is not measured. tests/duck-wire.test.ts holds a string column to ZSTD.
|
|
52
|
+
*/
|
|
53
|
+
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd" };
|
|
54
|
+
/**
|
|
55
|
+
* DUCKDB'S MEMORY, BOUNDED FOR THIS PROCESS: DuckDB's default is 80% of the machine's RAM, in a process
|
|
56
|
+
* that also holds V8's heap and two PGlites, which ends with the system killing every session and the sync together.
|
|
57
|
+
* Half the RAM unless `MASON_DUCKDB_MEMORY` says (`8GB`, `512MiB`); past it a sort, a join or an aggregate spills to
|
|
58
|
+
* `<file>.tmp` (DuckDB's own default, named here so it is the copy's folder), and what cannot spill fails ITS statement
|
|
59
|
+
* with 53200 and the way out (pgErrorOf), the run going on.
|
|
60
|
+
*/
|
|
61
|
+
/** The default memory limit for a machine with `totalBytes` of RAM: half of it, in MiB (mason doctor says it for any machine). */
|
|
62
|
+
export function defaultMemoryLimit(totalBytes) { return `${Math.floor(totalBytes / 2 / 2 ** 20)}MiB`; }
|
|
63
|
+
/**
|
|
64
|
+
* THE MEMORY THIS PROCESS MAY USE: the machine's RAM, or the container's limit where one is set — a cgroup v2
|
|
65
|
+
* `memory.max` or a cgroup v1 `memory.limit_in_bytes` below it. `os.totalmem()` is the HOST's RAM inside a container,
|
|
66
|
+
* so half of it was more than a limited container had, and the kernel killed the run instead of DuckDB spilling
|
|
67
|
+
* (the review of the cut). `read` is the test's.
|
|
68
|
+
*/
|
|
69
|
+
export function machineMemoryBytes(read = (f) => fs.readFileSync(f, "utf8"), total = os.totalmem()) {
|
|
70
|
+
let limit = total;
|
|
71
|
+
for (const file of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = read(file).trim();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
} // not a cgroup of that version, or not a container: the machine's RAM
|
|
79
|
+
// v2 says `max` for no limit; v1 says a number near 2^63; either way a number below the RAM is the limit
|
|
80
|
+
const n = Number(text);
|
|
81
|
+
if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit)
|
|
82
|
+
limit = n;
|
|
83
|
+
}
|
|
84
|
+
return limit;
|
|
85
|
+
}
|
|
86
|
+
export const DEFAULT_MEMORY_LIMIT = defaultMemoryLimit(machineMemoryBytes());
|
|
87
|
+
export const MEMORY_LIMIT_RE = /^\s*(\d+(?:\.\d+)?)\s*(KB|MB|GB|TB|KiB|MiB|GiB|TiB)\s*$/i;
|
|
88
|
+
/** DuckDB's memory limit in bytes, as DuckDB reads the text: KB / MB / GB / TB are powers of 1000, KiB / MiB / GiB / TiB of 1024 */
|
|
89
|
+
export function memoryLimitBytes(text) {
|
|
90
|
+
const m = MEMORY_LIMIT_RE.exec(text);
|
|
91
|
+
if (m === null)
|
|
92
|
+
throw new Error(`${JSON.stringify(text)} is not an amount of memory`);
|
|
93
|
+
const unit = m[2].toLowerCase();
|
|
94
|
+
const power = ["kb", "kib"].includes(unit) ? 1 : ["mb", "mib"].includes(unit) ? 2 : ["gb", "gib"].includes(unit) ? 3 : 4;
|
|
95
|
+
return Math.round(Number(m[1]) * (unit.endsWith("ib") ? 1024 : 1000) ** power);
|
|
96
|
+
}
|
|
97
|
+
/** the copies open (or closing) in this process, by file: a second instance of a file loses writes silently
|
|
98
|
+
* (src/duck/executor.ts), so an open waits for a close of the same file and refuses one still open */
|
|
99
|
+
const opened = new Map();
|
|
100
|
+
/**
|
|
101
|
+
* The instance, opened ON THE EXECUTOR WORKER (src/duck/executor-worker.ts "open"): its settings, the schemas every
|
|
102
|
+
* session's search_path may name, and the Mason functions, registered from that thread.
|
|
103
|
+
*/
|
|
104
|
+
export async function openDuck(file, settings) {
|
|
105
|
+
let instance;
|
|
106
|
+
try {
|
|
107
|
+
instance = await DuckDBInstance.create(file, settings);
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
const m = e.message;
|
|
111
|
+
throw new Error(/lock/i.test(m)
|
|
112
|
+
? `DuckDB could not open ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
|
|
113
|
+
: `DuckDB could not open ${file}: ${m}. mason destroy deletes the copy, and the next mason start copies again`);
|
|
114
|
+
}
|
|
115
|
+
const writer = await instance.connect();
|
|
116
|
+
await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
117
|
+
// a session's search_path is Postgres's `"$user", public`: DuckDB has no `public` until a public table is copied,
|
|
118
|
+
// and SET search_path to a schema that does not exist is refused
|
|
119
|
+
await writer.run("CREATE SCHEMA IF NOT EXISTS public");
|
|
120
|
+
// Postgres's functions where DuckDB's answer differently (src/duck/functions.ts): the instance's, once
|
|
121
|
+
registerFunctions(writer);
|
|
122
|
+
return { instance, writer };
|
|
123
|
+
}
|
|
124
|
+
export class DuckStore {
|
|
125
|
+
/** a connection of the instance, as `DuckDBInstance.connect` gives one (its statements run on the executor worker) */
|
|
126
|
+
instance;
|
|
127
|
+
writer;
|
|
128
|
+
file;
|
|
129
|
+
/** the memory limit every connection shares, in bytes */
|
|
130
|
+
memoryLimitBytes;
|
|
131
|
+
executor;
|
|
132
|
+
closed = null;
|
|
133
|
+
closeGraceMs;
|
|
134
|
+
constructor(file, executor, memoryLimit, closeGraceMs) {
|
|
135
|
+
this.file = file;
|
|
136
|
+
this.executor = executor;
|
|
137
|
+
this.memoryLimitBytes = memoryLimit;
|
|
138
|
+
this.closeGraceMs = closeGraceMs;
|
|
139
|
+
this.writer = executor.writer();
|
|
140
|
+
this.instance = { connect: () => executor.connect() };
|
|
141
|
+
}
|
|
142
|
+
/** `silentMs` and `closeGraceMs`: the executor's bounds (src/duck/executor.ts SILENT_MS, CLOSE_GRACE_MS), shorter only
|
|
143
|
+
* in a test */
|
|
144
|
+
static async open(file, log, options = {}) {
|
|
145
|
+
const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
|
146
|
+
if (!MEMORY_LIMIT_RE.test(memoryLimit))
|
|
147
|
+
throw new Error(`MASON_DUCKDB_MEMORY=${JSON.stringify(memoryLimit)} is not an amount of memory: write it as 8GB or 512MiB (unset, half the machine's RAM: ${DEFAULT_MEMORY_LIMIT})`);
|
|
148
|
+
const where = path.resolve(file);
|
|
149
|
+
const before = opened.get(where);
|
|
150
|
+
if (before !== undefined && before.closed === null)
|
|
151
|
+
throw new Error(`DuckDB could not open ${file}: this process has it open already (Mason bug: report it)`);
|
|
152
|
+
// a copy whose thread was given up on may still hold the file: a second instance of it would lose writes
|
|
153
|
+
if (before?.abandoned != null)
|
|
154
|
+
throw new Error(`DuckDB could not open ${file}: this process gave up on the copy's thread before (${before.abandoned}), which may still hold it; start mason again (a new process opens it)`);
|
|
155
|
+
const mine = { closed: null, abandoned: null };
|
|
156
|
+
opened.set(where, mine);
|
|
157
|
+
let executor;
|
|
158
|
+
try {
|
|
159
|
+
if (before !== undefined)
|
|
160
|
+
await before.closed;
|
|
161
|
+
// every connection's, the sync's and each session's
|
|
162
|
+
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, memory_limit: memoryLimit, temp_directory: `${file}.tmp` }, log,
|
|
163
|
+
...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}) });
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
if (opened.get(where) === mine)
|
|
167
|
+
opened.delete(where);
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), options.closeGraceMs);
|
|
171
|
+
// closed: the file may be opened again — unless its thread was given up on (`abandoned`), which keeps it here
|
|
172
|
+
store.closing = (p) => { mine.closed = p; void p.then(() => { if (executor.abandoned !== null)
|
|
173
|
+
mine.abandoned = executor.abandoned;
|
|
174
|
+
else if (opened.get(where) === mine)
|
|
175
|
+
opened.delete(where); }); };
|
|
176
|
+
return store;
|
|
177
|
+
}
|
|
178
|
+
closing = () => undefined;
|
|
179
|
+
/** called once if the copy's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
|
|
180
|
+
onCrash(f) { this.executor.onCrash(f); }
|
|
181
|
+
/** how many vectors each Mason function was called with so far, on the copy's thread (a test's, a measurement's) */
|
|
182
|
+
functionCalls() { return this.executor.functionCalls(); }
|
|
183
|
+
/** a connection for one Postgres session (on the executor worker: SessionRunner) */
|
|
184
|
+
runner() { return this.executor.runner(); }
|
|
185
|
+
/**
|
|
186
|
+
* The source's order of text under a libc collation (src/duck/libc-order.ts), kept in the copy: the one recorded
|
|
187
|
+
* for this collation and version, else `read` from the source (a few seconds) and recorded. `read: true` when it was.
|
|
188
|
+
*/
|
|
189
|
+
async libcOrder(collate, version, read) {
|
|
190
|
+
await this.writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
191
|
+
await this.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.libc_order (collation_name VARCHAR, collation_version VARCHAR, data VARCHAR)`);
|
|
192
|
+
const kept = (await this.writer.runAndReadAll(`SELECT data FROM ${MASON_SCHEMA}.libc_order WHERE collation_name = $1 AND collation_version IS NOT DISTINCT FROM $2`, [collate, version])).getRowsJS();
|
|
193
|
+
if (kept.length > 0)
|
|
194
|
+
return { order: decodeLibcOrder(String(kept[0][0])), read: false };
|
|
195
|
+
const order = await read();
|
|
196
|
+
// one order per collation: a version that moved replaces the last one's
|
|
197
|
+
await this.writer.run(`DELETE FROM ${MASON_SCHEMA}.libc_order WHERE collation_name = $1`, [collate]);
|
|
198
|
+
await this.writer.run(`INSERT INTO ${MASON_SCHEMA}.libc_order VALUES ($1, $2, $3)`, [collate, version, encodeLibcOrder(order)]);
|
|
199
|
+
return { order, read: true };
|
|
200
|
+
}
|
|
201
|
+
/** What the Postgres listener runs DuckDB statements with: a connection per session, and each statement's plan,
|
|
202
|
+
* its text sorted as the SOURCE sorts it (`collation`, pg_database's; absent, by bytes; a libc one by `libc`, its
|
|
203
|
+
* order as `libcOrder` read it), refusing what is not `verified` (null: nothing is refused for it, the oracle's
|
|
204
|
+
* recording run alone). */
|
|
205
|
+
async listener(collation, verified = VERIFIED, libc, copies = () => [], kept) {
|
|
206
|
+
const known = new Set((await this.writer.runAndReadAll("SELECT collname FROM pragma_collations()")).getRowsJson().map((r) => String(r[0])));
|
|
207
|
+
quoteDuckKeywords((await this.writer.runAndReadAll("SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category <> 'unreserved'")).getRowsJson().map((r) => String(r[0])));
|
|
208
|
+
const c = collation === undefined ? { kind: "bytes" } : duckCollation(collation.provider, collation.collate, collation.locale, known);
|
|
209
|
+
if (c.kind === "libc") {
|
|
210
|
+
if (libc === undefined)
|
|
211
|
+
throw new Error(`the database orders text by the libc collation ${collation.collate}, and its order was not read (Mason bug: report it)`);
|
|
212
|
+
await this.executor.registerLibcOrder(libc);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
collation: c,
|
|
216
|
+
open: () => this.runner(),
|
|
217
|
+
plan: (catalog, text, stmt, base, paramOids, searchPath, timeZone, userTypes) => planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, c, verified, userTypes, copies, 0, kept ?? null),
|
|
218
|
+
parameters: duckParameters,
|
|
219
|
+
// a stored JSON path ready or gone (src/duck/kept.ts): the plans kept are made again
|
|
220
|
+
...(kept !== undefined ? { generation: () => kept.generation() } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Closes the copy: every connection and the instance, on its thread, then the thread. Resolves once it is closed
|
|
224
|
+
* (a second open of the file waits for it), or once the executor's grace has passed whatever the thread does
|
|
225
|
+
* (src/duck/executor.ts CLOSE_GRACE_MS); never rejects — a failure is said in the log. */
|
|
226
|
+
close() {
|
|
227
|
+
if (this.closed === null) {
|
|
228
|
+
this.closed = this.executor.close(this.closeGraceMs);
|
|
229
|
+
this.closing(this.closed);
|
|
230
|
+
}
|
|
231
|
+
return this.closed;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** A Postgres `search_path` as DuckDB's: `"$user", public` → `public` (no schema is a user's here). */
|
|
235
|
+
export function duckSearchPath(pg) {
|
|
236
|
+
const names = pg.split(",").map((s) => s.trim()).filter((s) => s !== "" && s !== '"$user"' && s !== "$user")
|
|
237
|
+
.map((s) => s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1).replaceAll('""', '"') : s.toLowerCase());
|
|
238
|
+
return (names.length ? names : ["public"]).join(",");
|
|
239
|
+
}
|
|
240
|
+
const quoteLiteral = (s) => `'${s.replaceAll("'", "''")}'`;
|
|
241
|
+
/**
|
|
242
|
+
* A DuckDB error as Postgres would raise it: the SQLSTATE a driver branches on, the message, and the way out. DuckDB
|
|
243
|
+
* prefixes each message with its kind (`Catalog Error: …`, `Binder Error: …`), which decides the code.
|
|
244
|
+
*/
|
|
245
|
+
/** A DuckDB statement's failure under its signal, read ONE way on every path (`streamed`, a plain EXPLAIN): an abort
|
|
246
|
+
* with a reason (the statement timeout, server.ts armTimeout) is that reason, never DuckDB's "interrupted" read as a
|
|
247
|
+
* user's cancel; an abort without one is the cancel; else the error itself (the Fable review of #517: a plain
|
|
248
|
+
* EXPLAIN's own catch said a timeout was the user's cancel). */
|
|
249
|
+
export function failureOf(signal, e, sql) {
|
|
250
|
+
if (signal.aborted && signal.reason instanceof StatementError)
|
|
251
|
+
return new StatementError(signal.reason.fields, sql);
|
|
252
|
+
if (signal.aborted)
|
|
253
|
+
return new StatementError({ code: "57014", message: "canceling statement due to user request" }, sql);
|
|
254
|
+
return e instanceof StatementError ? e : pgErrorOf(e, sql);
|
|
255
|
+
}
|
|
256
|
+
export function pgErrorOf(err, sql) {
|
|
257
|
+
if (err instanceof StatementError)
|
|
258
|
+
return err;
|
|
259
|
+
const full = err instanceof Error ? err.message : String(err);
|
|
260
|
+
const kind = /^([A-Za-z ]+) Error: /.exec(full)?.[1] ?? "";
|
|
261
|
+
const message = full.replace(/^[A-Za-z ]+ Error: /, "").replace(/\n\n?LINE \d+:[\s\S]*$/, "").trim();
|
|
262
|
+
const pg = postgresWording(kind, message);
|
|
263
|
+
if (pg !== null)
|
|
264
|
+
return new StatementError({ ...pg, detail: `DuckDB: ${message}` }, sql);
|
|
265
|
+
const fn = /(?:Scalar|Aggregate|Table) Function with name "?([A-Za-z_][\w]*)"? does not exist/.exec(message)?.[1];
|
|
266
|
+
if (fn !== undefined) {
|
|
267
|
+
// what reaches DuckDB was verified against Postgres (src/duck/verified.ts), so a function DuckDB lacks is a gap in
|
|
268
|
+
// Mason's rewrite, never something for the client to write in DuckDB's dialect
|
|
269
|
+
return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason answers SQL with DuckDB, which has no ${fn}`,
|
|
270
|
+
hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `DuckDB: ${message}` });
|
|
271
|
+
}
|
|
272
|
+
const table = /Table with name "?([^"!\s]+)"? does not exist/.exec(message)?.[1];
|
|
273
|
+
if (table !== undefined) {
|
|
274
|
+
// DuckDB's miss comes after the catalog held the table (the plan passed): one dropped as no longer added, whose way
|
|
275
|
+
// back is `mason add`, so no copy state is read
|
|
276
|
+
return new StatementError({ code: "42P01", message: `relation "${table}" does not exist`,
|
|
277
|
+
hint: missingRelationHint(table, []), detail: `DuckDB: ${message}` });
|
|
278
|
+
}
|
|
279
|
+
const code = kind === "Parser" ? "42601"
|
|
280
|
+
: kind === "Catalog" ? "42P01"
|
|
281
|
+
: kind === "Binder" && /column .*not found|Referenced column|does not have a column named|referenced that exists in the SELECT clause/i.test(message) ? "42703"
|
|
282
|
+
// Postgres's "missing FROM-clause entry for table": DuckDB says it too for a column it cannot find through a table
|
|
283
|
+
// the FROM does name (`"public"."customers"."nope"`)
|
|
284
|
+
: kind === "Binder" && /Referenced table .* not found/i.test(message) ? "42P01"
|
|
285
|
+
// a function DuckDB has, but not with these arguments (Postgres's to_timestamp(text, format))
|
|
286
|
+
: kind === "Binder" && /No function matches the given name and argument types/i.test(message) ? "42883"
|
|
287
|
+
: kind === "Binder" && /GROUP BY|aggregate/i.test(message) ? "42803"
|
|
288
|
+
: kind === "Binder" ? "42804"
|
|
289
|
+
: kind === "Conversion" ? "22P02"
|
|
290
|
+
: kind === "Out of Range" ? "22003"
|
|
291
|
+
: kind === "Interrupt" || /INTERRUPT/i.test(full) ? "57014"
|
|
292
|
+
: kind === "Out of Memory" ? "53200"
|
|
293
|
+
: kind === "Not implemented" ? "0A000"
|
|
294
|
+
: kind === "Invalid Input" ? "22023"
|
|
295
|
+
: "XX000";
|
|
296
|
+
return new StatementError({ code, message: code === "57014" ? "canceling statement due to user request"
|
|
297
|
+
: code === "53200" ? "out of memory: this statement needs more than the memory Mason's DuckDB may use, in a part that cannot spill to disk" : message,
|
|
298
|
+
// DuckDB's own advice is SET, which a client of Mason cannot do: Mason's way out instead
|
|
299
|
+
...(code === "53200" ? { hint: "ask for less at once (a filter, a LIMIT, fewer or narrower columns, a coarser GROUP BY), or raise MASON_DUCKDB_MEMORY (8GB, 24GB) and start mason again; nothing else stopped" } : {}),
|
|
300
|
+
// what Mason ran (the statement as rewritten for DuckDB, src/duck/rewrite.ts): a report of the error needs it
|
|
301
|
+
detail: `DuckDB${kind ? ` ${kind} Error` : ""} (Mason answers SQL with DuckDB): ${message}${sql === "" ? "" : `; DuckDB ran: ${oneLine(sql, 2000)}`}`,
|
|
302
|
+
// Postgres accepted the GROUP BY (it analysed the statement first): DuckDB refusing it is Mason's rewrite's gap
|
|
303
|
+
...(code === "42803" ? { hint: "Postgres accepts this GROUP BY and Mason's rewrite did not carry it to DuckDB: report it with the statement; adding the column to the GROUP BY answers meanwhile" } : {}),
|
|
304
|
+
}, sql);
|
|
305
|
+
}
|
|
306
|
+
/** The errors DuckDB raises for what Postgres raises too, in Postgres's code and words (a tool or a person matches them). */
|
|
307
|
+
function postgresWording(kind, message) {
|
|
308
|
+
if (/^Division by zero\b/i.test(message) || message === "division by zero")
|
|
309
|
+
return { code: "22012", message: "division by zero" };
|
|
310
|
+
const zone = /Unknown TimeZone '([^']*)'/.exec(message);
|
|
311
|
+
if (zone)
|
|
312
|
+
return { code: "22023", message: `time zone "${zone[1]}" not recognized` };
|
|
313
|
+
const overflow = /Overflow in (?:addition|subtraction|multiplication|negation|cast) of (INT16|INT32|INT64)/.exec(message);
|
|
314
|
+
if (overflow)
|
|
315
|
+
return { code: "22003", message: `${{ INT16: "smallint", INT32: "integer", INT64: "bigint" }[overflow[1]]} out of range` };
|
|
316
|
+
// a cast of a number to an integer type it does not fit
|
|
317
|
+
const narrow = /can't be cast because the value is out of range for the destination type (INT16|INT32|INT64)|Failed to cast decimal value \S+ to type (INT16|INT32|INT64)/.exec(message);
|
|
318
|
+
if (narrow)
|
|
319
|
+
return { code: "22003", message: `${{ INT16: "smallint", INT32: "integer", INT64: "bigint" }[(narrow[1] ?? narrow[2])]} out of range` };
|
|
320
|
+
// make_date / make_timestamp of a field out of range
|
|
321
|
+
const field = /^Date out of range: (.*)$/.exec(message);
|
|
322
|
+
if (field)
|
|
323
|
+
return { code: "22008", message: `date field value out of range: ${field[1]}` };
|
|
324
|
+
// a cast to numeric(p, s) of a value with more than p - s integer digits
|
|
325
|
+
if (/Casting value "[^"]*" to type DECIMAL\(\d+,\s*\d+\) failed: value is out of range/.test(message))
|
|
326
|
+
return { code: "22003", message: "numeric field overflow" };
|
|
327
|
+
// an exact sum past DuckDB's 38 digits (a text read as numeric is summed as a DECIMAL(38,18), src/duck/rewrite.ts summed)
|
|
328
|
+
if (/^Overflow in HUGEINT addition\b/.test(message))
|
|
329
|
+
return { code: "0A000", message: `a sum past DuckDB's 38 digits is not supported by Mason (${gapMark("numeric-exact-sum")})` };
|
|
330
|
+
// a numeric(p, s) read from a text holding NaN or Infinity (src/duck/rewrite.ts pgInput): DuckDB's decimal has none
|
|
331
|
+
const special = /Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.exec(message);
|
|
332
|
+
if (special)
|
|
333
|
+
return { code: "0A000", message: `a numeric ${special[1]} read from a text is not supported by Mason (${gapMark("numeric-special")})` };
|
|
334
|
+
const conv = /Could not convert string '((?:[^']|'')*)' to (INT16|INT32|INT64|DOUBLE|FLOAT|DATE|TIMESTAMP|UUID|BOOL)/.exec(message);
|
|
335
|
+
if (conv) {
|
|
336
|
+
const t = { INT16: "smallint", INT32: "integer", INT64: "bigint", DOUBLE: "double precision", FLOAT: "real", DATE: "date", TIMESTAMP: "timestamp", UUID: "uuid", BOOL: "boolean" }[conv[2]];
|
|
337
|
+
return { code: "22P02", message: `invalid input syntax for type ${t}: "${conv[1].replaceAll("''", "'")}"` };
|
|
338
|
+
}
|
|
339
|
+
// a Mason function's own error (src/duck/functions.ts): Postgres's words after the prefix
|
|
340
|
+
// Postgres's own error, from its own function (src/duck/pg-eval.ts): its SQLSTATE, said
|
|
341
|
+
const pg = /^mason\[([0-9A-Z]{5})\]: (.*)$/s.exec(message);
|
|
342
|
+
if (pg)
|
|
343
|
+
return { code: pg[1], message: pg[2] };
|
|
344
|
+
const own = /^mason: (.*)$/s.exec(message);
|
|
345
|
+
if (own)
|
|
346
|
+
return { code: /^cannot |argument list|key must not be null/.test(own[1]) ? "22023" : /^invalid input syntax/.test(own[1]) ? "22P02" : / is not supported| not supported for type /.test(own[1]) ? "0A000" : "XX000", message: own[1] };
|
|
347
|
+
// the rewrite's own error() calls carry Postgres's message (src/duck/rewrite.ts)
|
|
348
|
+
if (kind === "Invalid Input" && /^invalid input syntax for type /.test(message))
|
|
349
|
+
return { code: "22P02", message };
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
/** how long no call of a session must have been answered before a result of its that did not end is closed: a task
|
|
353
|
+
* waiting for a call is answered within a turn of the worker's loop (microseconds when it is free), and the work a task
|
|
354
|
+
* does between two calls is one vector's (2,048 rows) — milliseconds; 50 ms leaves an order of magnitude over both */
|
|
355
|
+
const CALLS_QUIET_MS = 50;
|
|
356
|
+
/** when the wait for a statement's calls to stop is said in the log */
|
|
357
|
+
const CALLS_SLOW_MS = 5_000;
|
|
358
|
+
/** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
|
|
359
|
+
* on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
|
|
360
|
+
export class SessionRunner {
|
|
361
|
+
/** the session's slot of the cancel table (src/duck/cancel.ts), which its connection's `__mason_session` names */
|
|
362
|
+
slot;
|
|
363
|
+
cancels;
|
|
364
|
+
/** the number of the statement `run` or `explain` is asked next (the listener's, carried by the request) */
|
|
365
|
+
next = 0;
|
|
366
|
+
/** the answers being drained (`streamed`): `close` and the next statement wait for them first */
|
|
367
|
+
releasing = new Set();
|
|
368
|
+
/** what runs on the connection now: a statement being started, a chunk being read, an EXPLAIN. `close` waits for it
|
|
369
|
+
* too — a connection closed under DuckDB's pending call aborted the process (a stop during a drain) */
|
|
370
|
+
busy = new Set();
|
|
371
|
+
/** the answer left open on the connection, if any: what `close` drains before it closes the connection */
|
|
372
|
+
openAnswer = null;
|
|
373
|
+
conn;
|
|
374
|
+
log;
|
|
375
|
+
zone = null;
|
|
376
|
+
path = null;
|
|
377
|
+
constructor(conn, log, slot, cancels) {
|
|
378
|
+
this.conn = conn;
|
|
379
|
+
this.log = log;
|
|
380
|
+
this.slot = slot;
|
|
381
|
+
this.cancels = cancels;
|
|
382
|
+
}
|
|
383
|
+
/** a connection for one Postgres session, in the cancel table's `slot` */
|
|
384
|
+
static async open(instance, log, slot, cancels) {
|
|
385
|
+
const conn = await instance.connect();
|
|
386
|
+
// NULLs sort as Postgres sorts them: last ascending, FIRST descending (DuckDB's default is last both ways). An
|
|
387
|
+
// integer division is the rewrite's `//` where Postgres's operator is integer division (src/duck/rewrite.ts), so
|
|
388
|
+
// DuckDB's `/` stays the float division every other `/` is
|
|
389
|
+
await conn.run("SET default_null_order = 'nulls_last_on_asc_first_on_desc'");
|
|
390
|
+
// every Mason function's first argument (src/duck/functions.ts "every call carries its session")
|
|
391
|
+
await conn.run(`SET VARIABLE ${SESSION_VARIABLE} = ${quoteLiteral(slotName(slot))}`);
|
|
392
|
+
return new SessionRunner(conn, log, slot, cancels);
|
|
393
|
+
}
|
|
394
|
+
async settle(session) {
|
|
395
|
+
if (session.timeZone !== this.zone) {
|
|
396
|
+
await this.conn.run(`SET TimeZone = ${quoteLiteral(session.timeZone)}`);
|
|
397
|
+
this.zone = session.timeZone;
|
|
398
|
+
}
|
|
399
|
+
// Postgres skips a schema of the path that does not exist; DuckDB refuses the SET (and with it every statement):
|
|
400
|
+
// the path is the schemas DuckDB has (a table added later in a new schema is found at the next statement)
|
|
401
|
+
const have = new Set((await this.conn.runAndReadAll("SELECT schema_name FROM duckdb_schemas() WHERE database_name = current_database()")).getRowsJS().map(([n]) => n));
|
|
402
|
+
const wanted = duckSearchPath(session.searchPath).split(",").filter((n) => have.has(n));
|
|
403
|
+
const path = (wanted.length > 0 ? wanted : ["main"]).join(",");
|
|
404
|
+
if (path !== this.path) {
|
|
405
|
+
await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
|
|
406
|
+
this.path = path;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async prepare(sql) {
|
|
410
|
+
try {
|
|
411
|
+
return await this.conn.prepare(sql);
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
throw pgErrorOf(e, sql);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* A statement STREAMED (`SELECT *` of 23 M rows held every row three times over — DuckDB's, as JavaScript
|
|
419
|
+
* values, encoded — and ran the run out of heap): the answer reads DuckDB's result a chunk at a time, and the
|
|
420
|
+
* listener writes each chunk before it asks for the next. The cursor holds the connection (DuckDB starts nothing
|
|
421
|
+
* else on it until the result is read or closed) and the signal's interrupt until it ends: read to the end, failed,
|
|
422
|
+
* or closed.
|
|
423
|
+
*/
|
|
424
|
+
async run(sql, params, session, signal, fields = null, exact = []) {
|
|
425
|
+
return (await this.track(this.streamed(sql, params, session, signal, fields, exact))).answer;
|
|
426
|
+
}
|
|
427
|
+
/** `p`, held in `busy` until it settles */
|
|
428
|
+
track(p) {
|
|
429
|
+
this.busy.add(p);
|
|
430
|
+
void p.then(() => this.busy.delete(p), () => this.busy.delete(p));
|
|
431
|
+
return p;
|
|
432
|
+
}
|
|
433
|
+
/** `run`, and where its time goes as it is read: DuckDB's (its prepare, which plans; each chunk it computes) and
|
|
434
|
+
* Mason's (the exact numerics, the encoding), ms — what an EXPLAIN ANALYZE says */
|
|
435
|
+
async streamed(planned, params, session, signal, fields, exact) {
|
|
436
|
+
const sql = planned.text;
|
|
437
|
+
const seq = this.next;
|
|
438
|
+
await Promise.all([...this.releasing]);
|
|
439
|
+
// this statement's calls read their cancel from here on: never before the last one is released, which a cancel of
|
|
440
|
+
// its own still drains (src/duck/cancel.ts)
|
|
441
|
+
beginStatement(this.cancels, this.slot, seq);
|
|
442
|
+
try {
|
|
443
|
+
await this.settle(session);
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
throw pgErrorOf(e, sql);
|
|
447
|
+
}
|
|
448
|
+
const timing = { prepareMs: 0, duckdbMs: 0, masonMs: 0 };
|
|
449
|
+
const t0 = performance.now();
|
|
450
|
+
const p = await this.prepare(sql);
|
|
451
|
+
timing.prepareMs = performance.now() - t0;
|
|
452
|
+
// HOW A STATEMENT IS CANCELLED (src/duck/functions.ts "every call carries its session"; the test
|
|
453
|
+
// server's hung process). One that calls no Mason function is interrupted, and its result closed at once: the
|
|
454
|
+
// close is what cancels the executor's tasks, and a close put off lets the session's next statement take the
|
|
455
|
+
// connection first, after which the close waits for the cancelled query to run to its end (38 s, in
|
|
456
|
+
// tests/duck-wire.test.ts "statement_timeout"). One that does is cancelled through its session — its next call
|
|
457
|
+
// raises — AND interrupted (its tasks stop at their next chunk, so a call-free rest past its last call stops too),
|
|
458
|
+
// and its result is closed only once DuckDB has handed it back (the fetch's own path, or `drain`) and its calls
|
|
459
|
+
// have stopped (`cleanUp`: a task still inside a call waits for this thread, which a close would hold). Whether it calls
|
|
460
|
+
// one is the PLAN's (src/duck/plan.ts PlannedSql `functions`, read off the rewritten tree), never a match on its
|
|
461
|
+
// text: a stored JSON path's column and the view of a copy's visible columns are named `__mason_…` too, and
|
|
462
|
+
// read as calls, every `SELECT *` over such a copy was cancelled only by a call it never made (a review
|
|
463
|
+
// finding: 82 s past a 300 ms statement_timeout)
|
|
464
|
+
const callsFunctions = planned.functions.length > 0;
|
|
465
|
+
const cancel = () => { if (callsFunctions)
|
|
466
|
+
cancelStatement(this.cancels, this.slot, seq); this.conn.interrupt(); };
|
|
467
|
+
// A CANCEL ASKED IS A CANCEL ANSWERED: a function-calling statement past its last call runs on to its end, and one
|
|
468
|
+
// whose cancel lands as its last chunk is computed has nothing left to stop — either is told 57014, never "ok" (the
|
|
469
|
+
// listener's thread words it as its own signal says: the timeout's or the cancel's, src/duck/executor.ts)
|
|
470
|
+
const cancelAsked = () => signal.aborted || (callsFunctions && statementCancelled(this.cancels, this.slot, seq));
|
|
471
|
+
const cancelled = () => signal.aborted ? failureOf(signal, null, sql)
|
|
472
|
+
: new StatementError({ code: "57014", message: "canceling statement due to user request" }, sql);
|
|
473
|
+
signal.addEventListener("abort", cancel);
|
|
474
|
+
const failure = (e) => failureOf(signal, e, sql);
|
|
475
|
+
// an abort before the listener was there (the timeout fired in the settle or the prepare) cancels nothing
|
|
476
|
+
if (signal.aborted) {
|
|
477
|
+
signal.removeEventListener("abort", cancel);
|
|
478
|
+
p.destroySync();
|
|
479
|
+
throw failure(null);
|
|
480
|
+
}
|
|
481
|
+
const started = Date.now();
|
|
482
|
+
let total = 0;
|
|
483
|
+
let result = null;
|
|
484
|
+
let closed = false, fetching = false, released = false;
|
|
485
|
+
const release = () => {
|
|
486
|
+
if (released)
|
|
487
|
+
return;
|
|
488
|
+
released = true;
|
|
489
|
+
if (this.openAnswer === close)
|
|
490
|
+
this.openAnswer = null;
|
|
491
|
+
signal.removeEventListener("abort", cancel);
|
|
492
|
+
result?.closeSync();
|
|
493
|
+
p.destroySync();
|
|
494
|
+
this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
|
|
495
|
+
};
|
|
496
|
+
// A CALLING STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED
|
|
497
|
+
// ONLY ONCE ITS CALLS HAVE STOPPED (the suite hung for good, 0 % CPU). DuckDB hands back a failed chunk
|
|
498
|
+
// while another of the query's tasks may still wait INSIDE a call — for this thread, which runs every Mason
|
|
499
|
+
// function — and closing the result waits for that task (Executor::CancelTasks): this thread waited for the task,
|
|
500
|
+
// the task for this thread, and every later request to the copy with them (`cleanUp`). One that calls no function,
|
|
501
|
+
// or one read to its end, has no task left to wait for and is released at once
|
|
502
|
+
const releaseAfterCleanup = () => {
|
|
503
|
+
if (!callsFunctions || released) {
|
|
504
|
+
release();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const done = (async () => { await this.cleanUp(seq, sql); release(); })();
|
|
508
|
+
this.releasing.add(done);
|
|
509
|
+
void done.finally(() => this.releasing.delete(done));
|
|
510
|
+
};
|
|
511
|
+
// a result closed before its end while no fetch is in flight: a statement calling no function is released at
|
|
512
|
+
// once (the close cancels the executor); one that does is read on until DuckDB hands it back refused (its next
|
|
513
|
+
// call raises) or read to its end, then released — the session's next statement waits for that (`releasing`)
|
|
514
|
+
const drain = () => {
|
|
515
|
+
const r = result;
|
|
516
|
+
if (!callsFunctions || r === null) {
|
|
517
|
+
release();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const done = (async () => {
|
|
521
|
+
// read on until the next call raises or the result ends — an EMPTY chunk, as `fetch` reads it: past its end a
|
|
522
|
+
// streamed result hands back empty chunks for ever, never null, and a result closed after its last row but
|
|
523
|
+
// before its end was read here without end, a thread spinning and the session's close waiting on it
|
|
524
|
+
try {
|
|
525
|
+
for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
// the cancel's own refusal (its next call raised, 57014) is what the drain waits for. Anything else is the
|
|
529
|
+
// statement's real error, met after the listener let go of its answer: no one reads it any more, and a
|
|
530
|
+
// rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
|
|
531
|
+
if (pgErrorOf(e, sql).fields.code !== "57014")
|
|
532
|
+
this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
533
|
+
// it failed, so a task of it may still be inside a call: closed once its calls have stopped
|
|
534
|
+
await this.cleanUp(seq, sql);
|
|
535
|
+
}
|
|
536
|
+
release();
|
|
537
|
+
})();
|
|
538
|
+
this.releasing.add(done);
|
|
539
|
+
void done.finally(() => this.releasing.delete(done));
|
|
540
|
+
};
|
|
541
|
+
// an early end by the listener (a portal closed half read, the session gone): cancelled, and released — by the
|
|
542
|
+
// fetch in flight once DuckDB hands the chunk back, else by `drain`
|
|
543
|
+
const close = () => {
|
|
544
|
+
if (closed)
|
|
545
|
+
return;
|
|
546
|
+
closed = true;
|
|
547
|
+
cancel();
|
|
548
|
+
if (!fetching)
|
|
549
|
+
drain();
|
|
550
|
+
};
|
|
551
|
+
this.openAnswer = close;
|
|
552
|
+
try {
|
|
553
|
+
if (p.parameterCount !== params.length) {
|
|
554
|
+
throw new StatementError({ code: "08P01", message: `bind message supplies ${params.length} parameters, but the statement requires ${p.parameterCount}` }, sql);
|
|
555
|
+
}
|
|
556
|
+
// a value comes as text and DuckDB casts it to the parameter's type, as Postgres reads an untyped literal
|
|
557
|
+
params.forEach((v, i) => { if (v === null)
|
|
558
|
+
p.bindNull(i + 1);
|
|
559
|
+
else
|
|
560
|
+
p.bindVarchar(i + 1, v); });
|
|
561
|
+
const t1 = performance.now();
|
|
562
|
+
try {
|
|
563
|
+
result = await p.stream();
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
throw failure(e);
|
|
567
|
+
}
|
|
568
|
+
timing.duckdbMs += performance.now() - t1;
|
|
569
|
+
const r = result;
|
|
570
|
+
if (fields !== null && r.columnCount < fields.length)
|
|
571
|
+
throw new StatementError({ code: "XX000", message: `DuckDB answered ${r.columnCount} column(s) where Postgres's analysis has ${fields.length}: report it (Mason's rewrite)` }, sql);
|
|
572
|
+
// Postgres's columns where its analysis gave them (a WITH TIES rank DuckDB adds after them is not sent)
|
|
573
|
+
const columns = fields !== null
|
|
574
|
+
? fields.map((f, i) => pgTypedColumn(f.name, f.oid, r.columnType(i), f.typmod))
|
|
575
|
+
: Array.from({ length: r.columnCount }, (_, i) => wireColumn(r.columnName(i), r.columnType(i)));
|
|
576
|
+
const computed = fields !== null && exact.length > 0 ? exactColumns(columns, fields, exact, (i) => r.columnType(i)) : null;
|
|
577
|
+
// EVERY TYPE GAP IS LOGGED, per statement (decision 6 of the prototype's design): which DuckDB types reach a tool where Postgres's differ
|
|
578
|
+
for (const c of columns.filter((x) => x.divergence !== null))
|
|
579
|
+
this.log(`type gap: column ${JSON.stringify(c.name)}: ${c.divergence} — in ${oneLine(sql)}`);
|
|
580
|
+
const answer = {
|
|
581
|
+
columns,
|
|
582
|
+
fetch: async (formats, s) => {
|
|
583
|
+
// closed under a cancel (a stop drains every answer it aborted): the cancel's answer, never an end
|
|
584
|
+
if (closed) {
|
|
585
|
+
if (cancelAsked())
|
|
586
|
+
throw cancelled();
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
// a cancel that came while nothing ran (the listener waiting for the client to read): DuckDB had nothing to
|
|
591
|
+
// interrupt, so it is said here, before the next chunk is read
|
|
592
|
+
if (cancelAsked())
|
|
593
|
+
throw cancelled();
|
|
594
|
+
let chunk;
|
|
595
|
+
const c0 = performance.now();
|
|
596
|
+
fetching = true;
|
|
597
|
+
try {
|
|
598
|
+
chunk = await this.track(r.fetchChunk());
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
throw failure(e);
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
fetching = false;
|
|
605
|
+
}
|
|
606
|
+
if (closed) {
|
|
607
|
+
drain();
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
const c1 = performance.now();
|
|
611
|
+
timing.duckdbMs += c1 - c0;
|
|
612
|
+
// read to its end: the executor is done, and the result released at once — unless its cancel was asked
|
|
613
|
+
// meanwhile, which it ran past (`cancelAsked`)
|
|
614
|
+
if (chunk === null || chunk.rowCount === 0) {
|
|
615
|
+
closed = true;
|
|
616
|
+
// ended under a cancel (or an interrupt that DuckDB answered as an end): closed once its calls stopped
|
|
617
|
+
if (cancelAsked()) {
|
|
618
|
+
releaseAfterCleanup();
|
|
619
|
+
throw cancelled();
|
|
620
|
+
}
|
|
621
|
+
release();
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
let rows = chunk.getRows();
|
|
625
|
+
if (computed !== null)
|
|
626
|
+
rows = exactRows(computed, rows, fields.length, sql, params);
|
|
627
|
+
total += rows.length;
|
|
628
|
+
const encoded = encodeRows(columns, rows, formats, s);
|
|
629
|
+
timing.masonMs += performance.now() - c1;
|
|
630
|
+
return encoded;
|
|
631
|
+
}
|
|
632
|
+
catch (e) {
|
|
633
|
+
closed = true;
|
|
634
|
+
releaseAfterCleanup();
|
|
635
|
+
throw e;
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
close,
|
|
639
|
+
};
|
|
640
|
+
return { answer, timing };
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
closed = true;
|
|
644
|
+
releaseAfterCleanup();
|
|
645
|
+
throw e;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* EXPLAIN of a plan (src/duck/explain.ts). Without ANALYZE: DuckDB's `EXPLAIN (FORMAT JSON)` of the planned SQL, its
|
|
650
|
+
* parameters bound. With it: the statement RUN as `run` runs it — every chunk read, its exact numerics computed and
|
|
651
|
+
* encoded, none sent — with DuckDB's profiler writing each operator's rows and time to a file of its own, read after.
|
|
652
|
+
* Then the copies its scans read: their rows, and their last poll (`_mason.poll_state`).
|
|
653
|
+
*/
|
|
654
|
+
explain(sql, params, session, signal, how) {
|
|
655
|
+
return this.track(this.explained(sql, params, session, signal, how));
|
|
656
|
+
}
|
|
657
|
+
async explained(sql, params, session, signal, how) {
|
|
658
|
+
await Promise.all([...this.releasing]);
|
|
659
|
+
const o = how.options;
|
|
660
|
+
let plan;
|
|
661
|
+
let analyzed;
|
|
662
|
+
if (!o.analyze) {
|
|
663
|
+
try {
|
|
664
|
+
await this.settle(session);
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
throw pgErrorOf(e, sql.text);
|
|
668
|
+
}
|
|
669
|
+
const p = await this.prepare(`EXPLAIN (FORMAT JSON) ${sql.text}`);
|
|
670
|
+
const interrupt = () => this.conn.interrupt();
|
|
671
|
+
signal.addEventListener("abort", interrupt);
|
|
672
|
+
try {
|
|
673
|
+
if (signal.aborted)
|
|
674
|
+
throw failureOf(signal, null, sql.text);
|
|
675
|
+
params.forEach((v, i) => { if (v === null)
|
|
676
|
+
p.bindNull(i + 1);
|
|
677
|
+
else
|
|
678
|
+
p.bindVarchar(i + 1, v); });
|
|
679
|
+
let r;
|
|
680
|
+
try {
|
|
681
|
+
r = await p.runAndReadAll();
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
throw failureOf(signal, e, sql.text);
|
|
685
|
+
}
|
|
686
|
+
plan = planNodes(JSON.parse(String(r.getRowsJS()[0]?.[1] ?? "[]")));
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
signal.removeEventListener("abort", interrupt);
|
|
690
|
+
p.destroySync();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mason-explain-"));
|
|
695
|
+
const file = path.join(dir, "profile.json");
|
|
696
|
+
try {
|
|
697
|
+
let rows = 0;
|
|
698
|
+
let timing;
|
|
699
|
+
await this.conn.run("SET enable_profiling = 'json'");
|
|
700
|
+
try {
|
|
701
|
+
await this.conn.run(`SET profiling_output = ${quoteLiteral(file)}`);
|
|
702
|
+
const s = await this.streamed(sql, params, session, signal, how.fields, how.exact);
|
|
703
|
+
timing = s.timing;
|
|
704
|
+
try {
|
|
705
|
+
for (let chunk = await s.answer.fetch([], { timeZone: session.timeZone }); chunk !== null; chunk = await s.answer.fetch([], { timeZone: session.timeZone }))
|
|
706
|
+
rows += chunk.length;
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
s.answer.close();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
finally {
|
|
713
|
+
await this.conn.run("PRAGMA disable_profiling");
|
|
714
|
+
}
|
|
715
|
+
// the profile is the statement's: written when its result was read to its end, after every other statement here
|
|
716
|
+
plan = planNodes(JSON.parse(fs.readFileSync(file, "utf8")).operator ?? []);
|
|
717
|
+
analyzed = { rows, planning: { ...how.planning, duckdbMs: timing.prepareMs }, execution: { duckdbMs: timing.duckdbMs, masonMs: timing.masonMs } };
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// what it calls is the plan's (src/duck/plan.ts PlannedSql), the list that decides how it is cancelled
|
|
724
|
+
const explained = { copies: await this.copies(scannedTables(plan)), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
725
|
+
functions: [...sql.functions], plan, ...(analyzed === undefined ? {} : { analyzed }) };
|
|
726
|
+
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
727
|
+
}
|
|
728
|
+
/** each copy's rows and last poll (none recorded: a copy made whole, not polled yet) */
|
|
729
|
+
async copies(tables) {
|
|
730
|
+
const polls = (await this.conn.runAndReadAll(`SELECT count(*) FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = '${MASON_SCHEMA}' AND table_name = 'poll_state'`)).getRowsJS()[0][0] !== 0n;
|
|
731
|
+
const out = [];
|
|
732
|
+
for (const table of tables) {
|
|
733
|
+
const [schema, name] = table.split(".");
|
|
734
|
+
const rows = (await this.conn.runAndReadAll("SELECT estimated_size FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = $1 AND table_name = $2", [schema, name])).getRowsJS()[0]?.[0];
|
|
735
|
+
const polled = polls ? (await this.conn.runAndReadAll(`SELECT strftime(polled_at AT TIME ZONE 'UTC', '%Y-%m-%d %H:%M:%S') || ' UTC' FROM ${MASON_SCHEMA}.poll_state WHERE source = $1`, [table])).getRowsJS()[0]?.[0] : undefined;
|
|
736
|
+
out.push({ table, rows: rows === undefined || rows === null ? null : Number(rows), polledAt: typeof polled === "string" ? polled : null });
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* A CALLING STATEMENT THAT DID NOT RUN TO ITS END, WAITED FOR UNTIL ITS CALLS HAVE STOPPED, before its result is
|
|
742
|
+
* closed (`releaseAfterCleanup` in `streamed` says why). Every close is synchronous on this thread (DuckDB's node API
|
|
743
|
+
* has no other, and refuses a second query on a connection with a result open), so the close must find no task of the
|
|
744
|
+
* query inside, or waiting for, a call. The statement is marked cancelled (each call it still makes raises at once)
|
|
745
|
+
* and interrupted (its tasks stop at their next chunk), and this thread stays free — a task waiting for a call is
|
|
746
|
+
* answered within a turn of its loop — until no call of this session has been answered for `CALLS_QUIET_MS`. Never a
|
|
747
|
+
* deadline: a close while calls still come is the deadlock itself, and the calls end (each raises); a wait past
|
|
748
|
+
* `CALLS_SLOW_MS` is said once.
|
|
749
|
+
*/
|
|
750
|
+
async cleanUp(seq, sql) {
|
|
751
|
+
cancelStatement(this.cancels, this.slot, seq);
|
|
752
|
+
this.conn.interrupt();
|
|
753
|
+
const t0 = performance.now();
|
|
754
|
+
let said = false;
|
|
755
|
+
// ONE WHOLE WINDOW BEFORE THE FIRST LOOK: a call a task has queued for this thread but that has not run yet leaves
|
|
756
|
+
// `msSinceCall` at the previous call's time — long ago, when this thread was busy with another connection's close
|
|
757
|
+
// (an interrupt honoured, ~10 s for one join) — so a check made synchronously here could pass with that call still
|
|
758
|
+
// waiting, and the close deadlock on it the same way. A sleep of the window lets every queued call run first.
|
|
759
|
+
for (;;) {
|
|
760
|
+
await sleep(CALLS_QUIET_MS);
|
|
761
|
+
if (msSinceCall(this.slot) >= CALLS_QUIET_MS)
|
|
762
|
+
break;
|
|
763
|
+
if (!said && performance.now() - t0 > CALLS_SLOW_MS) {
|
|
764
|
+
said = true;
|
|
765
|
+
this.log(`a statement that did not run to its end still calls Mason's functions after ${CALLS_SLOW_MS / 1000} s; its result is closed once they stop — ${oneLine(sql)}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Closes the connection once nothing runs on it: an answer left open drained (its statement cancelled), every
|
|
770
|
+
* answer released and every call of DuckDB settled (never under the executor: `drain`; a connection
|
|
771
|
+
* closed under a drain's pending fetch aborted the process). */
|
|
772
|
+
async close() {
|
|
773
|
+
this.openAnswer?.();
|
|
774
|
+
for (let waiting = [...this.busy, ...this.releasing]; waiting.length > 0; waiting = [...this.busy, ...this.releasing])
|
|
775
|
+
await Promise.allSettled(waiting);
|
|
776
|
+
this.conn.closeSync();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function exactColumns(columns, fields, exact, typeAt) {
|
|
780
|
+
const base = fields.length;
|
|
781
|
+
const readers = exact[0].cells.map((conv, j) => cellText(typeAt(base + j), conv));
|
|
782
|
+
const computed = exact.filter((e) => {
|
|
783
|
+
const unreadable = readers.some((r, j) => r === null && programReads(e, j));
|
|
784
|
+
if (unreadable)
|
|
785
|
+
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it DuckDB computed as a double` };
|
|
786
|
+
return !unreadable;
|
|
787
|
+
});
|
|
788
|
+
if (computed.length === 0)
|
|
789
|
+
return null;
|
|
790
|
+
for (const e of computed) {
|
|
791
|
+
const f = fields[e.column];
|
|
792
|
+
columns[e.column] = exactColumn(f.name, f.oid);
|
|
793
|
+
}
|
|
794
|
+
return { columns: computed, readers };
|
|
795
|
+
}
|
|
796
|
+
/** one chunk's rows with its exact columns computed */
|
|
797
|
+
function exactRows(computed, rows, base, sql, params) {
|
|
798
|
+
return rows.map((row) => {
|
|
799
|
+
// a program's `$n` cell is the n-th parameter's bound text
|
|
800
|
+
const values = Object.fromEntries(params.map((v, i) => [`$${i + 1}`, v]));
|
|
801
|
+
computed.readers.forEach((r, j) => { const v = row[base + j] ?? null; values[String(j)] = v === null || r === null ? null : r(v); });
|
|
802
|
+
const out = row.slice();
|
|
803
|
+
for (const e of computed.columns) {
|
|
804
|
+
try {
|
|
805
|
+
out[e.column] = evaluateText(e.program, values);
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
if (err instanceof NumericError)
|
|
809
|
+
throw new StatementError({ code: err.sqlstate, message: err.message }, sql);
|
|
810
|
+
throw err;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/** what Mason computes after DuckDB, a column each: its exact numeric from DuckDB's values */
|
|
817
|
+
function computedOf(exact, fields) {
|
|
818
|
+
return exact.map((e) => {
|
|
819
|
+
const cells = new Set(cellsOf(e.program).filter((k) => !k.startsWith("$"))).size;
|
|
820
|
+
return `column ${e.column + 1}${fields?.[e.column] === undefined ? "" : ` "${fields[e.column].name}"`}: Postgres's exact numeric${e.text ? " as text" : ""}, from ${cells} of DuckDB's value(s)`;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/** whether the column's program reads cell j */
|
|
824
|
+
const programReads = (e, j) => JSON.stringify(e.program).includes(`"key":"${j}"`);
|
|
825
|
+
/** a statement on one line: whole in the log (its ORDER BY, past 160 characters, was what told a slow preview's
|
|
826
|
+
* cause), cut where it goes to a client in an error's detail */
|
|
827
|
+
const oneLine = (sql, max = Infinity) => { const t = sql.replace(/\s+/g, " ").trim(); return t.length > max ? `${t.slice(0, max - 3)}…` : t; };
|
|
828
|
+
const formatAt = (formats, i) => (formats.length === 0 ? 0 : formats.length === 1 ? formats[0] : formats[i] ?? 0) === 1 ? 1 : 0;
|
|
829
|
+
function encodeRows(columns, rows, formats, session) {
|
|
830
|
+
return rows.map((row) => dataRow(columns.map((c, i) => c.encode(row[i] ?? null, formatAt(formats, i), session))));
|
|
831
|
+
}
|