@mason-data/cli 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/duck/store.js
CHANGED
|
@@ -16,23 +16,23 @@
|
|
|
16
16
|
* (src/duck/sync.ts attach), so the store itself opens offline.
|
|
17
17
|
*/
|
|
18
18
|
import * as os from "node:os";
|
|
19
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
20
19
|
import { DuckDBInstance, version } from "@duckdb/node-api";
|
|
21
20
|
import { Executor } from "./executor.js";
|
|
22
21
|
import { beginStatement, cancelStatement, slotName, statementCancelled } from "./cancel.js";
|
|
23
22
|
import { StatementError } from "../api/postgres/server.js";
|
|
24
23
|
import { duckParameters, missingRelationHint, planDuck, quoteDuckKeywords } from "./plan.js";
|
|
25
24
|
import { VERIFIED } from "./verified.js";
|
|
26
|
-
import {
|
|
25
|
+
import { registerFunctions, SESSION_VARIABLE, SETTINGS_VARIABLE } from "./functions.js";
|
|
27
26
|
import { duckCollation } from "./rewrite.js";
|
|
28
27
|
import { decodeLibcOrder, encodeLibcOrder } from "./libc-order.js";
|
|
29
28
|
import { dataRow } from "../api/postgres/protocol.js";
|
|
30
29
|
import { cellText, exactColumn, pgTypedColumn, wireColumn } from "./types.js";
|
|
31
|
-
import { gapMark } from "./gaps.js";
|
|
30
|
+
import { gapIn, gapMark } from "./gaps.js";
|
|
32
31
|
import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
|
|
33
|
-
import { explainJson, explainText, planNodes, scannedTables } from "./explain.js";
|
|
32
|
+
import { explainJson, explainText, planNodes, profiled, qualifiedName, scannedTables } from "./explain.js";
|
|
34
33
|
import * as fs from "node:fs";
|
|
35
34
|
import * as path from "node:path";
|
|
35
|
+
import { extensionSettings, findExtensions } from "./extensions.js";
|
|
36
36
|
export const MASON_SCHEMA = "_mason";
|
|
37
37
|
/** the DuckDB the prototype runs (`@duckdb/node-api`'s bundled library) */
|
|
38
38
|
export const DUCKDB_VERSION = version();
|
|
@@ -49,8 +49,19 @@ export const DUCKDB_VERSION = version();
|
|
|
49
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
50
|
* columns keeping their own methods, BitPacking and RLE, since forcing only touches what ZSTD can store). The cost of
|
|
51
51
|
* reading a ZSTD column is not measured. tests/duck-wire.test.ts holds a string column to ZSTD.
|
|
52
|
+
*
|
|
53
|
+
* `lambda_syntax: ENABLE_SINGLE_ARROW`: the rewrite is a Postgres parse tree, printed by Postgres's deparser, which
|
|
54
|
+
* can spell DuckDB's lambda only as `x -> f(x)` (Postgres's `->` operator), never as `lambda x: f(x)`; DuckDB 2.0
|
|
55
|
+
* refuses that spelling by default. Two rewrites need a lambda: a jsonb's order under ICU or a libc locale, which maps
|
|
56
|
+
* DuckDB's `icu_sort_key` over the document's strings (src/duck/rewrite.ts `jsonbKey`; tests/duck-jsonb-order.test.ts
|
|
57
|
+
* holds the key under every collation), and a LEFT lateral's unnest of an expression, which computes the expression
|
|
58
|
+
* once with `list_transform(list_value(e), l -> CASE … l … END)[1]` (`rangeFunction`; tests/duck-wire.test.ts). A `->`
|
|
59
|
+
* that is not a lambda function's argument is still the json operator.
|
|
60
|
+
*
|
|
61
|
+
* Beside them, the copy's extensions (src/duck/extensions.ts `extensionSettings`): with Mason's package for this
|
|
62
|
+
* platform, `extension_directory` is that package and nothing is ever downloaded; without one, DuckDB's defaults.
|
|
52
63
|
*/
|
|
53
|
-
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd" };
|
|
64
|
+
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd", lambda_syntax: "ENABLE_SINGLE_ARROW" };
|
|
54
65
|
/**
|
|
55
66
|
* DUCKDB'S MEMORY, BOUNDED FOR THIS PROCESS: DuckDB's default is 80% of the machine's RAM, in a process
|
|
56
67
|
* that also holds V8's heap and two PGlites, which ends with the system killing every session and the sync together.
|
|
@@ -109,8 +120,8 @@ export async function openDuck(file, settings) {
|
|
|
109
120
|
catch (e) {
|
|
110
121
|
const m = e.message;
|
|
111
122
|
throw new Error(/lock/i.test(m)
|
|
112
|
-
? `
|
|
113
|
-
: `
|
|
123
|
+
? `Mason could not open the synced data ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
|
|
124
|
+
: `Mason could not open the synced data ${file}: ${m}. mason destroy deletes the synced data, and the next mason start syncs again`);
|
|
114
125
|
}
|
|
115
126
|
const writer = await instance.connect();
|
|
116
127
|
await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
@@ -131,27 +142,32 @@ export class DuckStore {
|
|
|
131
142
|
executor;
|
|
132
143
|
closed = null;
|
|
133
144
|
closeGraceMs;
|
|
134
|
-
|
|
145
|
+
/** where the copy's extensions come from: Mason's package for this platform, or DuckDB's install (src/duck/extensions.ts) */
|
|
146
|
+
extensions;
|
|
147
|
+
constructor(file, executor, memoryLimit, extensions, closeGraceMs) {
|
|
135
148
|
this.file = file;
|
|
136
149
|
this.executor = executor;
|
|
137
150
|
this.memoryLimitBytes = memoryLimit;
|
|
151
|
+
this.extensions = extensions;
|
|
138
152
|
this.closeGraceMs = closeGraceMs;
|
|
139
153
|
this.writer = executor.writer();
|
|
140
154
|
this.instance = { connect: () => executor.connect() };
|
|
141
155
|
}
|
|
142
156
|
/** `silentMs` and `closeGraceMs`: the executor's bounds (src/duck/executor.ts SILENT_MS, CLOSE_GRACE_MS), shorter only
|
|
143
|
-
* in a test
|
|
157
|
+
* in a test; `workerFile`, the executor worker's module, another only in a test; `extensions`, this machine's
|
|
158
|
+
* (`findExtensions`) unless a test names a package */
|
|
144
159
|
static async open(file, log, options = {}) {
|
|
160
|
+
const extensions = options.extensions ?? findExtensions();
|
|
145
161
|
const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
|
146
162
|
if (!MEMORY_LIMIT_RE.test(memoryLimit))
|
|
147
163
|
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
164
|
const where = path.resolve(file);
|
|
149
165
|
const before = opened.get(where);
|
|
150
166
|
if (before !== undefined && before.closed === null)
|
|
151
|
-
throw new Error(`
|
|
167
|
+
throw new Error(`Mason could not open the synced data ${file}: this process has it open already (Mason bug: report it)`);
|
|
152
168
|
// a copy whose thread was given up on may still hold the file: a second instance of it would lose writes
|
|
153
169
|
if (before?.abandoned != null)
|
|
154
|
-
throw new Error(`
|
|
170
|
+
throw new Error(`Mason could not open the synced data ${file}: this process gave up on the engine's thread before (${before.abandoned}), which may still hold it; start mason again (a new process opens it)`);
|
|
155
171
|
const mine = { closed: null, abandoned: null };
|
|
156
172
|
opened.set(where, mine);
|
|
157
173
|
let executor;
|
|
@@ -159,15 +175,15 @@ export class DuckStore {
|
|
|
159
175
|
if (before !== undefined)
|
|
160
176
|
await before.closed;
|
|
161
177
|
// 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 } : {}) });
|
|
178
|
+
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, ...extensionSettings(extensions), memory_limit: memoryLimit, temp_directory: `${file}.tmp` }, log,
|
|
179
|
+
...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}), ...(options.workerFile !== undefined ? { workerFile: options.workerFile } : {}) });
|
|
164
180
|
}
|
|
165
181
|
catch (e) {
|
|
166
182
|
if (opened.get(where) === mine)
|
|
167
183
|
opened.delete(where);
|
|
168
184
|
throw e;
|
|
169
185
|
}
|
|
170
|
-
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), options.closeGraceMs);
|
|
186
|
+
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), extensions, options.closeGraceMs);
|
|
171
187
|
// closed: the file may be opened again — unless its thread was given up on (`abandoned`), which keeps it here
|
|
172
188
|
store.closing = (p) => { mine.closed = p; void p.then(() => { if (executor.abandoned !== null)
|
|
173
189
|
mine.abandoned = executor.abandoned;
|
|
@@ -176,10 +192,12 @@ export class DuckStore {
|
|
|
176
192
|
return store;
|
|
177
193
|
}
|
|
178
194
|
closing = () => undefined;
|
|
179
|
-
/** called once if the
|
|
195
|
+
/** called once if the engine's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
|
|
180
196
|
onCrash(f) { this.executor.onCrash(f); }
|
|
181
|
-
/** how many vectors each Mason function was called with so far, on the
|
|
197
|
+
/** how many vectors each Mason function was called with so far, on the engine's thread (a test's, a measurement's) */
|
|
182
198
|
functionCalls() { return this.executor.functionCalls(); }
|
|
199
|
+
/** how many rows each Mason function was handed so far, on the engine's thread (a plan's cost in calls) */
|
|
200
|
+
functionRows() { return this.executor.functionRows(); }
|
|
183
201
|
/** a connection for one Postgres session (on the executor worker: SessionRunner) */
|
|
184
202
|
runner() { return this.executor.runner(); }
|
|
185
203
|
/**
|
|
@@ -211,11 +229,15 @@ export class DuckStore {
|
|
|
211
229
|
throw new Error(`the database orders text by the libc collation ${collation.collate}, and its order was not read (Mason bug: report it)`);
|
|
212
230
|
await this.executor.registerLibcOrder(libc);
|
|
213
231
|
}
|
|
232
|
+
// a jsonb's stored key is this collation's (src/duck/kept.ts `orderBy`)
|
|
233
|
+
await kept?.orderBy(c, c.kind === "libc" ? libc : undefined);
|
|
214
234
|
return {
|
|
215
235
|
collation: c,
|
|
216
236
|
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),
|
|
237
|
+
plan: (catalog, text, stmt, base, paramOids, searchPath, timeZone, userTypes, wireBound, grants) => planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, c, verified, userTypes, copies, 0, kept ?? null, wireBound ?? false, grants),
|
|
218
238
|
parameters: duckParameters,
|
|
239
|
+
// the sync's state, which EXPLAIN says how each copy is kept current from (src/duck/sync.ts KeptCurrent)
|
|
240
|
+
copies,
|
|
219
241
|
// a stored JSON path ready or gone (src/duck/kept.ts): the plans kept are made again
|
|
220
242
|
...(kept !== undefined ? { generation: () => kept.generation() } : {}),
|
|
221
243
|
};
|
|
@@ -260,21 +282,23 @@ export function pgErrorOf(err, sql) {
|
|
|
260
282
|
const kind = /^([A-Za-z ]+) Error: /.exec(full)?.[1] ?? "";
|
|
261
283
|
const message = full.replace(/^[A-Za-z ]+ Error: /, "").replace(/\n\n?LINE \d+:[\s\S]*$/, "").trim();
|
|
262
284
|
const pg = postgresWording(kind, message);
|
|
285
|
+
// a refusal raised inside DuckDB (a Mason function's, the rewrite's error() call) crossed it as its text alone: the
|
|
286
|
+
// entry its text names (gapIn), read here, the one place that text becomes a statement's error
|
|
263
287
|
if (pg !== null)
|
|
264
|
-
return new StatementError({
|
|
288
|
+
return new StatementError({ code: pg.code, message: pg.message, detail: `the engine: ${message}` }, sql, pg.gap ?? (pg.code === "0A000" ? gapIn(pg.message) : null));
|
|
265
289
|
const fn = /(?:Scalar|Aggregate|Table) Function with name "?([A-Za-z_][\w]*)"? does not exist/.exec(message)?.[1];
|
|
266
290
|
if (fn !== undefined) {
|
|
267
291
|
// what reaches DuckDB was verified against Postgres (src/duck/verified.ts), so a function DuckDB lacks is a gap in
|
|
268
292
|
// 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
|
|
270
|
-
hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `
|
|
293
|
+
return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason's engine has no ${fn}`,
|
|
294
|
+
hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `the engine: ${message}` });
|
|
271
295
|
}
|
|
272
296
|
const table = /Table with name "?([^"!\s]+)"? does not exist/.exec(message)?.[1];
|
|
273
297
|
if (table !== undefined) {
|
|
274
298
|
// DuckDB's miss comes after the catalog held the table (the plan passed): one dropped as no longer added, whose way
|
|
275
299
|
// back is `mason add`, so no copy state is read
|
|
276
300
|
return new StatementError({ code: "42P01", message: `relation "${table}" does not exist`,
|
|
277
|
-
hint: missingRelationHint(table, []), detail: `
|
|
301
|
+
hint: missingRelationHint(table, []), detail: `the engine: ${message}` });
|
|
278
302
|
}
|
|
279
303
|
const code = kind === "Parser" ? "42601"
|
|
280
304
|
: kind === "Catalog" ? "42P01"
|
|
@@ -294,13 +318,13 @@ export function pgErrorOf(err, sql) {
|
|
|
294
318
|
: kind === "Invalid Input" ? "22023"
|
|
295
319
|
: "XX000";
|
|
296
320
|
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
|
|
321
|
+
: code === "53200" ? "out of memory: this statement needs more than the memory Mason's engine may use, in a part that cannot spill to disk" : message,
|
|
298
322
|
// DuckDB's own advice is SET, which a client of Mason cannot do: Mason's way out instead
|
|
299
323
|
...(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
324
|
// what Mason ran (the statement as rewritten for DuckDB, src/duck/rewrite.ts): a report of the error needs it
|
|
301
|
-
detail: `
|
|
325
|
+
detail: `the engine${kind ? ` ${kind} error` : ""}: ${message}${sql === "" ? "" : `; Mason ran: ${oneLine(sql, 2000)}`}`,
|
|
302
326
|
// 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
|
|
327
|
+
...(code === "42803" ? { hint: "Postgres accepts this GROUP BY and Mason's rewrite did not carry it to the engine: report it with the statement; adding the column to the GROUP BY answers meanwhile" } : {}),
|
|
304
328
|
}, sql);
|
|
305
329
|
}
|
|
306
330
|
/** The errors DuckDB raises for what Postgres raises too, in Postgres's code and words (a tool or a person matches them). */
|
|
@@ -326,11 +350,11 @@ function postgresWording(kind, message) {
|
|
|
326
350
|
return { code: "22003", message: "numeric field overflow" };
|
|
327
351
|
// 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
352
|
if (/^Overflow in HUGEINT addition\b/.test(message))
|
|
329
|
-
return { code: "0A000", message: `a sum past
|
|
353
|
+
return { code: "0A000", message: `a sum past the engine's 38 digits is not supported by Mason (${gapMark("numeric-exact-sum")})`, gap: "numeric-exact-sum" };
|
|
330
354
|
// a numeric(p, s) read from a text holding NaN or Infinity (src/duck/rewrite.ts pgInput): DuckDB's decimal has none
|
|
331
355
|
const special = /Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.exec(message);
|
|
332
356
|
if (special)
|
|
333
|
-
return { code: "0A000", message: `a numeric ${special[1]} read from a text is not supported by Mason (${gapMark("numeric-special")})
|
|
357
|
+
return { code: "0A000", message: `a numeric ${special[1]} read from a text is not supported by Mason (${gapMark("numeric-special")})`, gap: "numeric-special" };
|
|
334
358
|
const conv = /Could not convert string '((?:[^']|'')*)' to (INT16|INT32|INT64|DOUBLE|FLOAT|DATE|TIMESTAMP|UUID|BOOL)/.exec(message);
|
|
335
359
|
if (conv) {
|
|
336
360
|
const t = { INT16: "smallint", INT32: "integer", INT64: "bigint", DOUBLE: "double precision", FLOAT: "real", DATE: "date", TIMESTAMP: "timestamp", UUID: "uuid", BOOL: "boolean" }[conv[2]];
|
|
@@ -349,12 +373,8 @@ function postgresWording(kind, message) {
|
|
|
349
373
|
return { code: "22P02", message };
|
|
350
374
|
return null;
|
|
351
375
|
}
|
|
352
|
-
/**
|
|
353
|
-
|
|
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;
|
|
376
|
+
/** when the wait for a statement to end is said in the log */
|
|
377
|
+
const ENDING_SLOW_MS = 5_000;
|
|
358
378
|
/** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
|
|
359
379
|
* on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
|
|
360
380
|
export class SessionRunner {
|
|
@@ -374,6 +394,7 @@ export class SessionRunner {
|
|
|
374
394
|
log;
|
|
375
395
|
zone = null;
|
|
376
396
|
path = null;
|
|
397
|
+
settings = null;
|
|
377
398
|
constructor(conn, log, slot, cancels) {
|
|
378
399
|
this.conn = conn;
|
|
379
400
|
this.log = log;
|
|
@@ -405,6 +426,13 @@ export class SessionRunner {
|
|
|
405
426
|
await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
|
|
406
427
|
this.path = path;
|
|
407
428
|
}
|
|
429
|
+
// what `current_setting(name)` reads (src/duck/rewrite.ts `settingRead`): the session's settings, as one map
|
|
430
|
+
const settings = JSON.stringify(session.settings);
|
|
431
|
+
if (settings !== this.settings) {
|
|
432
|
+
const entries = Object.entries(session.settings).map(([k, v]) => `${quoteLiteral(k)}: ${quoteLiteral(v)}`).join(", ");
|
|
433
|
+
await this.conn.run(`SET VARIABLE ${SETTINGS_VARIABLE} = MAP {${entries}}::MAP(VARCHAR, VARCHAR)`);
|
|
434
|
+
this.settings = settings;
|
|
435
|
+
}
|
|
408
436
|
}
|
|
409
437
|
async prepare(sql) {
|
|
410
438
|
try {
|
|
@@ -431,8 +459,9 @@ export class SessionRunner {
|
|
|
431
459
|
return p;
|
|
432
460
|
}
|
|
433
461
|
/** `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
|
-
|
|
462
|
+
* Mason's (the exact numerics, the encoding), ms — what an EXPLAIN ANALYZE says. `profile`: the file DuckDB's
|
|
463
|
+
* profiler writes this statement's profile to (EXPLAIN ANALYZE), turned on here, after the settle */
|
|
464
|
+
async streamed(planned, params, session, signal, fields, exact, profile = null) {
|
|
436
465
|
const sql = planned.text;
|
|
437
466
|
const seq = this.next;
|
|
438
467
|
await Promise.all([...this.releasing]);
|
|
@@ -445,18 +474,28 @@ export class SessionRunner {
|
|
|
445
474
|
catch (e) {
|
|
446
475
|
throw pgErrorOf(e, sql);
|
|
447
476
|
}
|
|
477
|
+
// THE PROFILER IS ON FOR THE STATEMENT ALONE: DuckDB writes the profile of the LAST statement it profiled on the
|
|
478
|
+
// connection, so it is turned on after every other statement Mason runs here (the settle's probe of the schemas,
|
|
479
|
+
// turned on before it, was reported as the plan of a count(*) that wrote no profile of its own), and the caller turns
|
|
480
|
+
// it off before it runs another (`explained`, which also holds the profile to the statement's text)
|
|
481
|
+
if (profile !== null) {
|
|
482
|
+
try {
|
|
483
|
+
await this.conn.run("SET enable_profiling = 'json'");
|
|
484
|
+
await this.conn.run(`SET profiling_output = ${quoteLiteral(profile)}`);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
throw pgErrorOf(e, sql);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
448
490
|
const timing = { prepareMs: 0, duckdbMs: 0, masonMs: 0 };
|
|
449
491
|
const t0 = performance.now();
|
|
450
492
|
const p = await this.prepare(sql);
|
|
451
493
|
timing.prepareMs = performance.now() - t0;
|
|
452
494
|
// HOW A STATEMENT IS CANCELLED (src/duck/functions.ts "every call carries its session"; the test
|
|
453
|
-
// server's hung process).
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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
|
|
495
|
+
// server's hung process). It is interrupted — its tasks stop at their next chunk — and, if it calls a Mason
|
|
496
|
+
// function, cancelled through its session as well: its next call raises, so a task of it does not call on. Its
|
|
497
|
+
// result is closed only once DuckDB ended the query, on a thread that is not this one (`cleanUp`: a task still
|
|
498
|
+
// inside a call waits for this thread, which a close would hold). Whether it calls
|
|
460
499
|
// one is the PLAN's (src/duck/plan.ts PlannedSql `functions`, read off the rewritten tree), never a match on its
|
|
461
500
|
// text: a stored JSON path's column and the view of a copy's visible columns are named `__mason_…` too, and
|
|
462
501
|
// read as calls, every `SELECT *` over such a copy was cancelled only by a call it never made (a review
|
|
@@ -481,7 +520,20 @@ export class SessionRunner {
|
|
|
481
520
|
const started = Date.now();
|
|
482
521
|
let total = 0;
|
|
483
522
|
let result = null;
|
|
484
|
-
let closed = false, fetching = false, released = false;
|
|
523
|
+
let closed = false, fetching = false, released = false, said = false;
|
|
524
|
+
/** DuckDB ended the query: a fetch failed, or read its end (`cleanUp` says why that is what `release` waits for) */
|
|
525
|
+
let ended = false;
|
|
526
|
+
let ending = false;
|
|
527
|
+
// ITS LINE IS SAID WHEN IT ENDS, before its client hears the end, never when its result is released: that waits for
|
|
528
|
+
// DuckDB to end it (`cleanUp`), by when its client is gone and the next session's statements are running, and a
|
|
529
|
+
// reader of the log took the late line for theirs (`tests/duck-wire.test.ts`, 1 run in 100 under load). The
|
|
530
|
+
// worker's port keeps the order: a line posted before the answer is read before it
|
|
531
|
+
const say = () => {
|
|
532
|
+
if (said)
|
|
533
|
+
return;
|
|
534
|
+
said = true;
|
|
535
|
+
this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
|
|
536
|
+
};
|
|
485
537
|
const release = () => {
|
|
486
538
|
if (released)
|
|
487
539
|
return;
|
|
@@ -491,52 +543,32 @@ export class SessionRunner {
|
|
|
491
543
|
signal.removeEventListener("abort", cancel);
|
|
492
544
|
result?.closeSync();
|
|
493
545
|
p.destroySync();
|
|
494
|
-
|
|
546
|
+
say();
|
|
495
547
|
};
|
|
496
|
-
// A
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
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();
|
|
548
|
+
// A STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED ONLY ONCE
|
|
549
|
+
// DuckDB ENDED IT OFF THIS THREAD (`cleanUp`), the session's next statement waiting for that (`releasing`). One read
|
|
550
|
+
// to its end has no task left, and is released at once
|
|
551
|
+
const endOffThisThread = () => {
|
|
552
|
+
if (released || ending)
|
|
505
553
|
return;
|
|
506
|
-
|
|
507
|
-
const done = (async () => { await this.cleanUp(seq, sql); release(); })();
|
|
554
|
+
ending = true;
|
|
555
|
+
const done = (async () => { await this.cleanUp(seq, sql, callsFunctions, ended ? null : result); release(); })();
|
|
508
556
|
this.releasing.add(done);
|
|
509
557
|
void done.finally(() => this.releasing.delete(done));
|
|
510
558
|
};
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
559
|
+
// it ended with an answer its client is about to hear: said now
|
|
560
|
+
const releaseAfterCleanup = () => { if (!released)
|
|
561
|
+
say(); endOffThisThread(); };
|
|
562
|
+
// a result closed before its end while no fetch is in flight: no end its client hears, so its line is its release's
|
|
514
563
|
const drain = () => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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));
|
|
564
|
+
// CLOSED WHILE DuckDB STILL STARTS IT (its `stream()` pending: an aggregate computes there whole): nothing is
|
|
565
|
+
// released under the pending start — that destroyed the prepared statement under it and, its release done, let the
|
|
566
|
+
// start's own failure skip `cleanUp`, so the connection and the instance closed while its other tasks still had
|
|
567
|
+
// calls queued for this thread, and the thread's end met them (Linux CI: `FATAL ERROR: Error::Error
|
|
568
|
+
// napi_define_properties`, 7 runs in 30 of a stop under a calling count). The start's own path releases it: its
|
|
569
|
+
// failure through `releaseAfterCleanup`, its answer drained once handed back (below `stream()`)
|
|
570
|
+
if (result !== null)
|
|
571
|
+
endOffThisThread();
|
|
540
572
|
};
|
|
541
573
|
// an early end by the listener (a portal closed half read, the session gone): cancelled, and released — by the
|
|
542
574
|
// fetch in flight once DuckDB hands the chunk back, else by `drain`
|
|
@@ -565,10 +597,22 @@ export class SessionRunner {
|
|
|
565
597
|
catch (e) {
|
|
566
598
|
throw failure(e);
|
|
567
599
|
}
|
|
600
|
+
// CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back, and drained. One that calls no function
|
|
601
|
+
// is told the cancel here, its release done first, before a column of a destroyed result is asked (DuckDB
|
|
602
|
+
// answers 0 columns for one: a review finding, an XX000 "report it" where the session had closed); one that calls
|
|
603
|
+
// holds its result open until DuckDB ended it, so its answer below is built from a live result and its fetch
|
|
604
|
+
// tells the cancel
|
|
605
|
+
if (closed) {
|
|
606
|
+
drain();
|
|
607
|
+
if (!callsFunctions) {
|
|
608
|
+
await Promise.all([...this.releasing]);
|
|
609
|
+
throw cancelled();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
568
612
|
timing.duckdbMs += performance.now() - t1;
|
|
569
613
|
const r = result;
|
|
570
614
|
if (fields !== null && r.columnCount < fields.length)
|
|
571
|
-
throw new StatementError({ code: "XX000", message: `
|
|
615
|
+
throw new StatementError({ code: "XX000", message: `the engine answered ${r.columnCount} column(s) where Postgres's analysis has ${fields.length}: report it (Mason's rewrite)` }, sql);
|
|
572
616
|
// Postgres's columns where its analysis gave them (a WITH TIES rank DuckDB adds after them is not sent)
|
|
573
617
|
const columns = fields !== null
|
|
574
618
|
? fields.map((f, i) => pgTypedColumn(f.name, f.oid, r.columnType(i), f.typmod))
|
|
@@ -598,11 +642,13 @@ export class SessionRunner {
|
|
|
598
642
|
chunk = await this.track(r.fetchChunk());
|
|
599
643
|
}
|
|
600
644
|
catch (e) {
|
|
645
|
+
ended = true;
|
|
601
646
|
throw failure(e);
|
|
602
647
|
}
|
|
603
648
|
finally {
|
|
604
649
|
fetching = false;
|
|
605
650
|
}
|
|
651
|
+
ended = chunk === null || chunk.rowCount === 0;
|
|
606
652
|
if (closed) {
|
|
607
653
|
drain();
|
|
608
654
|
return null;
|
|
@@ -649,57 +695,35 @@ export class SessionRunner {
|
|
|
649
695
|
* EXPLAIN of a plan (src/duck/explain.ts). Without ANALYZE: DuckDB's `EXPLAIN (FORMAT JSON)` of the planned SQL, its
|
|
650
696
|
* parameters bound. With it: the statement RUN as `run` runs it — every chunk read, its exact numerics computed and
|
|
651
697
|
* 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
|
|
698
|
+
* Then the copies its scans read: their rows, and how each is kept current as the sync says it (`how.copies`).
|
|
653
699
|
*/
|
|
654
700
|
explain(sql, params, session, signal, how) {
|
|
655
701
|
return this.track(this.explained(sql, params, session, signal, how));
|
|
656
702
|
}
|
|
657
703
|
async explained(sql, params, session, signal, how) {
|
|
658
|
-
await Promise.all([...this.releasing]);
|
|
659
704
|
const o = how.options;
|
|
705
|
+
// A STATEMENT THAT READS A FILTERED TABLE IS NEVER SHOWN TO THE ENGINE: its plan says what it proved from the table's
|
|
706
|
+
// statistics (docs/DESIGN.md "Roles: rows per user"), so the tables are the statement's and its filters' (a through:'s
|
|
707
|
+
// parents), and the rest is Mason's plan
|
|
708
|
+
if ((how.rowFilters ?? []).length > 0) {
|
|
709
|
+
const tables = [...new Set([...(how.reads ?? []), ...(how.rowFilters ?? []).map((f) => f.table)])].map((t) => { const dot = t.indexOf("."); return { schema: t.slice(0, dot), name: t.slice(dot + 1) }; });
|
|
710
|
+
const explained = { copies: await this.copies(tables, how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
711
|
+
functions: [...sql.functions], plan: [], filtered: true };
|
|
712
|
+
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
713
|
+
}
|
|
714
|
+
await Promise.all([...this.releasing]);
|
|
660
715
|
let plan;
|
|
661
716
|
let analyzed;
|
|
662
|
-
if (!o.analyze)
|
|
663
|
-
|
|
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
|
-
}
|
|
717
|
+
if (!o.analyze)
|
|
718
|
+
plan = await this.plainPlan(sql, params, session, signal);
|
|
693
719
|
else {
|
|
694
720
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mason-explain-"));
|
|
695
721
|
const file = path.join(dir, "profile.json");
|
|
696
722
|
try {
|
|
697
723
|
let rows = 0;
|
|
698
724
|
let timing;
|
|
699
|
-
await this.conn.run("SET enable_profiling = 'json'");
|
|
700
725
|
try {
|
|
701
|
-
await this.
|
|
702
|
-
const s = await this.streamed(sql, params, session, signal, how.fields, how.exact);
|
|
726
|
+
const s = await this.streamed(sql, params, session, signal, how.fields, how.exact, file);
|
|
703
727
|
timing = s.timing;
|
|
704
728
|
try {
|
|
705
729
|
for (let chunk = await s.answer.fetch([], { timeZone: session.timeZone }); chunk !== null; chunk = await s.answer.fetch([], { timeZone: session.timeZone }))
|
|
@@ -710,70 +734,158 @@ export class SessionRunner {
|
|
|
710
734
|
}
|
|
711
735
|
}
|
|
712
736
|
finally {
|
|
737
|
+
await Promise.all([...this.releasing]);
|
|
713
738
|
await this.conn.run("PRAGMA disable_profiling");
|
|
714
739
|
}
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
740
|
+
// THE PROFILE IS HELD TO THE STATEMENT, never assumed its: DuckDB writes one when the result is read to its end,
|
|
741
|
+
// naming the statement it timed. None: DuckDB ran no operator (it answered while planning — a count(*) of a
|
|
742
|
+
// whole table is the table's row count), and its plan is its EXPLAIN, the rows the ones it answered
|
|
743
|
+
const profile = fs.existsSync(file) ? profiled(JSON.parse(fs.readFileSync(file, "utf8"))) : null;
|
|
744
|
+
if (profile !== null && profile.sql !== sql.text) {
|
|
745
|
+
throw new StatementError({ code: "XX000", message: `the engine's profile of an EXPLAIN ANALYZE is of another statement (${oneLine(profile.sql ?? "none named")}): report it (Mason's EXPLAIN)` }, sql.text);
|
|
746
|
+
}
|
|
747
|
+
if (profile !== null)
|
|
748
|
+
plan = profile.plan;
|
|
749
|
+
else {
|
|
750
|
+
plan = await this.plainPlan(sql, params, session, signal);
|
|
751
|
+
if (plan[0] !== undefined)
|
|
752
|
+
plan[0].actualRows = rows;
|
|
753
|
+
}
|
|
754
|
+
analyzed = { rows, profiled: profile !== null, planning: { ...how.planning, duckdbMs: timing.prepareMs }, execution: { duckdbMs: timing.duckdbMs, masonMs: timing.masonMs } };
|
|
718
755
|
}
|
|
719
756
|
finally {
|
|
720
757
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
721
758
|
}
|
|
722
759
|
}
|
|
723
760
|
// 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),
|
|
761
|
+
const explained = { copies: await this.copies(scannedTables(plan), how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
725
762
|
functions: [...sql.functions], plan, ...(analyzed === undefined ? {} : { analyzed }) };
|
|
726
763
|
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
727
764
|
}
|
|
728
|
-
/**
|
|
729
|
-
async
|
|
730
|
-
|
|
765
|
+
/** DuckDB's `EXPLAIN (FORMAT JSON)` of the planned SQL, its parameters bound: its plan, estimates only */
|
|
766
|
+
async plainPlan(sql, params, session, signal) {
|
|
767
|
+
try {
|
|
768
|
+
await this.settle(session);
|
|
769
|
+
}
|
|
770
|
+
catch (e) {
|
|
771
|
+
throw pgErrorOf(e, sql.text);
|
|
772
|
+
}
|
|
773
|
+
const p = await this.prepare(`EXPLAIN (FORMAT JSON) ${sql.text}`);
|
|
774
|
+
const interrupt = () => this.conn.interrupt();
|
|
775
|
+
signal.addEventListener("abort", interrupt);
|
|
776
|
+
try {
|
|
777
|
+
if (signal.aborted)
|
|
778
|
+
throw failureOf(signal, null, sql.text);
|
|
779
|
+
params.forEach((v, i) => { if (v === null)
|
|
780
|
+
p.bindNull(i + 1);
|
|
781
|
+
else
|
|
782
|
+
p.bindVarchar(i + 1, v); });
|
|
783
|
+
let r;
|
|
784
|
+
try {
|
|
785
|
+
r = await p.runAndReadAll();
|
|
786
|
+
}
|
|
787
|
+
catch (e) {
|
|
788
|
+
throw failureOf(signal, e, sql.text);
|
|
789
|
+
}
|
|
790
|
+
return planNodes(JSON.parse(String(r.getRowsJS()[0]?.[1] ?? "[]")));
|
|
791
|
+
}
|
|
792
|
+
finally {
|
|
793
|
+
signal.removeEventListener("abort", interrupt);
|
|
794
|
+
p.destroySync();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** each copy's rows, and the sync's facts of it (how it is kept current: src/duck/sync.ts `KeptCurrent`, never the
|
|
798
|
+
* poll's ledger read here — a project switched to logical replication still holds its last poll there) */
|
|
799
|
+
async copies(tables, facts, filters = []) {
|
|
731
800
|
const out = [];
|
|
732
|
-
for (const
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
801
|
+
for (const { schema, name } of tables) {
|
|
802
|
+
// the sync's source is the two names as they are; EXPLAIN says the table as a statement writes it (qualifiedName)
|
|
803
|
+
const filter = filters.find((f) => f.table === `${schema}.${name}`)?.words;
|
|
804
|
+
// a table the role filters: its counts are every row's, not the role's, and are never read or said here
|
|
805
|
+
const rows = filter !== undefined ? null : (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];
|
|
806
|
+
out.push({ table: qualifiedName({ schema, name }), rows: rows === undefined || rows === null ? null : Number(rows), facts: facts.find((f) => f.source === `${schema}.${name}`) ?? null,
|
|
807
|
+
...(filter !== undefined ? { filter } : {}) });
|
|
737
808
|
}
|
|
738
809
|
return out;
|
|
739
810
|
}
|
|
740
811
|
/**
|
|
741
|
-
* A
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
* `
|
|
812
|
+
* A STATEMENT THAT DID NOT RUN TO ITS END, ENDED BY DuckDB OFF THIS THREAD before its result is closed. THIS THREAD
|
|
813
|
+
* NEVER WAITS FOR THE ENGINE'S TASKS: every close is synchronous here, a close of an open result waits for the query's
|
|
814
|
+
* tasks (Executor::CancelTasks), and this thread runs every Mason function, so a task inside a call waits for it (the
|
|
815
|
+
* suite hung for good at 0 % CPU; `tests/duck-executor.test.ts` "a vector slower than"). Waiting for quiet instead —
|
|
816
|
+
* no call for 50 ms, as this did — was no proof: a vector whose native work outlasts the window has made no call yet.
|
|
817
|
+
* And a call-free statement's tasks hold this thread as long as DuckDB's vector takes, with every session behind it.
|
|
818
|
+
*
|
|
819
|
+
* WHAT WAITS IS A FETCH (read in DuckDB 2.0's source, `ClientContext::EndQueryInternal`, and held by the test above).
|
|
820
|
+
* Every end of a query runs `CancelTasks` on the thread that ends it, and a fetch runs on libuv's pool. Interrupted
|
|
821
|
+
* first, the next fetch does not compute: it fails at once (`BufferedData::Participate`), ending the query there, its
|
|
822
|
+
* tasks waited for while this thread answers their calls; or it reads the end, or the end was read already (`r`
|
|
823
|
+
* null). So the wait is bounded by DuckDB's next chunk, on a statement that never ends by itself too, and an EMPTY chunk
|
|
824
|
+
* is the end, as `fetch` reads it: past its end a streamed result hands back empty chunks for ever, never null.
|
|
825
|
+
*
|
|
826
|
+
* THE PROBE PROVES IT (a statement performs no wait: while a result holds the connection DuckDB refuses it, "connection
|
|
827
|
+
* has an open result", before anything else). So a `SELECT 1` that ran is proof that nothing of the statement is left,
|
|
828
|
+
* and the close finds nothing to wait for. Refused, the reading above no longer holds: said as a Mason bug, and closed
|
|
829
|
+
* all the same, the watch (src/duck/executor.ts SILENT_MS) catching the deadlock if there is one. A wait past
|
|
830
|
+
* `ENDING_SLOW_MS` is said once.
|
|
749
831
|
*/
|
|
750
|
-
async cleanUp(seq, sql) {
|
|
751
|
-
|
|
832
|
+
async cleanUp(seq, sql, calls, r) {
|
|
833
|
+
if (calls)
|
|
834
|
+
cancelStatement(this.cancels, this.slot, seq);
|
|
752
835
|
this.conn.interrupt();
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
836
|
+
const slow = setTimeout(() => this.log(`a statement that did not run to its end has not stopped after ${ENDING_SLOW_MS / 1000} s; its result is closed once the engine ended it — ${oneLine(sql)}`), ENDING_SLOW_MS);
|
|
837
|
+
try {
|
|
838
|
+
if (r !== null) {
|
|
839
|
+
try {
|
|
840
|
+
for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
// the cancel's own refusal (the interrupt, or its next call raised, 57014) is what this waits for. Anything else
|
|
844
|
+
// is the statement's real error, met after the listener let go of its answer: no one reads it any more, and a
|
|
845
|
+
// rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
|
|
846
|
+
if (pgErrorOf(e, sql).fields.code !== "57014")
|
|
847
|
+
this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
848
|
+
}
|
|
766
849
|
}
|
|
850
|
+
// an abort landing now interrupts the probe past DuckDB's check, which is its proof all the same
|
|
851
|
+
try {
|
|
852
|
+
await this.conn.run("SELECT 1");
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
if (/open result/.test(String(e)))
|
|
856
|
+
this.log(`a statement's result is still open after the engine failed or ended it (Mason bug, report it with mason logs): closed all the same — ${oneLine(sql)}`);
|
|
857
|
+
else if (pgErrorOf(e, "SELECT 1").fields.code !== "57014")
|
|
858
|
+
this.log(`the check that a statement ended failed: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
finally {
|
|
862
|
+
clearTimeout(slow);
|
|
767
863
|
}
|
|
768
864
|
}
|
|
769
865
|
/** Closes the connection once nothing runs on it: an answer left open drained (its statement cancelled), every
|
|
770
866
|
* answer released and every call of DuckDB settled (never under the executor: `drain`; a connection
|
|
771
867
|
* closed under a drain's pending fetch aborted the process). */
|
|
772
868
|
async close() {
|
|
773
|
-
this
|
|
869
|
+
// an answer left open is closed and drained — and one a statement still STARTING opens while this waits (the
|
|
870
|
+
// session closed between its ask and its answer: left open, the connection closed under it and its running tasks)
|
|
871
|
+
for (let last = null;;) {
|
|
872
|
+
const open = this.openAnswer;
|
|
873
|
+
if (open !== null && open !== last) {
|
|
874
|
+
last = open;
|
|
875
|
+
open();
|
|
876
|
+
}
|
|
877
|
+
await this.idle();
|
|
878
|
+
// released (null), or one already closed: done; a new one opened meanwhile: closed in turn
|
|
879
|
+
if (this.openAnswer === null || this.openAnswer === last)
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
this.conn.closeSync();
|
|
883
|
+
}
|
|
884
|
+
/** Settles once nothing runs on the connection: every answer released and every call of DuckDB settled (an answer
|
|
885
|
+
* closed early is answered then: src/duck/executor-worker.ts `answer.close`). */
|
|
886
|
+
async idle() {
|
|
774
887
|
for (let waiting = [...this.busy, ...this.releasing]; waiting.length > 0; waiting = [...this.busy, ...this.releasing])
|
|
775
888
|
await Promise.allSettled(waiting);
|
|
776
|
-
this.conn.closeSync();
|
|
777
889
|
}
|
|
778
890
|
}
|
|
779
891
|
function exactColumns(columns, fields, exact, typeAt) {
|
|
@@ -782,7 +894,7 @@ function exactColumns(columns, fields, exact, typeAt) {
|
|
|
782
894
|
const computed = exact.filter((e) => {
|
|
783
895
|
const unreadable = readers.some((r, j) => r === null && programReads(e, j));
|
|
784
896
|
if (unreadable)
|
|
785
|
-
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it
|
|
897
|
+
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it the engine computed as a double` };
|
|
786
898
|
return !unreadable;
|
|
787
899
|
});
|
|
788
900
|
if (computed.length === 0)
|
|
@@ -806,7 +918,7 @@ function exactRows(computed, rows, base, sql, params) {
|
|
|
806
918
|
}
|
|
807
919
|
catch (err) {
|
|
808
920
|
if (err instanceof NumericError)
|
|
809
|
-
throw new StatementError({ code: err.sqlstate, message: err.message }, sql);
|
|
921
|
+
throw new StatementError({ code: err.sqlstate, message: err.message }, sql, err.gap);
|
|
810
922
|
throw err;
|
|
811
923
|
}
|
|
812
924
|
}
|
|
@@ -817,7 +929,7 @@ function exactRows(computed, rows, base, sql, params) {
|
|
|
817
929
|
function computedOf(exact, fields) {
|
|
818
930
|
return exact.map((e) => {
|
|
819
931
|
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
|
|
932
|
+
return `column ${e.column + 1}${fields?.[e.column] === undefined ? "" : ` "${fields[e.column].name}"`}: Postgres's exact numeric${e.text ? " as text" : ""}, from ${cells} of the engine's value(s)`;
|
|
821
933
|
});
|
|
822
934
|
}
|
|
823
935
|
/** whether the column's program reads cell j */
|