@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.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -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 { msSinceCall, registerFunctions, SESSION_VARIABLE } from "./functions.js";
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
- ? `DuckDB could not open ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
113
- : `DuckDB could not open ${file}: ${m}. mason destroy deletes the copy, and the next mason start copies again`);
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
- constructor(file, executor, memoryLimit, closeGraceMs) {
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(`DuckDB could not open ${file}: this process has it open already (Mason bug: report it)`);
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(`DuckDB could not open ${file}: this process gave up on the copy's thread before (${before.abandoned}), which may still hold it; start mason again (a new process opens it)`);
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 copy's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
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 copy's thread (a test's, a measurement's) */
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({ ...pg, detail: `DuckDB: ${message}` }, sql);
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 answers SQL with DuckDB, which has no ${fn}`,
270
- hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `DuckDB: ${message}` });
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: `DuckDB: ${message}` });
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 DuckDB may use, in a part that cannot spill to disk" : message,
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: `DuckDB${kind ? ` ${kind} Error` : ""} (Mason answers SQL with DuckDB): ${message}${sql === "" ? "" : `; DuckDB ran: ${oneLine(sql, 2000)}`}`,
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 DuckDB: report it with the statement; adding the column to the GROUP BY answers meanwhile" } : {}),
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 DuckDB's 38 digits is not supported by Mason (${gapMark("numeric-exact-sum")})` };
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
- /** how long no call of a session must have been answered before a result of its that did not end is closed: a task
353
- * waiting for a call is answered within a turn of the worker's loop (microseconds when it is free), and the work a task
354
- * does between two calls is one vector's (2,048 rows) — milliseconds; 50 ms leaves an order of magnitude over both */
355
- const CALLS_QUIET_MS = 50;
356
- /** when the wait for a statement's calls to stop is said in the log */
357
- const CALLS_SLOW_MS = 5_000;
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
- async streamed(planned, params, session, signal, fields, exact) {
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). One that calls no Mason function is interrupted, and its result closed at once: the
454
- // close is what cancels the executor's tasks, and a close put off lets the session's next statement take the
455
- // connection first, after which the close waits for the cancelled query to run to its end (38 s, in
456
- // tests/duck-wire.test.ts "statement_timeout"). One that does is cancelled through its session — its next call
457
- // raises — AND interrupted (its tasks stop at their next chunk, so a call-free rest past its last call stops too),
458
- // and its result is closed only once DuckDB has handed it back (the fetch's own path, or `drain`) and its calls
459
- // have stopped (`cleanUp`: a task still inside a call waits for this thread, which a close would hold). Whether it calls
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
- this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
546
+ say();
495
547
  };
496
- // A CALLING STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED
497
- // ONLY ONCE ITS CALLS HAVE STOPPED (the suite hung for good, 0 % CPU). DuckDB hands back a failed chunk
498
- // while another of the query's tasks may still wait INSIDE a call — for this thread, which runs every Mason
499
- // function — and closing the result waits for that task (Executor::CancelTasks): this thread waited for the task,
500
- // the task for this thread, and every later request to the copy with them (`cleanUp`). One that calls no function,
501
- // or one read to its end, has no task left to wait for and is released at once
502
- const releaseAfterCleanup = () => {
503
- if (!callsFunctions || released) {
504
- release();
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
- // a result closed before its end while no fetch is in flight: a statement calling no function is released at
512
- // once (the close cancels the executor); one that does is read on until DuckDB hands it back refused (its next
513
- // call raises) or read to its end, then released — the session's next statement waits for that (`releasing`)
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
- const r = result;
516
- if (!callsFunctions || r === null) {
517
- release();
518
- return;
519
- }
520
- const done = (async () => {
521
- // read on until the next call raises or the result ends — an EMPTY chunk, as `fetch` reads it: past its end a
522
- // streamed result hands back empty chunks for ever, never null, and a result closed after its last row but
523
- // before its end was read here without end, a thread spinning and the session's close waiting on it
524
- try {
525
- for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
526
- }
527
- catch (e) {
528
- // the cancel's own refusal (its next call raised, 57014) is what the drain waits for. Anything else is the
529
- // statement's real error, met after the listener let go of its answer: no one reads it any more, and a
530
- // rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
531
- if (pgErrorOf(e, sql).fields.code !== "57014")
532
- this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
533
- // it failed, so a task of it may still be inside a call: closed once its calls have stopped
534
- await this.cleanUp(seq, sql);
535
- }
536
- release();
537
- })();
538
- this.releasing.add(done);
539
- void done.finally(() => this.releasing.delete(done));
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: `DuckDB answered ${r.columnCount} column(s) where Postgres's analysis has ${fields.length}: report it (Mason's rewrite)` }, sql);
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 their last poll (`_mason.poll_state`).
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
- try {
664
- await this.settle(session);
665
- }
666
- catch (e) {
667
- throw pgErrorOf(e, sql.text);
668
- }
669
- const p = await this.prepare(`EXPLAIN (FORMAT JSON) ${sql.text}`);
670
- const interrupt = () => this.conn.interrupt();
671
- signal.addEventListener("abort", interrupt);
672
- try {
673
- if (signal.aborted)
674
- throw failureOf(signal, null, sql.text);
675
- params.forEach((v, i) => { if (v === null)
676
- p.bindNull(i + 1);
677
- else
678
- p.bindVarchar(i + 1, v); });
679
- let r;
680
- try {
681
- r = await p.runAndReadAll();
682
- }
683
- catch (e) {
684
- throw failureOf(signal, e, sql.text);
685
- }
686
- plan = planNodes(JSON.parse(String(r.getRowsJS()[0]?.[1] ?? "[]")));
687
- }
688
- finally {
689
- signal.removeEventListener("abort", interrupt);
690
- p.destroySync();
691
- }
692
- }
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.conn.run(`SET profiling_output = ${quoteLiteral(file)}`);
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
- // the profile is the statement's: written when its result was read to its end, after every other statement here
716
- plan = planNodes(JSON.parse(fs.readFileSync(file, "utf8")).operator ?? []);
717
- analyzed = { rows, planning: { ...how.planning, duckdbMs: timing.prepareMs }, execution: { duckdbMs: timing.duckdbMs, masonMs: timing.masonMs } };
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
- /** each copy's rows and last poll (none recorded: a copy made whole, not polled yet) */
729
- async copies(tables) {
730
- const polls = (await this.conn.runAndReadAll(`SELECT count(*) FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = '${MASON_SCHEMA}' AND table_name = 'poll_state'`)).getRowsJS()[0][0] !== 0n;
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 table of tables) {
733
- const [schema, name] = table.split(".");
734
- const rows = (await this.conn.runAndReadAll("SELECT estimated_size FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = $1 AND table_name = $2", [schema, name])).getRowsJS()[0]?.[0];
735
- const polled = polls ? (await this.conn.runAndReadAll(`SELECT strftime(polled_at AT TIME ZONE 'UTC', '%Y-%m-%d %H:%M:%S') || ' UTC' FROM ${MASON_SCHEMA}.poll_state WHERE source = $1`, [table])).getRowsJS()[0]?.[0] : undefined;
736
- out.push({ table, rows: rows === undefined || rows === null ? null : Number(rows), polledAt: typeof polled === "string" ? polled : null });
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 CALLING STATEMENT THAT DID NOT RUN TO ITS END, WAITED FOR UNTIL ITS CALLS HAVE STOPPED, before its result is
742
- * closed (`releaseAfterCleanup` in `streamed` says why). Every close is synchronous on this thread (DuckDB's node API
743
- * has no other, and refuses a second query on a connection with a result open), so the close must find no task of the
744
- * query inside, or waiting for, a call. The statement is marked cancelled (each call it still makes raises at once)
745
- * and interrupted (its tasks stop at their next chunk), and this thread stays free — a task waiting for a call is
746
- * answered within a turn of its loop — until no call of this session has been answered for `CALLS_QUIET_MS`. Never a
747
- * deadline: a close while calls still come is the deadlock itself, and the calls end (each raises); a wait past
748
- * `CALLS_SLOW_MS` is said once.
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
- cancelStatement(this.cancels, this.slot, seq);
832
+ async cleanUp(seq, sql, calls, r) {
833
+ if (calls)
834
+ cancelStatement(this.cancels, this.slot, seq);
752
835
  this.conn.interrupt();
753
- const t0 = performance.now();
754
- let said = false;
755
- // ONE WHOLE WINDOW BEFORE THE FIRST LOOK: a call a task has queued for this thread but that has not run yet leaves
756
- // `msSinceCall` at the previous call's time — long ago, when this thread was busy with another connection's close
757
- // (an interrupt honoured, ~10 s for one join) — so a check made synchronously here could pass with that call still
758
- // waiting, and the close deadlock on it the same way. A sleep of the window lets every queued call run first.
759
- for (;;) {
760
- await sleep(CALLS_QUIET_MS);
761
- if (msSinceCall(this.slot) >= CALLS_QUIET_MS)
762
- break;
763
- if (!said && performance.now() - t0 > CALLS_SLOW_MS) {
764
- said = true;
765
- this.log(`a statement that did not run to its end still calls Mason's functions after ${CALLS_SLOW_MS / 1000} s; its result is closed once they stop — ${oneLine(sql)}`);
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.openAnswer?.();
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 DuckDB computed as a double` };
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 DuckDB's value(s)`;
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 */