@mason-data/cli 0.7.0 → 0.9.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 +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- 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 +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -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 +12 -4
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
|
-
import { StatementError } from "
|
|
24
|
-
import { duckParameters, missingRelationHint, planDuck, quoteDuckKeywords } from "./plan.js";
|
|
22
|
+
import { StatementError } from "./statement.js";
|
|
23
|
+
import { duckParameters, missingRelationHint, ordersText, planDuck, quoteDuckKeywords } from "./plan.js";
|
|
25
24
|
import { VERIFIED } from "./verified.js";
|
|
26
|
-
import {
|
|
27
|
-
import { duckCollation } from "./
|
|
28
|
-
import { decodeLibcOrder, encodeLibcOrder } from "./libc-order.js";
|
|
25
|
+
import { registerFunctions, SESSION_VARIABLE, SETTINGS_VARIABLE } from "./functions.js";
|
|
26
|
+
import { caseMappingOf, CollationResolver, duckCollation, isLibcCollation } from "./collation.js";
|
|
27
|
+
import { decodeLibcOrder, encodeLibcOrder, isCurrentLibcOrder } 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";
|
|
32
|
-
import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
|
|
30
|
+
import { gapIn, gapMark } from "./gaps.js";
|
|
31
|
+
import { cellsOf, evaluateText, fieldOverflow, NumericError } from "../lib/pg-numeric.js";
|
|
33
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();
|
|
@@ -57,25 +57,102 @@ export const DUCKDB_VERSION = version();
|
|
|
57
57
|
* holds the key under every collation), and a LEFT lateral's unnest of an expression, which computes the expression
|
|
58
58
|
* once with `list_transform(list_value(e), l -> CASE … l … END)[1]` (`rangeFunction`; tests/duck-wire.test.ts). A `->`
|
|
59
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.
|
|
60
63
|
*/
|
|
61
64
|
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd", lambda_syntax: "ENABLE_SINGLE_ARROW" };
|
|
62
65
|
/**
|
|
63
66
|
* DUCKDB'S MEMORY, BOUNDED FOR THIS PROCESS: DuckDB's default is 80% of the machine's RAM, in a process
|
|
64
67
|
* that also holds V8's heap and two PGlites, which ends with the system killing every session and the sync together.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
+
* Unless `MASON_DUCKDB_MEMORY` says (`8GB`, `512MiB`), half the memory, or what leaves `OUTSIDE_RESERVE_BYTES` beside
|
|
69
|
+
* it when that is less (`defaultMemoryLimit`); past it a sort, a join or an aggregate spills to `<file>.tmp` (DuckDB's
|
|
70
|
+
* own default, named here so it is the copy's folder), and what cannot spill fails ITS statement with 53200 and the way
|
|
71
|
+
* out (pgErrorOf), the run going on.
|
|
72
|
+
*
|
|
73
|
+
* THE ROOM OUTSIDE THE LIMIT (issue 301, docs/DESIGN.md "Memory: the room outside the engine's limit"): the limit counts
|
|
74
|
+
* DuckDB's buffers, never Node's heap, the catalog's PGlite, the pg-eval PGlite nor what DuckDB allocates outside its
|
|
75
|
+
* buffer manager, and with half of a 1, 1.5, 2 or 3 GiB container that room was too small: the kernel killed the run in
|
|
76
|
+
* each. Measured with the limit at 256MiB, the process reached 2.3 GiB beside it (the pg-eval PGlite alone ~0.5 GiB of
|
|
77
|
+
* WebAssembly memory once a statement handed it 1.5 M values, the catalog's ~0.26 GiB). `OUTSIDE_RESERVE_BYTES` is that
|
|
78
|
+
* peak with a margin; `MEMORY_FLOOR_BYTES` the smallest container the whole path (a first sync, a generated table's
|
|
79
|
+
* build, writes on the stream, statements through the listener) ran in under this default. Under the floor, with
|
|
80
|
+
* `MASON_DUCKDB_MEMORY` unset, a start goes on and WARNS (`memoryWarning`: the run's start, mason status, mason doctor),
|
|
81
|
+
* its statements given MIN_SHARE_BYTES, the least room for them and the most for the rest (Tom's decision on PR 382: a
|
|
82
|
+
* warning, never a refusal); set, the variable is the operator's word at any size.
|
|
83
|
+
*/
|
|
84
|
+
export const OUTSIDE_RESERVE_BYTES = 2560 * 2 ** 20;
|
|
85
|
+
export const MEMORY_FLOOR_BYTES = 3072 * 2 ** 20;
|
|
86
|
+
/** the statements' share where the reserve leaves less (under the floor, where a start warns): what let the run last
|
|
87
|
+
* longest, measured (docs/DESIGN.md): at 2 GiB the whole path ran with it, as with 256MiB, and at 1.5 GiB it answered
|
|
88
|
+
* every statement before the kill where 256MiB was killed in the writes; the same statements answered at both, one
|
|
89
|
+
* wide aggregate failing alone (53200), and the old quarter, 512MiB, was killed at 2 GiB */
|
|
90
|
+
export const MIN_SHARE_BYTES = 128 * 2 ** 20;
|
|
91
|
+
/**
|
|
92
|
+
* The default memory limit for `totalBytes` of memory, in whole MiB (mason doctor says it for any machine): half of it,
|
|
93
|
+
* the page cache keeping the other half, or what leaves OUTSIDE_RESERVE_BYTES beside it when that is less (from 5 GiB
|
|
94
|
+
* up it is half), never under MIN_SHARE_BYTES (from 2.625 GiB down: under the floor, where a start warns), nor past half.
|
|
95
|
+
*/
|
|
96
|
+
export function defaultMemoryLimit(totalBytes) {
|
|
97
|
+
const mib = 2 ** 20;
|
|
98
|
+
const half = Math.floor(totalBytes / 2 / mib), room = Math.floor((totalBytes - OUTSIDE_RESERVE_BYTES) / mib);
|
|
99
|
+
return `${Math.min(half, Math.max(MIN_SHARE_BYTES / mib, room))}MiB`;
|
|
100
|
+
}
|
|
101
|
+
/** A size as a reader recognises it: GiB to one decimal from 1 GiB up, else MiB. */
|
|
102
|
+
export function memorySize(bytes) {
|
|
103
|
+
return bytes >= 2 ** 30 ? `${(bytes / 2 ** 30).toFixed(1)} GiB` : `${Math.round(bytes / 2 ** 20)} MiB`;
|
|
104
|
+
}
|
|
105
|
+
/** `MASON_DUCKDB_MEMORY` as it is set: null unset (or set empty, as the run reads it) */
|
|
106
|
+
function memorySet(env) {
|
|
107
|
+
const v = env.MASON_DUCKDB_MEMORY;
|
|
108
|
+
return v === undefined || v === "" ? null : v;
|
|
109
|
+
}
|
|
110
|
+
/** `MASON_DUCKDB_MEMORY` that is not an amount: the one text, for the store's open, mason serve, mason start and doctor */
|
|
111
|
+
export function memorySettingWrong(text) {
|
|
112
|
+
return { message: `MASON_DUCKDB_MEMORY=${JSON.stringify(text)} is not an amount of memory`,
|
|
113
|
+
why: "it is the memory the synced data's statements may use before they spill to disk",
|
|
114
|
+
fix: `write it as 8GB or 512MiB, or unset it for the default (${DEFAULT_MEMORY_LIMIT} here: ${DEFAULT_MEMORY_RULE})` };
|
|
115
|
+
}
|
|
116
|
+
/** THE DEFAULT'S RULE IN WORDS, the one text every message, help and guide that states the default says (a fact derived
|
|
117
|
+
* in several places: PR 380's first-sync refusal said "half the machine's RAM" after the default had changed);
|
|
118
|
+
* `defaultMemoryLimit` is the rule, and `tests/conventions.test.ts` holds src/ to saying it only through this */
|
|
119
|
+
export const DEFAULT_MEMORY_RULE = `half the container's memory limit, else of the machine's RAM, or what leaves ${memorySize(OUTSIDE_RESERVE_BYTES)} ` +
|
|
120
|
+
`beside it when that is less, never under ${memorySize(MIN_SHARE_BYTES)}`;
|
|
121
|
+
/**
|
|
122
|
+
* WHAT A START REFUSES OF THE MEMORY (mason serve's one report; mason start and mason doctor take the first):
|
|
123
|
+
* `MASON_DUCKDB_MEMORY` that is not an amount. A size under the floor is never refused: it is `memoryWarning`'s.
|
|
124
|
+
*/
|
|
125
|
+
export function memoryProblems(env) {
|
|
126
|
+
const set = memorySet(env);
|
|
127
|
+
return set !== null && !MEMORY_LIMIT_RE.test(set) ? [memorySettingWrong(set)] : [];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* THE ONE WARNING OF THE MEMORY: `memory` under MEMORY_FLOOR_BYTES while `MASON_DUCKDB_MEMORY` is unset (or not an
|
|
131
|
+
* amount, which is refused beside it). The start goes on; the run says it at its start and in what mason status reads
|
|
132
|
+
* (its warnings), and mason doctor's memory line says it, each from this text alone. `memory` is `machineMemory()`.
|
|
68
133
|
*/
|
|
69
|
-
|
|
70
|
-
|
|
134
|
+
export function memoryWarning(memory, env) {
|
|
135
|
+
const set = memorySet(env);
|
|
136
|
+
if ((set !== null && MEMORY_LIMIT_RE.test(set)) || memory.bytes >= MEMORY_FLOOR_BYTES)
|
|
137
|
+
return null;
|
|
138
|
+
const size = memorySize(memory.bytes);
|
|
139
|
+
return { message: `${size} of memory is under the ${memorySize(MEMORY_FLOOR_BYTES)} Mason needs`,
|
|
140
|
+
why: `${size} is ${memory.from === "container" ? "the container's memory limit" : "the machine's RAM"}; ` +
|
|
141
|
+
`beside what the synced data's statements may use, Mason itself needs up to ${memorySize(OUTSIDE_RESERVE_BYTES)} (measured, with a margin), ` +
|
|
142
|
+
"and with less the system may stop the whole run, every session with it, when a first sync, a burst of writes or a statement needs the memory",
|
|
143
|
+
fix: `give it ${memorySize(MEMORY_FLOOR_BYTES)} or more (the container's memory limit), or set MASON_DUCKDB_MEMORY=<n> ` +
|
|
144
|
+
"to choose the statements' share yourself, the rest left to Mason (mason doctor says what it leaves)" };
|
|
145
|
+
}
|
|
146
|
+
/** a problem or the warning as one line: mason serve's settings report, the run's warning, mason doctor's detail */
|
|
147
|
+
export function memoryProblemText(p) { return `${p.message}: ${p.why} — ${p.fix}`; }
|
|
71
148
|
/**
|
|
72
149
|
* THE MEMORY THIS PROCESS MAY USE: the machine's RAM, or the container's limit where one is set — a cgroup v2
|
|
73
150
|
* `memory.max` or a cgroup v1 `memory.limit_in_bytes` below it. `os.totalmem()` is the HOST's RAM inside a container,
|
|
74
151
|
* so half of it was more than a limited container had, and the kernel killed the run instead of DuckDB spilling
|
|
75
152
|
* (the review of the cut). `read` is the test's.
|
|
76
153
|
*/
|
|
77
|
-
export function
|
|
78
|
-
let limit = total;
|
|
154
|
+
export function machineMemory(read = (f) => fs.readFileSync(f, "utf8"), total = os.totalmem()) {
|
|
155
|
+
let limit = { bytes: total, from: "machine" };
|
|
79
156
|
for (const file of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
|
|
80
157
|
let text;
|
|
81
158
|
try {
|
|
@@ -86,12 +163,35 @@ export function machineMemoryBytes(read = (f) => fs.readFileSync(f, "utf8"), tot
|
|
|
86
163
|
} // not a cgroup of that version, or not a container: the machine's RAM
|
|
87
164
|
// v2 says `max` for no limit; v1 says a number near 2^63; either way a number below the RAM is the limit
|
|
88
165
|
const n = Number(text);
|
|
89
|
-
if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit)
|
|
90
|
-
limit = n;
|
|
166
|
+
if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit.bytes)
|
|
167
|
+
limit = { bytes: n, from: "container" };
|
|
91
168
|
}
|
|
92
169
|
return limit;
|
|
93
170
|
}
|
|
171
|
+
/** `machineMemory`'s bytes: the one size the default limit and the floor are read against */
|
|
172
|
+
export function machineMemoryBytes(read, total) { return machineMemory(read, total).bytes; }
|
|
94
173
|
export const DEFAULT_MEMORY_LIMIT = defaultMemoryLimit(machineMemoryBytes());
|
|
174
|
+
/**
|
|
175
|
+
* WHAT STATEMENTS MAY SPILL (issue 105: a 100-row preview's sort took every free byte of a volume, and the sync's room
|
|
176
|
+
* with it). DuckDB's default ceiling is "90% of available disk space", and what it answered when the disk ran out first
|
|
177
|
+
* was `XX000` with a path. Here the ceiling is HALF of the room statements have, what is free where the copy is and what
|
|
178
|
+
* they have spilled there (the disk's free bytes exclude the spill, so a ceiling of the free bytes alone would grow with
|
|
179
|
+
* each read as the spill grows, and end near the whole disk). The sync's writes keep the other half whatever statements
|
|
180
|
+
* do, and it follows the disk (`DuckStore.keepSpillCeiling`, every `SPILL_REFRESH_MS`): a copy that grows narrows it.
|
|
181
|
+
* `MASON_SPILL_LIMIT` sets the amount instead, never past 90% of that room. Spilled files are shared by the statements
|
|
182
|
+
* running, so two at once share the ceiling. Past it the statement that needed the room fails alone, 53400 as past
|
|
183
|
+
* Postgres's temp_file_limit, with the way out (pgErrorOf), and its files are gone with it; a disk that is really full
|
|
184
|
+
* is 53100. A sizing decision reads `store.spillLimitBytes`, as it reads `store.memoryLimitBytes`.
|
|
185
|
+
*/
|
|
186
|
+
const SPILL_FREE_SHARE = 0.5, SPILL_FREE_MOST = 0.9;
|
|
187
|
+
const SPILL_REFRESH_MS = 15_000;
|
|
188
|
+
export function spillCeiling(freeBytes, spilledBytes, setBytes) {
|
|
189
|
+
const room = freeBytes + spilledBytes;
|
|
190
|
+
return Math.max(2 ** 20, Math.floor(setBytes === null ? room * SPILL_FREE_SHARE : Math.min(setBytes, room * SPILL_FREE_MOST)));
|
|
191
|
+
}
|
|
192
|
+
/** the bytes free where `dir` is: the one reading of it (the sync's disk check, the spill's ceiling) */
|
|
193
|
+
export function freeBytesAt(dir) { const st = fs.statfsSync(dir); return st.bavail * st.bsize; }
|
|
194
|
+
const spillSetting = (bytes) => `${Math.floor(bytes / 2 ** 20)}MiB`;
|
|
95
195
|
export const MEMORY_LIMIT_RE = /^\s*(\d+(?:\.\d+)?)\s*(KB|MB|GB|TB|KiB|MiB|GiB|TiB)\s*$/i;
|
|
96
196
|
/** 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 */
|
|
97
197
|
export function memoryLimitBytes(text) {
|
|
@@ -117,8 +217,8 @@ export async function openDuck(file, settings) {
|
|
|
117
217
|
catch (e) {
|
|
118
218
|
const m = e.message;
|
|
119
219
|
throw new Error(/lock/i.test(m)
|
|
120
|
-
? `
|
|
121
|
-
: `
|
|
220
|
+
? `Mason could not open the synced data ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
|
|
221
|
+
: `Mason could not open the synced data ${file}: ${m}. mason destroy deletes the synced data, and the next mason start syncs again`);
|
|
122
222
|
}
|
|
123
223
|
const writer = await instance.connect();
|
|
124
224
|
await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
@@ -136,30 +236,54 @@ export class DuckStore {
|
|
|
136
236
|
file;
|
|
137
237
|
/** the memory limit every connection shares, in bytes */
|
|
138
238
|
memoryLimitBytes;
|
|
239
|
+
/** what statements may spill now, in bytes (`spillCeiling`): it follows the disk */
|
|
240
|
+
spillLimitBytes;
|
|
241
|
+
spillSet;
|
|
242
|
+
diskFree;
|
|
243
|
+
spillTimer;
|
|
244
|
+
spillLane = null;
|
|
245
|
+
spillKept = null;
|
|
246
|
+
log;
|
|
139
247
|
executor;
|
|
140
248
|
closed = null;
|
|
141
249
|
closeGraceMs;
|
|
142
|
-
|
|
250
|
+
/** where the copy's extensions come from: Mason's package for this platform, or DuckDB's install (src/duck/extensions.ts) */
|
|
251
|
+
extensions;
|
|
252
|
+
constructor(file, executor, memoryLimit, extensions, spill, log, closeGraceMs) {
|
|
143
253
|
this.file = file;
|
|
144
254
|
this.executor = executor;
|
|
145
255
|
this.memoryLimitBytes = memoryLimit;
|
|
256
|
+
this.extensions = extensions;
|
|
146
257
|
this.closeGraceMs = closeGraceMs;
|
|
258
|
+
this.spillLimitBytes = spill.limit;
|
|
259
|
+
this.spillSet = spill.set;
|
|
260
|
+
this.diskFree = spill.diskFree;
|
|
261
|
+
this.log = log;
|
|
262
|
+
this.spillTimer = setInterval(() => { void this.keepSpillCeiling(); }, SPILL_REFRESH_MS);
|
|
263
|
+
this.spillTimer.unref();
|
|
147
264
|
this.writer = executor.writer();
|
|
148
265
|
this.instance = { connect: () => executor.connect() };
|
|
149
266
|
}
|
|
150
267
|
/** `silentMs` and `closeGraceMs`: the executor's bounds (src/duck/executor.ts SILENT_MS, CLOSE_GRACE_MS), shorter only
|
|
151
|
-
* in a test; `workerFile`, the executor worker's module, another only in a test
|
|
268
|
+
* in a test; `workerFile`, the executor worker's module, another only in a test; `extensions`, this machine's
|
|
269
|
+
* (`findExtensions`) unless a test names a package */
|
|
152
270
|
static async open(file, log, options = {}) {
|
|
271
|
+
const extensions = options.extensions ?? findExtensions();
|
|
153
272
|
const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
|
154
273
|
if (!MEMORY_LIMIT_RE.test(memoryLimit))
|
|
155
|
-
throw new Error(
|
|
274
|
+
throw new Error(memoryProblemText(memorySettingWrong(memoryLimit)));
|
|
275
|
+
if (options.spillLimit !== undefined && !MEMORY_LIMIT_RE.test(options.spillLimit))
|
|
276
|
+
throw new Error(`MASON_SPILL_LIMIT=${JSON.stringify(options.spillLimit)} is not an amount of disk: write it as 40GB or 512MiB (unset, half of what is free on the disk)`);
|
|
277
|
+
const spillSet = options.spillLimit === undefined ? null : memoryLimitBytes(options.spillLimit);
|
|
278
|
+
const diskFree = options.diskFree ?? freeBytesAt;
|
|
156
279
|
const where = path.resolve(file);
|
|
280
|
+
const spillLimit = spillCeiling(diskFree(path.dirname(where)), 0, spillSet);
|
|
157
281
|
const before = opened.get(where);
|
|
158
282
|
if (before !== undefined && before.closed === null)
|
|
159
|
-
throw new Error(`
|
|
283
|
+
throw new Error(`Mason could not open the synced data ${file}: this process has it open already (Mason bug: report it)`);
|
|
160
284
|
// a copy whose thread was given up on may still hold the file: a second instance of it would lose writes
|
|
161
285
|
if (before?.abandoned != null)
|
|
162
|
-
throw new Error(`
|
|
286
|
+
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)`);
|
|
163
287
|
const mine = { closed: null, abandoned: null };
|
|
164
288
|
opened.set(where, mine);
|
|
165
289
|
let executor;
|
|
@@ -167,7 +291,7 @@ export class DuckStore {
|
|
|
167
291
|
if (before !== undefined)
|
|
168
292
|
await before.closed;
|
|
169
293
|
// every connection's, the sync's and each session's
|
|
170
|
-
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, memory_limit: memoryLimit, temp_directory: `${file}.tmp
|
|
294
|
+
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, ...extensionSettings(extensions), memory_limit: memoryLimit, temp_directory: `${file}.tmp`, max_temp_directory_size: spillSetting(spillLimit) }, log,
|
|
171
295
|
...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}), ...(options.workerFile !== undefined ? { workerFile: options.workerFile } : {}) });
|
|
172
296
|
}
|
|
173
297
|
catch (e) {
|
|
@@ -175,7 +299,7 @@ export class DuckStore {
|
|
|
175
299
|
opened.delete(where);
|
|
176
300
|
throw e;
|
|
177
301
|
}
|
|
178
|
-
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), options.closeGraceMs);
|
|
302
|
+
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), extensions, { limit: spillLimit, set: spillSet, diskFree }, log, options.closeGraceMs);
|
|
179
303
|
// closed: the file may be opened again — unless its thread was given up on (`abandoned`), which keeps it here
|
|
180
304
|
store.closing = (p) => { mine.closed = p; void p.then(() => { if (executor.abandoned !== null)
|
|
181
305
|
mine.abandoned = executor.abandoned;
|
|
@@ -184,23 +308,63 @@ export class DuckStore {
|
|
|
184
308
|
return store;
|
|
185
309
|
}
|
|
186
310
|
closing = () => undefined;
|
|
187
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* THE SPILL'S CEILING MOVED WITH THE DISK (`spillCeiling`): the bytes free and spilled now, set on the instance on a
|
|
313
|
+
* connection of its own, never the writer's. One at a time; a move under 1 % is none. DuckDB refuses a ceiling under
|
|
314
|
+
* what is spilled at that moment (more may have spilled since the read): it stays where it was until the next turn.
|
|
315
|
+
* Any other failure is said; the ceiling stands, which is the last one set.
|
|
316
|
+
*/
|
|
317
|
+
keepSpillCeiling() {
|
|
318
|
+
if (this.closed !== null)
|
|
319
|
+
return Promise.resolve();
|
|
320
|
+
this.spillKept ??= this.spillTurn().catch((e) => {
|
|
321
|
+
if (this.closed === null)
|
|
322
|
+
this.log(`the spill's ceiling was not moved: ${e instanceof Error ? e.message : String(e)}`);
|
|
323
|
+
}).finally(() => { this.spillKept = null; });
|
|
324
|
+
return this.spillKept;
|
|
325
|
+
}
|
|
326
|
+
async spillTurn() {
|
|
327
|
+
let c;
|
|
328
|
+
// a connection that failed is asked for again next turn, never kept as the answer
|
|
329
|
+
try {
|
|
330
|
+
c = await (this.spillLane ??= this.executor.connect());
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
this.spillLane = null;
|
|
334
|
+
throw e;
|
|
335
|
+
}
|
|
336
|
+
const spilled = Number((await c.runAndReadAll("SELECT coalesce(sum(size), 0) FROM duckdb_temporary_files()")).getRowsJS()[0][0]);
|
|
337
|
+
const next = spillCeiling(this.diskFree(path.dirname(path.resolve(this.file))), spilled, this.spillSet);
|
|
338
|
+
if (Math.abs(next - this.spillLimitBytes) < this.spillLimitBytes * 0.01)
|
|
339
|
+
return;
|
|
340
|
+
try {
|
|
341
|
+
await c.run(`SET GLOBAL max_temp_directory_size = '${spillSetting(next)}'`);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
if (e instanceof Error && e.message.includes("failed to adjust"))
|
|
345
|
+
return;
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
this.spillLimitBytes = next;
|
|
349
|
+
}
|
|
350
|
+
/** called once if the engine's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
|
|
188
351
|
onCrash(f) { this.executor.onCrash(f); }
|
|
189
|
-
/** how many vectors each Mason function was called with so far, on the
|
|
352
|
+
/** how many vectors each Mason function was called with so far, on the engine's thread (a test's, a measurement's) */
|
|
190
353
|
functionCalls() { return this.executor.functionCalls(); }
|
|
191
|
-
/** how many rows each Mason function was handed so far, on the
|
|
354
|
+
/** how many rows each Mason function was handed so far, on the engine's thread (a plan's cost in calls) */
|
|
192
355
|
functionRows() { return this.executor.functionRows(); }
|
|
193
356
|
/** a connection for one Postgres session (on the executor worker: SessionRunner) */
|
|
194
357
|
runner() { return this.executor.runner(); }
|
|
195
358
|
/**
|
|
196
359
|
* The source's order of text under a libc collation (src/duck/libc-order.ts), kept in the copy: the one recorded
|
|
197
|
-
* for this collation and version, else `read` from the source (a few seconds) and recorded.
|
|
360
|
+
* for this collation and version, in this release's form, else `read` from the source (a few seconds) and recorded.
|
|
361
|
+
* `read: true` when it was.
|
|
198
362
|
*/
|
|
199
363
|
async libcOrder(collate, version, read) {
|
|
200
364
|
await this.writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
201
365
|
await this.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.libc_order (collation_name VARCHAR, collation_version VARCHAR, data VARCHAR)`);
|
|
202
366
|
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();
|
|
203
|
-
if (kept.length > 0)
|
|
367
|
+
if (kept.length > 0 && isCurrentLibcOrder(String(kept[0][0])))
|
|
204
368
|
return { order: decodeLibcOrder(String(kept[0][0])), read: false };
|
|
205
369
|
const order = await read();
|
|
206
370
|
// one order per collation: a version that moved replaces the last one's
|
|
@@ -212,22 +376,50 @@ export class DuckStore {
|
|
|
212
376
|
* its text sorted as the SOURCE sorts it (`collation`, pg_database's; absent, by bytes; a libc one by `libc`, its
|
|
213
377
|
* order as `libcOrder` read it), refusing what is not `verified` (null: nothing is refused for it, the oracle's
|
|
214
378
|
* recording run alone). */
|
|
215
|
-
async listener(collation, verified = VERIFIED, libc, copies = () => [], kept) {
|
|
379
|
+
async listener(collation, verified = VERIFIED, libc, copies = () => [], kept, more = {}) {
|
|
216
380
|
const known = new Set((await this.writer.runAndReadAll("SELECT collname FROM pragma_collations()")).getRowsJson().map((r) => String(r[0])));
|
|
217
381
|
quoteDuckKeywords((await this.writer.runAndReadAll("SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category <> 'unreserved'")).getRowsJson().map((r) => String(r[0])));
|
|
218
|
-
const
|
|
382
|
+
const named = collation === undefined ? { kind: "bytes" } : duckCollation(collation.provider, collation.collate, collation.locale, known);
|
|
383
|
+
// (a libc locale whose strcoll is byte order, musl's or macOS's: src/duck/libc-order.ts)
|
|
384
|
+
const c = named.kind === "libc" && libc?.bytes === true ? { kind: "bytes" } : named;
|
|
219
385
|
if (c.kind === "libc") {
|
|
220
386
|
if (libc === undefined)
|
|
221
387
|
throw new Error(`the database orders text by the libc collation ${collation.collate}, and its order was not read (Mason bug: report it)`);
|
|
222
|
-
await this.executor.registerLibcOrder(libc);
|
|
388
|
+
await this.executor.registerLibcOrder(c.collate, libc, true);
|
|
223
389
|
}
|
|
390
|
+
// each other libc collation read at the start (a synced column's): its key's order, by its collcollate
|
|
391
|
+
for (const [collate, order] of more.libcOrders ?? [])
|
|
392
|
+
if (!order.bytes)
|
|
393
|
+
await this.executor.registerLibcOrder(collate, order, false);
|
|
394
|
+
// a libc collation named first by a statement: read from the source on its first use, kept in the copy, then the
|
|
395
|
+
// engine told its order before the plan that needs it is made (src/duck/collation.ts)
|
|
396
|
+
const readLibc = more.readLibc;
|
|
397
|
+
// the database's case mapping is its CTYPE's (datctype; ICU's or builtin's locale), never its order's
|
|
398
|
+
const ctype = collation === undefined ? null : collation.provider === "c" ? collation.ctype ?? collation.collate : collation.locale ?? collation.collate;
|
|
399
|
+
const resolver = new CollationResolver({ database: c, databaseCollate: collation !== undefined && isLibcCollation(collation.provider, collation.collate) ? collation.collate : null,
|
|
400
|
+
...(collation === undefined ? {} : { databaseCase: { mapping: caseMappingOf(collation.provider, ctype, collation.locale), ctype: ctype ?? "" } }),
|
|
401
|
+
source: more.collations ?? null, known, libc: more.libcOrders ?? new Map(),
|
|
402
|
+
readLibc: readLibc === undefined ? null : async (sc) => {
|
|
403
|
+
const r = await this.libcOrder(sc.collate ?? "C", sc.version ?? null, () => readLibc(sc));
|
|
404
|
+
if (!r.order.bytes)
|
|
405
|
+
await this.executor.registerLibcOrder(sc.collate ?? "C", r.order, false);
|
|
406
|
+
return r.order;
|
|
407
|
+
} });
|
|
408
|
+
// the database's own order, held where a plan reads each order by its collcollate (src/duck/libc-bound.ts)
|
|
409
|
+
if (c.kind === "libc" && libc !== undefined)
|
|
410
|
+
resolver.hold(c.collate, libc);
|
|
224
411
|
// a jsonb's stored key is this collation's (src/duck/kept.ts `orderBy`)
|
|
225
412
|
await kept?.orderBy(c, c.kind === "libc" ? libc : undefined);
|
|
226
413
|
return {
|
|
227
414
|
collation: c,
|
|
415
|
+
collations: resolver,
|
|
228
416
|
open: () => this.runner(),
|
|
229
|
-
plan: (catalog, text, stmt, base, paramOids,
|
|
417
|
+
plan: (catalog, text, stmt, base, paramOids, under, userTypes, wireBound, grants, relations, outputs) => planDuck({ catalog, text, stmt, base, paramOids, searchPath: under.searchPath, timeZone: under.timeZone, dateStyle: under.dateStyle, collations: resolver, verified, userTypes, copies, kept: kept ?? null, wireBound: wireBound ?? false, grants, ...(relations === undefined ? {} : { relations }), outputs: outputs ?? false }),
|
|
230
418
|
parameters: duckParameters,
|
|
419
|
+
// a SELECT of no table that orders text under a collation that is not byte order: the synced data's (issue 122);
|
|
420
|
+
// one that maps case (upper, lower, initcap: issue 406) is the synced data's where its run answers it (the
|
|
421
|
+
// listener's `answersCase`), else the catalog's, as before
|
|
422
|
+
ordersText: (catalog, text, paramOids, under, bindable) => ordersText({ catalog, text, paramOids, under, collations: resolver, bindable }),
|
|
231
423
|
// the sync's state, which EXPLAIN says how each copy is kept current from (src/duck/sync.ts KeptCurrent)
|
|
232
424
|
copies,
|
|
233
425
|
// a stored JSON path ready or gone (src/duck/kept.ts): the plans kept are made again
|
|
@@ -238,6 +430,7 @@ export class DuckStore {
|
|
|
238
430
|
* (a second open of the file waits for it), or once the executor's grace has passed whatever the thread does
|
|
239
431
|
* (src/duck/executor.ts CLOSE_GRACE_MS); never rejects — a failure is said in the log. */
|
|
240
432
|
close() {
|
|
433
|
+
clearInterval(this.spillTimer);
|
|
241
434
|
if (this.closed === null) {
|
|
242
435
|
this.closed = this.executor.close(this.closeGraceMs);
|
|
243
436
|
this.closing(this.closed);
|
|
@@ -273,22 +466,35 @@ export function pgErrorOf(err, sql) {
|
|
|
273
466
|
const full = err instanceof Error ? err.message : String(err);
|
|
274
467
|
const kind = /^([A-Za-z ]+) Error: /.exec(full)?.[1] ?? "";
|
|
275
468
|
const message = full.replace(/^[A-Za-z ]+ Error: /, "").replace(/\n\n?LINE \d+:[\s\S]*$/, "").trim();
|
|
469
|
+
// NO ROOM TO SPILL (issue 105): the ceiling, or the disk itself before it. Postgres's class for a full disk, in
|
|
470
|
+
// Mason's words: the engine's text names a path and a setting no client of Mason has, so it stays out of the answer
|
|
471
|
+
// Read by the engine's own kind and sentence: a statement's literal may hold the same words in any other error
|
|
472
|
+
const ceiling = kind === "Out of Memory" && /This limit was set by the 'max_temp_directory_size' setting/.test(full);
|
|
473
|
+
if (ceiling || (kind === "IO" && /No space left on device/.test(full))) {
|
|
474
|
+
return new StatementError({ code: ceiling ? "53400" : "53100", message: ceiling ? "could not write to temporary file: this statement needs more room on disk than statements may take"
|
|
475
|
+
: "could not write to temporary file: No space left on device",
|
|
476
|
+
hint: "ask for less at once (a filter, a LIMIT, fewer or narrower columns, a coarser GROUP BY), free disk where the synced data is, or raise MASON_SPILL_LIMIT (40GB; by default half of what is free) and start mason again; nothing else stopped" }, sql, "spill-limit");
|
|
477
|
+
}
|
|
276
478
|
const pg = postgresWording(kind, message);
|
|
479
|
+
// a refusal raised inside DuckDB (a Mason function's, the rewrite's error() call) crossed it as its text alone: the
|
|
480
|
+
// entry its text names (gapIn), read here, the one place that text becomes a statement's error
|
|
481
|
+
// (Mason's own refusal keeps the engine's text out of the DETAIL: it may quote the row, tests/conventions.test.ts)
|
|
482
|
+
// (a Mason function's error that carries Postgres's own DETAIL sends it as Postgres does, apart from the message)
|
|
277
483
|
if (pg !== null)
|
|
278
|
-
return new StatementError({
|
|
484
|
+
return new StatementError({ code: pg.code, message: pg.message, ...(pg.code === "0A000" ? {} : { detail: pg.detail ?? `the engine: ${message}` }) }, sql, pg.gap ?? (pg.code === "0A000" ? gapIn(pg.message) : null));
|
|
279
485
|
const fn = /(?:Scalar|Aggregate|Table) Function with name "?([A-Za-z_][\w]*)"? does not exist/.exec(message)?.[1];
|
|
280
486
|
if (fn !== undefined) {
|
|
281
487
|
// what reaches DuckDB was verified against Postgres (src/duck/verified.ts), so a function DuckDB lacks is a gap in
|
|
282
488
|
// Mason's rewrite, never something for the client to write in DuckDB's dialect
|
|
283
|
-
return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason
|
|
284
|
-
hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `
|
|
489
|
+
return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason's engine has no ${fn}`,
|
|
490
|
+
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}` });
|
|
285
491
|
}
|
|
286
492
|
const table = /Table with name "?([^"!\s]+)"? does not exist/.exec(message)?.[1];
|
|
287
493
|
if (table !== undefined) {
|
|
288
494
|
// DuckDB's miss comes after the catalog held the table (the plan passed): one dropped as no longer added, whose way
|
|
289
495
|
// back is `mason add`, so no copy state is read
|
|
290
496
|
return new StatementError({ code: "42P01", message: `relation "${table}" does not exist`,
|
|
291
|
-
hint: missingRelationHint(table, []), detail: `
|
|
497
|
+
hint: missingRelationHint(table, []), detail: `the engine: ${message}` });
|
|
292
498
|
}
|
|
293
499
|
const code = kind === "Parser" ? "42601"
|
|
294
500
|
: kind === "Catalog" ? "42P01"
|
|
@@ -304,17 +510,20 @@ export function pgErrorOf(err, sql) {
|
|
|
304
510
|
: kind === "Out of Range" ? "22003"
|
|
305
511
|
: kind === "Interrupt" || /INTERRUPT/i.test(full) ? "57014"
|
|
306
512
|
: kind === "Out of Memory" ? "53200"
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
513
|
+
// a commit that could not pin or allocate a block is the same limit reached, under the transaction's own kind
|
|
514
|
+
// (measured on DuckDB 2.0.0: "Failed to commit: failed to pin block of size 1.0 MiB (365.7 MiB/366.2 MiB used)")
|
|
515
|
+
: kind === "TransactionContext" && /^Failed to commit: failed to (?:pin block|allocate data) of size .* used\)/.test(message) ? "53200"
|
|
516
|
+
: kind === "Not implemented" ? "0A000"
|
|
517
|
+
: kind === "Invalid Input" ? "22023"
|
|
518
|
+
: "XX000";
|
|
310
519
|
return new StatementError({ code, message: code === "57014" ? "canceling statement due to user request"
|
|
311
|
-
: code === "53200" ? "out of memory: this statement needs more than the memory Mason's
|
|
520
|
+
: 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,
|
|
312
521
|
// DuckDB's own advice is SET, which a client of Mason cannot do: Mason's way out instead
|
|
313
522
|
...(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" } : {}),
|
|
314
523
|
// what Mason ran (the statement as rewritten for DuckDB, src/duck/rewrite.ts): a report of the error needs it
|
|
315
|
-
detail: `
|
|
524
|
+
detail: `the engine${kind ? ` ${kind} error` : ""}: ${message}${sql === "" ? "" : `; Mason ran: ${oneLine(sql, 2000)}`}`,
|
|
316
525
|
// Postgres accepted the GROUP BY (it analysed the statement first): DuckDB refusing it is Mason's rewrite's gap
|
|
317
|
-
...(code === "42803" ? { hint: "Postgres accepts this GROUP BY and Mason's rewrite did not carry it to
|
|
526
|
+
...(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" } : {}),
|
|
318
527
|
}, sql);
|
|
319
528
|
}
|
|
320
529
|
/** The errors DuckDB raises for what Postgres raises too, in Postgres's code and words (a tool or a person matches them). */
|
|
@@ -335,16 +544,23 @@ function postgresWording(kind, message) {
|
|
|
335
544
|
const field = /^Date out of range: (.*)$/.exec(message);
|
|
336
545
|
if (field)
|
|
337
546
|
return { code: "22008", message: `date field value out of range: ${field[1]}` };
|
|
338
|
-
// a cast to numeric(p, s) of a value with more than p - s integer digits
|
|
339
|
-
if (/Casting value "[^"]*" to type DECIMAL\(\d+,\s*\d+\) failed: value is out of range/.test(message))
|
|
340
|
-
return { code: "22003", message: "numeric field overflow" };
|
|
341
547
|
// 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)
|
|
342
548
|
if (/^Overflow in HUGEINT addition\b/.test(message))
|
|
343
|
-
return { code: "0A000", message: `a sum past
|
|
549
|
+
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" };
|
|
344
550
|
// a numeric(p, s) read from a text holding NaN or Infinity (src/duck/rewrite.ts pgInput): DuckDB's decimal has none
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
551
|
+
if (/Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.test(message))
|
|
552
|
+
return { code: "0A000", message: `a numeric NaN or Infinity read from a text is not supported by Mason (${gapMark("numeric-special")})`, gap: "numeric-special" };
|
|
553
|
+
// A NUMERIC PAST ITS DIGITS, in every spelling the engine gives (a generated table's run stores one, src/duck/generated.ts
|
|
554
|
+
// failureWords): a cast to numeric(p, s), of a value or a text, with more than p - s digits before the point, said
|
|
555
|
+
// with Postgres's DETAIL (and a number constant that does not fit: `12345::numeric(3)`, DuckDB's "Could not cast value
|
|
556
|
+
// 12345 to DECIMAL(3,0)"); past the 38 digits the engine computes a numeric with, by an operation, a ROUND or a cast
|
|
557
|
+
const typmod = /(?:Casting value "[^"]*" to type|Could not convert string ['"].*['"] to|^Could not cast value \S+ to) DECIMAL\((\d+),\s*(\d+)\)/.exec(message);
|
|
558
|
+
if (typmod !== null && Number(typmod[1]) < 38) {
|
|
559
|
+
return { code: "22003", ...fieldOverflow(Number(typmod[1]), Number(typmod[2])) };
|
|
560
|
+
}
|
|
561
|
+
if (typmod !== null || /Overflow in (?:\w+ )?(?:addition|subtraction|multiplication|negation|ROUND|cast)\b.*DECIMAL|Overflow in HUGEINT \w+|out of range for (?:the desired )?DECIMAL|Could not cast value .* to DECIMAL/i.test(message)) {
|
|
562
|
+
return { code: "22003", message: "numeric field overflow", detail: "The value has more than the 38 digits Mason's engine computes a numeric with." };
|
|
563
|
+
}
|
|
348
564
|
const conv = /Could not convert string '((?:[^']|'')*)' to (INT16|INT32|INT64|DOUBLE|FLOAT|DATE|TIMESTAMP|UUID|BOOL)/.exec(message);
|
|
349
565
|
if (conv) {
|
|
350
566
|
const t = { INT16: "smallint", INT32: "integer", INT64: "bigint", DOUBLE: "double precision", FLOAT: "real", DATE: "date", TIMESTAMP: "timestamp", UUID: "uuid", BOOL: "boolean" }[conv[2]];
|
|
@@ -352,9 +568,10 @@ function postgresWording(kind, message) {
|
|
|
352
568
|
}
|
|
353
569
|
// a Mason function's own error (src/duck/functions.ts): Postgres's words after the prefix
|
|
354
570
|
// Postgres's own error, from its own function (src/duck/pg-eval.ts): its SQLSTATE, said
|
|
355
|
-
|
|
571
|
+
// (and its DETAIL on a line of its own, src/duck/functions.ts `raised`)
|
|
572
|
+
const pg = /^mason\[([0-9A-Z]{5})\]: (.*?)(?:\nDETAIL: (.*))?$/s.exec(message);
|
|
356
573
|
if (pg)
|
|
357
|
-
return { code: pg[1], message: pg[2] };
|
|
574
|
+
return { code: pg[1], message: pg[2], ...(pg[3] === undefined ? {} : { detail: pg[3] }) };
|
|
358
575
|
const own = /^mason: (.*)$/s.exec(message);
|
|
359
576
|
if (own)
|
|
360
577
|
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] };
|
|
@@ -363,12 +580,55 @@ function postgresWording(kind, message) {
|
|
|
363
580
|
return { code: "22P02", message };
|
|
364
581
|
return null;
|
|
365
582
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
583
|
+
export class SessionSetup {
|
|
584
|
+
conn;
|
|
585
|
+
zone = null;
|
|
586
|
+
path = null;
|
|
587
|
+
settings = null;
|
|
588
|
+
constructor(conn) { this.conn = conn; }
|
|
589
|
+
/** `name`: what the Mason functions are passed as the session: a slot of the cancel table (`s<n>`, src/duck/cancel.ts)
|
|
590
|
+
* for a wire session; any other name is never cancelled through the table (a run is interrupted instead). Set up as a
|
|
591
|
+
* session with no role is: UTC, under `public`, no settings, until `settle` says otherwise. */
|
|
592
|
+
static async open(conn, name) {
|
|
593
|
+
// NULLs sort as Postgres sorts them: last ascending, FIRST descending (DuckDB's default is last both ways). An
|
|
594
|
+
// integer division is the rewrite's `//` where Postgres's operator is integer division (src/duck/rewrite.ts), so
|
|
595
|
+
// DuckDB's `/` stays the float division every other `/` is
|
|
596
|
+
await conn.run("SET default_null_order = 'nulls_last_on_asc_first_on_desc'");
|
|
597
|
+
// every Mason function's first argument (src/duck/functions.ts "every call carries its session")
|
|
598
|
+
await conn.run(`SET VARIABLE ${SESSION_VARIABLE} = ${quoteLiteral(name)}`);
|
|
599
|
+
const setup = new SessionSetup(conn);
|
|
600
|
+
await setup.settle(NO_ROLE_SESSION);
|
|
601
|
+
return setup;
|
|
602
|
+
}
|
|
603
|
+
/** the session's zone, search_path and settings, each set when it changed: before each statement */
|
|
604
|
+
async settle(session) {
|
|
605
|
+
if (session.timeZone !== this.zone) {
|
|
606
|
+
await this.conn.run(`SET TimeZone = ${quoteLiteral(session.timeZone)}`);
|
|
607
|
+
this.zone = session.timeZone;
|
|
608
|
+
}
|
|
609
|
+
// Postgres skips a schema of the path that does not exist; DuckDB refuses the SET (and with it every statement):
|
|
610
|
+
// the path is the schemas DuckDB has (a table added later in a new schema is found at the next statement)
|
|
611
|
+
const have = new Set((await this.conn.runAndReadAll("SELECT schema_name FROM duckdb_schemas() WHERE database_name = current_database()")).getRowsJS().map(([n]) => n));
|
|
612
|
+
const wanted = duckSearchPath(session.searchPath).split(",").filter((n) => have.has(n));
|
|
613
|
+
const path = (wanted.length > 0 ? wanted : ["main"]).join(",");
|
|
614
|
+
if (path !== this.path) {
|
|
615
|
+
await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
|
|
616
|
+
this.path = path;
|
|
617
|
+
}
|
|
618
|
+
// what `current_setting(name)` reads (src/duck/rewrite.ts `settingRead`): the session's settings, as one map
|
|
619
|
+
const settings = JSON.stringify(session.settings);
|
|
620
|
+
if (settings !== this.settings) {
|
|
621
|
+
const entries = Object.entries(session.settings).map(([k, v]) => `${quoteLiteral(k)}: ${quoteLiteral(v)}`).join(", ");
|
|
622
|
+
await this.conn.run(`SET VARIABLE ${SETTINGS_VARIABLE} = MAP {${entries}}::MAP(VARCHAR, VARCHAR)`);
|
|
623
|
+
this.settings = settings;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** a session with no role, as a generated table is planned and run (src/duck/generated-plan.ts): UTC, under `public`, no
|
|
628
|
+
* settings (what reads one is refused there: `current_setting` is "it reads a session") */
|
|
629
|
+
export const NO_ROLE_SESSION = { timeZone: "UTC", searchPath: "public", dateStyle: "ISO, MDY", settings: {} };
|
|
630
|
+
/** when the wait for a statement to end is said in the log */
|
|
631
|
+
const ENDING_SLOW_MS = 5_000;
|
|
372
632
|
/** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
|
|
373
633
|
* on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
|
|
374
634
|
export class SessionRunner {
|
|
@@ -386,40 +646,20 @@ export class SessionRunner {
|
|
|
386
646
|
openAnswer = null;
|
|
387
647
|
conn;
|
|
388
648
|
log;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
constructor(conn, log, slot, cancels) {
|
|
649
|
+
setup;
|
|
650
|
+
constructor(conn, log, slot, cancels, setup) {
|
|
392
651
|
this.conn = conn;
|
|
393
652
|
this.log = log;
|
|
394
653
|
this.slot = slot;
|
|
395
654
|
this.cancels = cancels;
|
|
655
|
+
this.setup = setup;
|
|
396
656
|
}
|
|
397
657
|
/** a connection for one Postgres session, in the cancel table's `slot` */
|
|
398
658
|
static async open(instance, log, slot, cancels) {
|
|
399
659
|
const conn = await instance.connect();
|
|
400
|
-
|
|
401
|
-
// integer division is the rewrite's `//` where Postgres's operator is integer division (src/duck/rewrite.ts), so
|
|
402
|
-
// DuckDB's `/` stays the float division every other `/` is
|
|
403
|
-
await conn.run("SET default_null_order = 'nulls_last_on_asc_first_on_desc'");
|
|
404
|
-
// every Mason function's first argument (src/duck/functions.ts "every call carries its session")
|
|
405
|
-
await conn.run(`SET VARIABLE ${SESSION_VARIABLE} = ${quoteLiteral(slotName(slot))}`);
|
|
406
|
-
return new SessionRunner(conn, log, slot, cancels);
|
|
407
|
-
}
|
|
408
|
-
async settle(session) {
|
|
409
|
-
if (session.timeZone !== this.zone) {
|
|
410
|
-
await this.conn.run(`SET TimeZone = ${quoteLiteral(session.timeZone)}`);
|
|
411
|
-
this.zone = session.timeZone;
|
|
412
|
-
}
|
|
413
|
-
// Postgres skips a schema of the path that does not exist; DuckDB refuses the SET (and with it every statement):
|
|
414
|
-
// the path is the schemas DuckDB has (a table added later in a new schema is found at the next statement)
|
|
415
|
-
const have = new Set((await this.conn.runAndReadAll("SELECT schema_name FROM duckdb_schemas() WHERE database_name = current_database()")).getRowsJS().map(([n]) => n));
|
|
416
|
-
const wanted = duckSearchPath(session.searchPath).split(",").filter((n) => have.has(n));
|
|
417
|
-
const path = (wanted.length > 0 ? wanted : ["main"]).join(",");
|
|
418
|
-
if (path !== this.path) {
|
|
419
|
-
await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
|
|
420
|
-
this.path = path;
|
|
421
|
-
}
|
|
660
|
+
return new SessionRunner(conn, log, slot, cancels, await SessionSetup.open(conn, slotName(slot)));
|
|
422
661
|
}
|
|
662
|
+
async settle(session) { await this.setup.settle(session); }
|
|
423
663
|
async prepare(sql) {
|
|
424
664
|
try {
|
|
425
665
|
return await this.conn.prepare(sql);
|
|
@@ -478,13 +718,10 @@ export class SessionRunner {
|
|
|
478
718
|
const p = await this.prepare(sql);
|
|
479
719
|
timing.prepareMs = performance.now() - t0;
|
|
480
720
|
// HOW A STATEMENT IS CANCELLED (src/duck/functions.ts "every call carries its session"; the test
|
|
481
|
-
// server's hung process).
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
// raises — AND interrupted (its tasks stop at their next chunk, so a call-free rest past its last call stops too),
|
|
486
|
-
// and its result is closed only once DuckDB has handed it back (the fetch's own path, or `drain`) and its calls
|
|
487
|
-
// have stopped (`cleanUp`: a task still inside a call waits for this thread, which a close would hold). Whether it calls
|
|
721
|
+
// server's hung process). It is interrupted — its tasks stop at their next chunk — and, if it calls a Mason
|
|
722
|
+
// function, cancelled through its session as well: its next call raises, so a task of it does not call on. Its
|
|
723
|
+
// result is closed only once DuckDB ended the query, on a thread that is not this one (`cleanUp`: a task still
|
|
724
|
+
// inside a call waits for this thread, which a close would hold). Whether it calls
|
|
488
725
|
// one is the PLAN's (src/duck/plan.ts PlannedSql `functions`, read off the rewritten tree), never a match on its
|
|
489
726
|
// text: a stored JSON path's column and the view of a copy's visible columns are named `__mason_…` too, and
|
|
490
727
|
// read as calls, every `SELECT *` over such a copy was cancelled only by a call it never made (a review
|
|
@@ -509,7 +746,20 @@ export class SessionRunner {
|
|
|
509
746
|
const started = Date.now();
|
|
510
747
|
let total = 0;
|
|
511
748
|
let result = null;
|
|
512
|
-
let closed = false, fetching = false, released = false;
|
|
749
|
+
let closed = false, fetching = false, released = false, said = false;
|
|
750
|
+
/** DuckDB ended the query: a fetch failed, or read its end (`cleanUp` says why that is what `release` waits for) */
|
|
751
|
+
let ended = false;
|
|
752
|
+
let ending = false;
|
|
753
|
+
// ITS LINE IS SAID WHEN IT ENDS, before its client hears the end, never when its result is released: that waits for
|
|
754
|
+
// DuckDB to end it (`cleanUp`), by when its client is gone and the next session's statements are running, and a
|
|
755
|
+
// reader of the log took the late line for theirs (`tests/duck-wire.test.ts`, 1 run in 100 under load). The
|
|
756
|
+
// worker's port keeps the order: a line posted before the answer is read before it
|
|
757
|
+
const say = () => {
|
|
758
|
+
if (said)
|
|
759
|
+
return;
|
|
760
|
+
said = true;
|
|
761
|
+
this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
|
|
762
|
+
};
|
|
513
763
|
const release = () => {
|
|
514
764
|
if (released)
|
|
515
765
|
return;
|
|
@@ -519,60 +769,32 @@ export class SessionRunner {
|
|
|
519
769
|
signal.removeEventListener("abort", cancel);
|
|
520
770
|
result?.closeSync();
|
|
521
771
|
p.destroySync();
|
|
522
|
-
|
|
772
|
+
say();
|
|
523
773
|
};
|
|
524
|
-
// A
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// or one read to its end, has no task left to wait for and is released at once
|
|
530
|
-
const releaseAfterCleanup = () => {
|
|
531
|
-
if (!callsFunctions || released) {
|
|
532
|
-
release();
|
|
774
|
+
// A STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED ONLY ONCE
|
|
775
|
+
// DuckDB ENDED IT OFF THIS THREAD (`cleanUp`), the session's next statement waiting for that (`releasing`). One read
|
|
776
|
+
// to its end has no task left, and is released at once
|
|
777
|
+
const endOffThisThread = () => {
|
|
778
|
+
if (released || ending)
|
|
533
779
|
return;
|
|
534
|
-
|
|
535
|
-
const done = (async () => { await this.cleanUp(seq, sql); release(); })();
|
|
780
|
+
ending = true;
|
|
781
|
+
const done = (async () => { await this.cleanUp(seq, sql, callsFunctions, ended ? null : result); release(); })();
|
|
536
782
|
this.releasing.add(done);
|
|
537
783
|
void done.finally(() => this.releasing.delete(done));
|
|
538
784
|
};
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
785
|
+
// it ended with an answer its client is about to hear: said now
|
|
786
|
+
const releaseAfterCleanup = () => { if (!released)
|
|
787
|
+
say(); endOffThisThread(); };
|
|
788
|
+
// a result closed before its end while no fetch is in flight: no end its client hears, so its line is its release's
|
|
542
789
|
const drain = () => {
|
|
543
|
-
const r = result;
|
|
544
790
|
// CLOSED WHILE DuckDB STILL STARTS IT (its `stream()` pending: an aggregate computes there whole): nothing is
|
|
545
791
|
// released under the pending start — that destroyed the prepared statement under it and, its release done, let the
|
|
546
792
|
// start's own failure skip `cleanUp`, so the connection and the instance closed while its other tasks still had
|
|
547
793
|
// calls queued for this thread, and the thread's end met them (Linux CI: `FATAL ERROR: Error::Error
|
|
548
794
|
// napi_define_properties`, 7 runs in 30 of a stop under a calling count). The start's own path releases it: its
|
|
549
795
|
// failure through `releaseAfterCleanup`, its answer drained once handed back (below `stream()`)
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
if (!callsFunctions) {
|
|
553
|
-
release();
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
const done = (async () => {
|
|
557
|
-
// read on until the next call raises or the result ends — an EMPTY chunk, as `fetch` reads it: past its end a
|
|
558
|
-
// streamed result hands back empty chunks for ever, never null, and a result closed after its last row but
|
|
559
|
-
// before its end was read here without end, a thread spinning and the session's close waiting on it
|
|
560
|
-
try {
|
|
561
|
-
for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
|
|
562
|
-
}
|
|
563
|
-
catch (e) {
|
|
564
|
-
// the cancel's own refusal (its next call raised, 57014) is what the drain waits for. Anything else is the
|
|
565
|
-
// statement's real error, met after the listener let go of its answer: no one reads it any more, and a
|
|
566
|
-
// rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
|
|
567
|
-
if (pgErrorOf(e, sql).fields.code !== "57014")
|
|
568
|
-
this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
569
|
-
// it failed, so a task of it may still be inside a call: closed once its calls have stopped
|
|
570
|
-
await this.cleanUp(seq, sql);
|
|
571
|
-
}
|
|
572
|
-
release();
|
|
573
|
-
})();
|
|
574
|
-
this.releasing.add(done);
|
|
575
|
-
void done.finally(() => this.releasing.delete(done));
|
|
796
|
+
if (result !== null)
|
|
797
|
+
endOffThisThread();
|
|
576
798
|
};
|
|
577
799
|
// an early end by the listener (a portal closed half read, the session gone): cancelled, and released — by the
|
|
578
800
|
// fetch in flight once DuckDB hands the chunk back, else by `drain`
|
|
@@ -601,23 +823,23 @@ export class SessionRunner {
|
|
|
601
823
|
catch (e) {
|
|
602
824
|
throw failure(e);
|
|
603
825
|
}
|
|
604
|
-
// CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
826
|
+
// CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back, and drained. One that calls no function
|
|
827
|
+
// is told the cancel here, its release done first, before a column of a destroyed result is asked (DuckDB
|
|
828
|
+
// answers 0 columns for one: a review finding, an XX000 "report it" where the session had closed); one that calls
|
|
829
|
+
// holds its result open until DuckDB ended it, so its answer below is built from a live result and its fetch
|
|
830
|
+
// tells the cancel
|
|
609
831
|
if (closed) {
|
|
832
|
+
drain();
|
|
610
833
|
if (!callsFunctions) {
|
|
611
|
-
|
|
834
|
+
await Promise.all([...this.releasing]);
|
|
612
835
|
throw cancelled();
|
|
613
836
|
}
|
|
614
|
-
drain();
|
|
615
837
|
}
|
|
616
838
|
timing.duckdbMs += performance.now() - t1;
|
|
617
839
|
const r = result;
|
|
618
840
|
if (fields !== null && r.columnCount < fields.length)
|
|
619
|
-
throw new StatementError({ code: "XX000", message: `
|
|
620
|
-
// Postgres's columns where its analysis gave them (
|
|
841
|
+
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);
|
|
842
|
+
// Postgres's columns where its analysis gave them (the exact numerics' cells DuckDB answers after them are not sent)
|
|
621
843
|
const columns = fields !== null
|
|
622
844
|
? fields.map((f, i) => pgTypedColumn(f.name, f.oid, r.columnType(i), f.typmod))
|
|
623
845
|
: Array.from({ length: r.columnCount }, (_, i) => wireColumn(r.columnName(i), r.columnType(i)));
|
|
@@ -646,11 +868,13 @@ export class SessionRunner {
|
|
|
646
868
|
chunk = await this.track(r.fetchChunk());
|
|
647
869
|
}
|
|
648
870
|
catch (e) {
|
|
871
|
+
ended = true;
|
|
649
872
|
throw failure(e);
|
|
650
873
|
}
|
|
651
874
|
finally {
|
|
652
875
|
fetching = false;
|
|
653
876
|
}
|
|
877
|
+
ended = chunk === null || chunk.rowCount === 0;
|
|
654
878
|
if (closed) {
|
|
655
879
|
drain();
|
|
656
880
|
return null;
|
|
@@ -703,8 +927,17 @@ export class SessionRunner {
|
|
|
703
927
|
return this.track(this.explained(sql, params, session, signal, how));
|
|
704
928
|
}
|
|
705
929
|
async explained(sql, params, session, signal, how) {
|
|
706
|
-
await Promise.all([...this.releasing]);
|
|
707
930
|
const o = how.options;
|
|
931
|
+
// A STATEMENT THAT READS A FILTERED TABLE IS NEVER SHOWN TO THE ENGINE: its plan says what it proved from the table's
|
|
932
|
+
// statistics (docs/DESIGN.md "Roles: rows per user"), so the tables are the statement's and its filters' (a through:'s
|
|
933
|
+
// parents), and the rest is Mason's plan
|
|
934
|
+
if ((how.rowFilters ?? []).length > 0) {
|
|
935
|
+
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) }; });
|
|
936
|
+
const explained = { copies: await this.copies(tables, how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
937
|
+
functions: [...sql.functions], plan: [], filtered: true };
|
|
938
|
+
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
939
|
+
}
|
|
940
|
+
await Promise.all([...this.releasing]);
|
|
708
941
|
let plan;
|
|
709
942
|
let analyzed;
|
|
710
943
|
if (!o.analyze)
|
|
@@ -727,6 +960,7 @@ export class SessionRunner {
|
|
|
727
960
|
}
|
|
728
961
|
}
|
|
729
962
|
finally {
|
|
963
|
+
await Promise.all([...this.releasing]);
|
|
730
964
|
await this.conn.run("PRAGMA disable_profiling");
|
|
731
965
|
}
|
|
732
966
|
// THE PROFILE IS HELD TO THE STATEMENT, never assumed its: DuckDB writes one when the result is read to its end,
|
|
@@ -734,7 +968,7 @@ export class SessionRunner {
|
|
|
734
968
|
// whole table is the table's row count), and its plan is its EXPLAIN, the rows the ones it answered
|
|
735
969
|
const profile = fs.existsSync(file) ? profiled(JSON.parse(fs.readFileSync(file, "utf8"))) : null;
|
|
736
970
|
if (profile !== null && profile.sql !== sql.text) {
|
|
737
|
-
throw new StatementError({ code: "XX000", message: `
|
|
971
|
+
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);
|
|
738
972
|
}
|
|
739
973
|
if (profile !== null)
|
|
740
974
|
plan = profile.plan;
|
|
@@ -750,7 +984,7 @@ export class SessionRunner {
|
|
|
750
984
|
}
|
|
751
985
|
}
|
|
752
986
|
// what it calls is the plan's (src/duck/plan.ts PlannedSql), the list that decides how it is cancelled
|
|
753
|
-
const explained = { copies: await this.copies(scannedTables(plan), how.copies), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
987
|
+
const explained = { copies: await this.copies(scannedTables(plan), how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
754
988
|
functions: [...sql.functions], plan, ...(analyzed === undefined ? {} : { analyzed }) };
|
|
755
989
|
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
756
990
|
}
|
|
@@ -788,42 +1022,70 @@ export class SessionRunner {
|
|
|
788
1022
|
}
|
|
789
1023
|
/** each copy's rows, and the sync's facts of it (how it is kept current: src/duck/sync.ts `KeptCurrent`, never the
|
|
790
1024
|
* poll's ledger read here — a project switched to logical replication still holds its last poll there) */
|
|
791
|
-
async copies(tables, facts) {
|
|
1025
|
+
async copies(tables, facts, filters = []) {
|
|
792
1026
|
const out = [];
|
|
793
1027
|
for (const { schema, name } of tables) {
|
|
794
|
-
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];
|
|
795
1028
|
// the sync's source is the two names as they are; EXPLAIN says the table as a statement writes it (qualifiedName)
|
|
796
|
-
|
|
1029
|
+
const filter = filters.find((f) => f.table === `${schema}.${name}`)?.words;
|
|
1030
|
+
// a table the role filters: its counts are every row's, not the role's, and are never read or said here
|
|
1031
|
+
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];
|
|
1032
|
+
out.push({ table: qualifiedName({ schema, name }), rows: rows === undefined || rows === null ? null : Number(rows), facts: facts.find((f) => f.source === `${schema}.${name}`) ?? null,
|
|
1033
|
+
...(filter !== undefined ? { filter } : {}) });
|
|
797
1034
|
}
|
|
798
1035
|
return out;
|
|
799
1036
|
}
|
|
800
1037
|
/**
|
|
801
|
-
* A
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* `
|
|
1038
|
+
* A STATEMENT THAT DID NOT RUN TO ITS END, ENDED BY DuckDB OFF THIS THREAD before its result is closed. THIS THREAD
|
|
1039
|
+
* NEVER WAITS FOR THE ENGINE'S TASKS: every close is synchronous here, a close of an open result waits for the query's
|
|
1040
|
+
* tasks (Executor::CancelTasks), and this thread runs every Mason function, so a task inside a call waits for it (the
|
|
1041
|
+
* suite hung for good at 0 % CPU; `tests/duck-executor.test.ts` "a vector slower than"). Waiting for quiet instead —
|
|
1042
|
+
* no call for 50 ms, as this did — was no proof: a vector whose native work outlasts the window has made no call yet.
|
|
1043
|
+
* And a call-free statement's tasks hold this thread as long as DuckDB's vector takes, with every session behind it.
|
|
1044
|
+
*
|
|
1045
|
+
* WHAT WAITS IS A FETCH (read in DuckDB 2.0's source, `ClientContext::EndQueryInternal`, and held by the test above).
|
|
1046
|
+
* Every end of a query runs `CancelTasks` on the thread that ends it, and a fetch runs on libuv's pool. Interrupted
|
|
1047
|
+
* first, the next fetch does not compute: it fails at once (`BufferedData::Participate`), ending the query there, its
|
|
1048
|
+
* tasks waited for while this thread answers their calls; or it reads the end, or the end was read already (`r`
|
|
1049
|
+
* null). So the wait is bounded by DuckDB's next chunk, on a statement that never ends by itself too, and an EMPTY chunk
|
|
1050
|
+
* is the end, as `fetch` reads it: past its end a streamed result hands back empty chunks for ever, never null.
|
|
1051
|
+
*
|
|
1052
|
+
* THE PROBE PROVES IT (a statement performs no wait: while a result holds the connection DuckDB refuses it, "connection
|
|
1053
|
+
* has an open result", before anything else). So a `SELECT 1` that ran is proof that nothing of the statement is left,
|
|
1054
|
+
* and the close finds nothing to wait for. Refused, the reading above no longer holds: said as a Mason bug, and closed
|
|
1055
|
+
* all the same, the watch (src/duck/executor.ts SILENT_MS) catching the deadlock if there is one. A wait past
|
|
1056
|
+
* `ENDING_SLOW_MS` is said once.
|
|
809
1057
|
*/
|
|
810
|
-
async cleanUp(seq, sql) {
|
|
811
|
-
|
|
1058
|
+
async cleanUp(seq, sql, calls, r) {
|
|
1059
|
+
if (calls)
|
|
1060
|
+
cancelStatement(this.cancels, this.slot, seq);
|
|
812
1061
|
this.conn.interrupt();
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1062
|
+
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);
|
|
1063
|
+
try {
|
|
1064
|
+
if (r !== null) {
|
|
1065
|
+
try {
|
|
1066
|
+
for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
|
|
1067
|
+
}
|
|
1068
|
+
catch (e) {
|
|
1069
|
+
// the cancel's own refusal (the interrupt, or its next call raised, 57014) is what this waits for. Anything else
|
|
1070
|
+
// is the statement's real error, met after the listener let go of its answer: no one reads it any more, and a
|
|
1071
|
+
// rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
|
|
1072
|
+
if (pgErrorOf(e, sql).fields.code !== "57014")
|
|
1073
|
+
this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// an abort landing now interrupts the probe past DuckDB's check, which is its proof all the same
|
|
1077
|
+
try {
|
|
1078
|
+
await this.conn.run("SELECT 1");
|
|
826
1079
|
}
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
if (/open result/.test(String(e)))
|
|
1082
|
+
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)}`);
|
|
1083
|
+
else if (pgErrorOf(e, "SELECT 1").fields.code !== "57014")
|
|
1084
|
+
this.log(`the check that a statement ended failed: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
finally {
|
|
1088
|
+
clearTimeout(slow);
|
|
827
1089
|
}
|
|
828
1090
|
}
|
|
829
1091
|
/** Closes the connection once nothing runs on it: an answer left open drained (its statement cancelled), every
|
|
@@ -858,7 +1120,7 @@ function exactColumns(columns, fields, exact, typeAt) {
|
|
|
858
1120
|
const computed = exact.filter((e) => {
|
|
859
1121
|
const unreadable = readers.some((r, j) => r === null && programReads(e, j));
|
|
860
1122
|
if (unreadable)
|
|
861
|
-
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it
|
|
1123
|
+
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it the engine computed as a double` };
|
|
862
1124
|
return !unreadable;
|
|
863
1125
|
});
|
|
864
1126
|
if (computed.length === 0)
|
|
@@ -882,7 +1144,7 @@ function exactRows(computed, rows, base, sql, params) {
|
|
|
882
1144
|
}
|
|
883
1145
|
catch (err) {
|
|
884
1146
|
if (err instanceof NumericError)
|
|
885
|
-
throw new StatementError({ code: err.sqlstate, message: err.message }, sql);
|
|
1147
|
+
throw new StatementError({ code: err.sqlstate, message: err.message, ...(err.detail === null ? {} : { detail: err.detail }) }, sql, err.gap);
|
|
886
1148
|
throw err;
|
|
887
1149
|
}
|
|
888
1150
|
}
|
|
@@ -893,11 +1155,11 @@ function exactRows(computed, rows, base, sql, params) {
|
|
|
893
1155
|
function computedOf(exact, fields) {
|
|
894
1156
|
return exact.map((e) => {
|
|
895
1157
|
const cells = new Set(cellsOf(e.program).filter((k) => !k.startsWith("$"))).size;
|
|
896
|
-
return `column ${e.column + 1}${fields?.[e.column] === undefined ? "" : ` "${fields[e.column].name}"`}: Postgres's exact numeric${e.text ? " as text" : ""}, from ${cells} of
|
|
1158
|
+
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)`;
|
|
897
1159
|
});
|
|
898
1160
|
}
|
|
899
1161
|
/** whether the column's program reads cell j */
|
|
900
|
-
const programReads = (e, j) =>
|
|
1162
|
+
const programReads = (e, j) => cellsOf(e.program).includes(String(j));
|
|
901
1163
|
/** a statement on one line: whole in the log (its ORDER BY, past 160 characters, was what told a slow preview's
|
|
902
1164
|
* cause), cut where it goes to a client in an error's detail */
|
|
903
1165
|
const oneLine = (sql, max = Infinity) => { const t = sql.replace(/\s+/g, " ").trim(); return t.length > max ? `${t.slice(0, max - 3)}…` : t; };
|