@mason-data/cli 0.8.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 +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- 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 +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- 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 +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- 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 +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -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 +9 -7
- 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 +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/dist/src/duck/sync.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { MASON_SCHEMA } from "./store.js";
|
|
1
|
+
import { DEFAULT_MEMORY_RULE, freeBytesAt, MASON_SCHEMA, NO_ROLE_SESSION, pgErrorOf, SessionSetup } from "./store.js";
|
|
2
|
+
/** the session the generated tables' run passes the Mason functions: no slot of the cancel table (src/duck/cancel.ts
|
|
3
|
+
* `slotOf`), so never cancelled through it; a stop interrupts the run's connection instead */
|
|
4
|
+
const GENERATED_SESSION = "generated";
|
|
5
|
+
import { GeneratedTables } from "./generated.js";
|
|
2
6
|
import { KeptJsonPaths } from "./kept.js";
|
|
3
7
|
import { DuckDBEnumType, DuckDBListType } from "@duckdb/node-api";
|
|
4
8
|
import { wireColumn } from "./types.js";
|
|
@@ -8,11 +12,14 @@ import * as fs from "node:fs";
|
|
|
8
12
|
import * as path from "node:path";
|
|
9
13
|
import { PgConnection, formatLsn, parseLsn, pgLiteral } from "../cdc/pg-wire.js";
|
|
10
14
|
import { createSlot, currentLsn, dropSlot, readSlot } from "../cdc/source.js";
|
|
15
|
+
import { holdOf } from "./held-types.js";
|
|
11
16
|
import { addCounts, countsText, duckValue, foldInto, noChanges, noCounts, ReplicationStream } from "./stream.js";
|
|
12
17
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
13
|
-
import {
|
|
18
|
+
import { numericWiderThan } from "../lib/pg-numeric.js";
|
|
19
|
+
import { gapLine, namedPublicationVerdict, readNamedPublication } from "./publication.js";
|
|
14
20
|
import { extensionFailure, loadPostgresSql } from "./extensions.js";
|
|
15
|
-
import {
|
|
21
|
+
import { errorCode, errorText, maskPasswordKeyword, messageOf } from "../lib/secret-mask.js";
|
|
22
|
+
import { BIG_TABLE_ROWS, intervalOf, msText, syncedText, syncText, syncWords } from "../lib/table-sync.js";
|
|
16
23
|
export function syncModeOf(env) {
|
|
17
24
|
const v = env.MASON_DUCK_SYNC || "poll";
|
|
18
25
|
if (v !== "poll" && v !== "recopy")
|
|
@@ -36,6 +43,54 @@ export function nextPollAt(endedMs, intervalSeconds, pollMs) {
|
|
|
36
43
|
export function passDelayMs(earliestDueMs, nowMs, lastPassMs) {
|
|
37
44
|
return Math.min(2 ** 31 - 1, Math.max(0, earliestDueMs - nowMs, 4 * lastPassMs));
|
|
38
45
|
}
|
|
46
|
+
/** The least grace a polled or recopied table is given past its interval before it is behind (`keptPromise`): a pass
|
|
47
|
+
* attaches the source and queues on the writer, so a poll due now lands a little later. */
|
|
48
|
+
export const POLL_GRACE_FLOOR_SECONDS = 60;
|
|
49
|
+
/** How old a STREAMED table's synced data may be before it is behind (`keptPromise`): an idle healthy stream says it has
|
|
50
|
+
* everything the database wrote on the keepalive it asks for every 10 s (src/duck/stream.ts `sendStatus(…, true)`),
|
|
51
|
+
* so 120 s is twelve of them, and past `wal_sender_timeout`'s default 60 s: one reconnect never crosses it. */
|
|
52
|
+
export const REPLICATION_BEHIND_SECONDS = 120;
|
|
53
|
+
/**
|
|
54
|
+
* WHAT A TABLE'S `sync:` PROMISES: the age of its synced data past which it is BEHIND, and the facts that would make it
|
|
55
|
+
* so now — the one derivation (`Sync.keptOf` calls it; src/cli/readiness.ts `behindOf` compares it with the age at the
|
|
56
|
+
* moment asked). It reads what the schedule reads (`nextPollAt`'s table poll, `passDelayMs`'s last pass), never a second
|
|
57
|
+
* measure, and changes neither load rule: it says them.
|
|
58
|
+
* - `poll`: its interval plus max(60 s, twice its own last poll this run; 60 s when not measured, as after a start);
|
|
59
|
+
* - `recopy`: the run's interval plus max(60 s, twice the last pass);
|
|
60
|
+
* - `replication`: `REPLICATION_BEHIND_SECONDS`;
|
|
61
|
+
* - `off`: nothing (null): it is as of its last `mason sync`, and never behind.
|
|
62
|
+
*/
|
|
63
|
+
export function keptPromise(o) {
|
|
64
|
+
if (o.mode === "off")
|
|
65
|
+
return { promisedSeconds: null, lateWhy: null };
|
|
66
|
+
if (o.mode === "replication") {
|
|
67
|
+
return { promisedSeconds: REPLICATION_BEHIND_SECONDS, lateWhy: o.pending !== null ? o.pending : o.notRunning !== null ? `the stream is not running: ${o.notRunning}`
|
|
68
|
+
: "the stream has not had everything the database wrote since then (the Stream section says its lag)" };
|
|
69
|
+
}
|
|
70
|
+
const own = o.mode === "recopy" ? o.lastPassMs : o.lastPollMs;
|
|
71
|
+
const promisedSeconds = o.intervalSeconds + Math.max(POLL_GRACE_FLOOR_SECONDS, own === null ? 0 : (2 * own) / 1000);
|
|
72
|
+
const interval = o.intervalSeconds * 1000;
|
|
73
|
+
const fifth = "to keep the database's load to a fifth";
|
|
74
|
+
let lateWhy;
|
|
75
|
+
if (o.pending !== null)
|
|
76
|
+
lateWhy = o.pending;
|
|
77
|
+
else if (o.passFailed)
|
|
78
|
+
lateWhy = `the sync's last pass failed (mason logs --events says why): it is tried again every ${msText(interval)}`;
|
|
79
|
+
else if (o.mode === "recopy")
|
|
80
|
+
lateWhy = o.lastPassMs === 0 ? "it is read again whole at the next pass"
|
|
81
|
+
: `the last pass, which reads every table again whole, took ${msText(o.lastPassMs)}, and Mason waits four times a pass between passes ${fifth}`;
|
|
82
|
+
else if (4 * o.lastPassMs > interval && 4 * o.lastPassMs >= 4 * (o.lastPollMs ?? 0)) {
|
|
83
|
+
lateWhy = `every polled table's polls took ${msText(o.lastPassMs)} in the last pass, and Mason waits four times that between passes ${fifth}`;
|
|
84
|
+
}
|
|
85
|
+
else if (o.lastPollMs !== null && 4 * o.lastPollMs > interval) {
|
|
86
|
+
lateWhy = `its own poll took ${msText(o.lastPollMs)}, and Mason waits four times a table's poll before its next ${fifth}`;
|
|
87
|
+
}
|
|
88
|
+
else if (o.lastPollMs === null)
|
|
89
|
+
lateWhy = "it has not been polled since Mason started: it is polled at the next pass";
|
|
90
|
+
else
|
|
91
|
+
lateWhy = o.dueMs !== null && o.dueMs > o.now ? `its next poll is due ${clock(o.dueMs)} UTC` : "its poll is due, after the polls of the pass before it";
|
|
92
|
+
return { promisedSeconds, lateWhy };
|
|
93
|
+
}
|
|
39
94
|
/** a poll asked for that cannot run (`Sync.pollNow`), its reason in the asker's words: `code` is the CLI's failure code */
|
|
40
95
|
export class PollRefused extends Error {
|
|
41
96
|
code;
|
|
@@ -111,6 +166,14 @@ export async function writeStaged(w, t, target, stage, where, kept) {
|
|
|
111
166
|
* by its constraint (`duckdb_constraints()`), which every copy of that layout carries and no copy of this one does.
|
|
112
167
|
*/
|
|
113
168
|
export const RETIRED_LAYOUT = "stored in key order with an index, by an older Mason: read again whole, in heap order";
|
|
169
|
+
/**
|
|
170
|
+
* A COPY AN OLDER MASON MADE BEFORE A TYPE WAS HELD AS ITS TEXT (src/duck/held-types.ts): a column this Mason holds as the
|
|
171
|
+
* source's text (a `point`, a `money`, a range) stored as the extension's scan read it (a `point` as a STRUCT). Its
|
|
172
|
+
* record (`copy_state.columns`) was written by code that did not know the column's kind, so it cannot say so: found by
|
|
173
|
+
* what the copy physically is (`duckdb_columns()`), copied again through the normal path (`needsCopy`), serving as it
|
|
174
|
+
* was until its swap; never converted in place.
|
|
175
|
+
*/
|
|
176
|
+
export const HELD_LAYOUT = "stored by an older Mason with a column not held as the source's text: read again whole, each such column as its text";
|
|
114
177
|
/**
|
|
115
178
|
* How long a failed copy waits before it is tried again: four times the attempt, at least 5 minutes, doubled for each
|
|
116
179
|
* failure in a row, at most 6 hours (a copy whose sort failed for good read a production replica whole for
|
|
@@ -168,6 +231,65 @@ export function chunkPages(pages, rows, targetRows = COPY_CHUNK_ROWS) {
|
|
|
168
231
|
return COPY_CHUNK_MAX_PAGES;
|
|
169
232
|
return Math.min(COPY_CHUNK_MAX_PAGES, Math.max(1, Math.round(targetRows / (rows / pages))));
|
|
170
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* A CHUNK'S OUT-OF-LINE BYTES ARE BOUNDED BY THE MEMORY (issue 96: a table of ~20,000 rows of ~68 KB of `jsonb` out of
|
|
236
|
+
* line has a heap of a few hundred pages, so its rows ceiling was the whole table, read as ONE chunk, which ran out of
|
|
237
|
+
* a 7.8 GiB limit). A heap page holds a TOAST pointer of ~20 bytes where the row holds its value, so the rows ceiling
|
|
238
|
+
* (`chunkPages`, measured on heap rows) cannot see what a chunk reads; the source's catalog can: its TOAST table's bytes
|
|
239
|
+
* (`pg_table_size` of `reltoastrelid`), a heap page's share of them, read each attempt (`Sync.outOfLineBytes`). The
|
|
240
|
+
* ceiling is then at most `budget / out-of-line bytes a page` pages, at least one: the budget is the memory limit
|
|
241
|
+
* (`store.memoryLimitBytes`) times `COPY_CHUNK_MEMORY_SHARE`, shared by the readers that may read at once
|
|
242
|
+
* (`MASON_COPY_PARALLEL`). A table with nothing out of line gets exactly the rows ceiling.
|
|
243
|
+
*
|
|
244
|
+
* MEASURED (DuckDB 2.0.0, `duckdb_memory()` sampled every 50 ms; docs/DESIGN.md "Large values out of line"): a chunk
|
|
245
|
+
* of `jsonb` held ~5.5 times its bytes on the source while it read (366 MiB for one 67 MB page), one of uncompressed
|
|
246
|
+
* text 1 to 2 times; so a chunk is given an eighth of the limit, the rest left to the readers beside it, the stream,
|
|
247
|
+
* the polls and the queries, and a chunk that still runs out goes on smaller (`readChunks`).
|
|
248
|
+
*/
|
|
249
|
+
export const COPY_CHUNK_MEMORY_SHARE = 1 / 8;
|
|
250
|
+
/** the bytes one reader's chunk may read out of line: the memory limit's share (`COPY_CHUNK_MEMORY_SHARE`), split
|
|
251
|
+
* between the readers that may read at once */
|
|
252
|
+
export function chunkBudgetBytes(memoryLimitBytes, readers) {
|
|
253
|
+
return (memoryLimitBytes * COPY_CHUNK_MEMORY_SHARE) / Math.max(1, readers);
|
|
254
|
+
}
|
|
255
|
+
/** THE CEILING of a chunk of a table of `pages` heap pages, about `rows` rows and `outOfLine` bytes out of line
|
|
256
|
+
* (its TOAST table's; null: unknown): the rows ceiling (`chunkPages`), held to `budget` bytes out of line, at least
|
|
257
|
+
* one page */
|
|
258
|
+
export function chunkCeiling(pages, rows, outOfLine, budget, targetRows = COPY_CHUNK_ROWS) {
|
|
259
|
+
const byRows = chunkPages(pages, rows, targetRows);
|
|
260
|
+
if (outOfLine === null || outOfLine <= 0 || pages <= 0)
|
|
261
|
+
return byRows;
|
|
262
|
+
return Math.min(byRows, Math.max(1, Math.floor(budget / (outOfLine / pages))));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* NO TABLE IS REFUSED BY ARITHMETIC (fix round 1 of PR 380): whether the engine can write a table's values is the
|
|
266
|
+
* engine's to say, at the write (53200 at a chunk's read or commit, `readChunks`), never a formula over the source's
|
|
267
|
+
* catalog. A refusal decided up front from the TOAST bytes in a row group, measured on a few rows of ~100 KB, refused
|
|
268
|
+
* 140,000 rows of ~2.6 KB through a 128 MB limit that the base synced in one chunk. What stays open: a checkpoint
|
|
269
|
+
* merging a table's small row groups of LARGE values that do not fit invalidates the database (issue 371).
|
|
270
|
+
*/
|
|
271
|
+
/** WHY A TABLE IS NOT SYNCED IN THIS MEMORY (`readChunks`): its rows, the limit as set, and the ways out — in the
|
|
272
|
+
* operator's words, never the engine's own remedies (its threads, its settings) */
|
|
273
|
+
export function tooLargeText(source, what, memoryLimitBytes) {
|
|
274
|
+
return `the rows of ${source} are too large for the memory the synced data may use: ${what}, in ${humanBytes(memoryLimitBytes)} ` +
|
|
275
|
+
`(MASON_DUCKDB_MEMORY; unset, ${DEFAULT_MEMORY_RULE}). Raise MASON_DUCKDB_MEMORY and start mason again, or ` +
|
|
276
|
+
`mason add ${source} --columns … without its widest columns`;
|
|
277
|
+
}
|
|
278
|
+
/** A CHUNK'S RANGE GIVEN BACK to what is left to read (`readChunks`: its chunk ran out of memory and rolled back):
|
|
279
|
+
* put among `gaps` in page order and joined to the gaps it meets, so the gaps still tile what no chunk committed —
|
|
280
|
+
* no page twice, none skipped */
|
|
281
|
+
export function giveBack(gaps, r) {
|
|
282
|
+
gaps.push({ ...r });
|
|
283
|
+
gaps.sort((a, b) => a.first - b.first);
|
|
284
|
+
for (let i = 0; i + 1 < gaps.length;) {
|
|
285
|
+
const a = gaps[i], b = gaps[i + 1];
|
|
286
|
+
if (a.end !== null && a.end === b.first) {
|
|
287
|
+
gaps.splice(i, 2, { first: a.first, end: b.end });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
i++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
171
293
|
/**
|
|
172
294
|
* HOW LONG A CHUNK SHOULD TAKE TO READ, in seconds (`MASON_COPY_CHUNK_SECONDS`, 1 to 300, default 12:
|
|
173
295
|
* a laptop copying a 20 M-row table from a database over the internet showed `0 of ~20,126,276 rows` for minutes, then
|
|
@@ -382,14 +504,46 @@ export function streamRetryDelayMs(intervalMs, failures) {
|
|
|
382
504
|
}
|
|
383
505
|
/** what Mason's backends on the source are called (pg_stat_activity.application_name): the copy's readers and the polls */
|
|
384
506
|
export const SOURCE_APPLICATION_NAME = "mason-data";
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
507
|
+
/**
|
|
508
|
+
* THE ENGINE NEVER HOLDS THE SOURCE'S PASSWORD IN A STRING IT REPEATS (issue 599). DuckDB's `postgres` extension
|
|
509
|
+
* repeats an ATTACH's path whole in its error (`Unable to connect to Postgres at "<path>": …`), and every connection
|
|
510
|
+
* reads it in `duckdb_databases()`; a run whose source restarted under it wrote `password='…'` to its log, its ledger,
|
|
511
|
+
* `mason status` and the web page. So the path is `attachPath`'s, which takes nothing but the sslmode, and the
|
|
512
|
+
* credentials go through the extension's SECRET MANAGER (`sourceSecretSql`): a temporary, in-memory secret of TYPE
|
|
513
|
+
* postgres, made on the writer's turn just before the ATTACH that names it and dropped right after it, whatever the
|
|
514
|
+
* ATTACH did (`attach`). Measured on the extension pinned in cli/extensions.json: its error says the path alone,
|
|
515
|
+
* `duckdb_secrets()` says `password=redacted`, the attachment keeps the credentials it merged at the ATTACH (a copy's
|
|
516
|
+
* readers and a reconnect after a restart connect with the secret gone), and it quotes each value for libpq itself.
|
|
517
|
+
* Not PGPASSWORD: the process's environment is every child's, and setting it races the engine's threads reading it.
|
|
518
|
+
* Whatever a dependency repeats one day, the sync's exits mask it (src/lib/secret-mask.ts).
|
|
519
|
+
*/
|
|
520
|
+
export function attachPath(s) {
|
|
521
|
+
const v = (x) => `'${x.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
522
|
+
return [`sslmode=${v(s.sslmode)}`, `application_name=${v(SOURCE_APPLICATION_NAME)}`].join(" ");
|
|
523
|
+
}
|
|
524
|
+
/** THE ATTACH THE SYNC RUNS: the secret path's, always. A test that shows the mask alone (tests/integration/duck-secret.itest.ts)
|
|
525
|
+
* replaces it through `attachSeamForTest`, which a run never imports: no option, setting or environment reaches it */
|
|
526
|
+
const secretAttachSql = (s) => `ATTACH ${literal(attachPath(s))} AS ${SRC} (TYPE postgres, SECRET ${SOURCE_SECRET}, READ_ONLY)`;
|
|
527
|
+
let attachSqlOf = secretAttachSql;
|
|
528
|
+
export const attachSeamForTest = {
|
|
529
|
+
/** replaces the ATTACH (null: the secret path's again), for a test's process alone */
|
|
530
|
+
set(f) { attachSqlOf = f ?? secretAttachSql; },
|
|
531
|
+
};
|
|
532
|
+
/** the secret the ATTACH names: an explicit name, so no other ATTACH takes it (only `__default_postgres` is taken
|
|
533
|
+
* unnamed), and a wire session's planned SQL cannot name it */
|
|
534
|
+
export const SOURCE_SECRET = "mason_source_credentials";
|
|
535
|
+
/** the source's credentials as the extension's secret: temporary (in memory, never written to disk) and replaced, its
|
|
536
|
+
* password left out when there is none (libpq then reads PGPASSWORD or a pgpass file, as it did before) */
|
|
537
|
+
export function sourceSecretSql(s) {
|
|
538
|
+
return `CREATE OR REPLACE TEMPORARY SECRET ${SOURCE_SECRET} (TYPE postgres, HOST ${literal(s.host)}, PORT ${literal(String(s.port))}, ` +
|
|
539
|
+
`DATABASE ${literal(s.database)}, USER ${literal(s.user)}${s.password ? `, PASSWORD ${literal(s.password)}` : ""})`;
|
|
390
540
|
}
|
|
391
541
|
/** a table's columns as a copy is made with them: their names and source types, in order */
|
|
392
|
-
const columnsKey = (columns) => JSON.stringify(columns.map((c) =>
|
|
542
|
+
const columnsKey = (columns) => JSON.stringify(columns.map((c) => {
|
|
543
|
+
// what the copy holds is part of the shape: a copy made before a column's type was held as its text is read again
|
|
544
|
+
const hold = holdOf(c.type, c.typeKind);
|
|
545
|
+
return hold === "same" && c.domain !== true ? [c.name, c.type] : [c.name, c.type, hold, ...(c.domain === true ? ["domain"] : [])];
|
|
546
|
+
}));
|
|
393
547
|
/** `postgres_query` over the attached source: a read of Postgres's own SQL in the DuckDB transaction's snapshot */
|
|
394
548
|
const pq = (sql) => `postgres_query(${literal(SRC)}, ${literal(sql)})`;
|
|
395
549
|
/**
|
|
@@ -405,6 +559,16 @@ const pq = (sql) => `postgres_query(${literal(SRC)}, ${literal(sql)})`;
|
|
|
405
559
|
*/
|
|
406
560
|
const NUMERIC_TYPE = /^numeric(?:\((\d+)(?:,\s*(\d+))?\))?$/;
|
|
407
561
|
const WIDER = "mason: a numeric value wider than its synced column";
|
|
562
|
+
/** Whether a ROLLBACK's `e` says the engine had ended the transaction itself ("cannot rollback - no transaction is
|
|
563
|
+
* active", after a COMMIT or a read out of memory): a decision, so it reads the raw words (`messageOf`) */
|
|
564
|
+
export function endedTransaction(e) {
|
|
565
|
+
return /no transaction is active/.test(messageOf(e));
|
|
566
|
+
}
|
|
567
|
+
/** Whether `e` is a numeric of `source` wider than its synced column (`WIDER`): a decision, so it reads the error's raw
|
|
568
|
+
* words (`messageOf`), never the words Mason repeats (`errorText`) — the stream's, the catch-up's and the poll's alike */
|
|
569
|
+
export function widerIn(e, source) {
|
|
570
|
+
return messageOf(e).includes(`${WIDER} [${source}]`);
|
|
571
|
+
}
|
|
408
572
|
/** a source numeric the extension reads as a DOUBLE: no precision, or more than DuckDB's 38 digits */
|
|
409
573
|
function readsAsDouble(type) {
|
|
410
574
|
const m = NUMERIC_TYPE.exec(type);
|
|
@@ -418,16 +582,9 @@ function fitsDecimal(column, scale, source, significant) {
|
|
|
418
582
|
return `CASE WHEN length(${decimals}) > ${scale} OR length(regexp_extract(${v}, '^-?(\\d+)', 1)) > ${38 - scale} ` +
|
|
419
583
|
`OR ${v} IN ('NaN', 'Infinity', '-Infinity') THEN error(${literal(`${WIDER} [${source}]`)}) ELSE ${v} END AS ${v}`;
|
|
420
584
|
}
|
|
421
|
-
/** a numeric's text that does not fit DECIMAL(38, scale): `fitsDecimal`'s rule, for a value the stream brings
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return true;
|
|
425
|
-
const m = /^-?(\d*)(?:\.(\d+))?$/.exec(text);
|
|
426
|
-
if (m === null)
|
|
427
|
-
return true;
|
|
428
|
-
const decimals = significant ? (m[2] ?? "").replace(/0+$/, "") : m[2] ?? "";
|
|
429
|
-
return decimals.length > scale || m[1].length > 38 - scale;
|
|
430
|
-
}
|
|
585
|
+
/** a numeric's text that does not fit DECIMAL(38, scale): `fitsDecimal`'s rule, for a value the stream brings (the one
|
|
586
|
+
* rule, src/lib/pg-numeric.ts `numericWiderThan`) */
|
|
587
|
+
const widerThan = (text, scale, significant) => numericWiderThan(text, scale, significant);
|
|
431
588
|
/** The DuckDB type a copied column is stored as: the extension's own, but a JSON for a json / jsonb (it reads them as text). */
|
|
432
589
|
/** a char(n) column (format_type's `character(n)`): the extension reads its values with the padding trimmed */
|
|
433
590
|
const CHAR_TYPE = /^character\((\d+)\)$/;
|
|
@@ -437,10 +594,12 @@ function storedValue(c) {
|
|
|
437
594
|
return m ? `rpad(${ident(c.name)}, ${m[1]}, ' ')` : ident(c.name);
|
|
438
595
|
}
|
|
439
596
|
function storedType(c, scanned) {
|
|
440
|
-
|
|
597
|
+
const hold = holdOf(c.type, c.typeKind);
|
|
598
|
+
return hold === "text" ? "VARCHAR" : hold === "text[]" ? "VARCHAR[]" : /^jsonb?$/.test(c.type) ? "JSON" : scanned;
|
|
441
599
|
}
|
|
442
|
-
/** the order the queue takes them in: a catch-up is one poll that makes a copied table current
|
|
443
|
-
|
|
600
|
+
/** the order the queue takes them in: a catch-up is one poll that makes a copied table current; a generated table's
|
|
601
|
+
* re-run reads what the copies hold, so it goes behind them */
|
|
602
|
+
const REBUILD_ORDER = ["catch-up", "copy", "generated"];
|
|
444
603
|
export class Sync {
|
|
445
604
|
store;
|
|
446
605
|
source;
|
|
@@ -466,7 +625,7 @@ export class Sync {
|
|
|
466
625
|
/** each table whose copy failed: the failures in a row, and when it is tried again (`copyRetryDelayMs`) */
|
|
467
626
|
retries = new Map();
|
|
468
627
|
/** each copied table in a layout this Mason no longer keeps (`RETIRED_LAYOUT`), found at `open`: copied again */
|
|
469
|
-
retired = new
|
|
628
|
+
retired = new Map();
|
|
470
629
|
/** the catalog's own connection (`rebuildSchema`): it sees only what a copy or poll has committed, and is never
|
|
471
630
|
* held by a copy's statement, so a table is served the moment its copy commits */
|
|
472
631
|
catalog = null;
|
|
@@ -474,6 +633,8 @@ export class Sync {
|
|
|
474
633
|
schemaBuild = Promise.resolve();
|
|
475
634
|
tables = new Map();
|
|
476
635
|
views = [];
|
|
636
|
+
/** the source's collations (src/cli/preflight.ts), which the catalog declares where it lacks one (`setCollations`) */
|
|
637
|
+
collations = [];
|
|
477
638
|
states = new Map();
|
|
478
639
|
/** each copied table's columns as its copy was made with them (`columnsKey`): a change copies it again */
|
|
479
640
|
copiedAs = new Map();
|
|
@@ -599,6 +760,14 @@ export class Sync {
|
|
|
599
760
|
dbPositionRead = null;
|
|
600
761
|
/** THE STORED JSON PATHS (src/duck/kept.ts): every write below computes them; the plans read them */
|
|
601
762
|
kept;
|
|
763
|
+
/** THE GENERATED TABLES (src/duck/generated.ts): every commit that changes a copy marks them, the rebuild queue runs them */
|
|
764
|
+
generated;
|
|
765
|
+
/** the connection their runs read and write on (`open`), set up as a session: what `stop` interrupts */
|
|
766
|
+
generatedLane = null;
|
|
767
|
+
/** the generated lane's session set-up: settled before each plan and run (`GeneratedHost.settle`) */
|
|
768
|
+
generatedSession = null;
|
|
769
|
+
/** the review's check's own connection and its set-up, never the run's (`GeneratedHost.checkConnection`) */
|
|
770
|
+
generatedCheck = null;
|
|
602
771
|
constructor(store, source, database, log, intervalMs, mode = "poll", replication = null, options = {}) {
|
|
603
772
|
this.store = store;
|
|
604
773
|
this.source = source;
|
|
@@ -616,7 +785,7 @@ export class Sync {
|
|
|
616
785
|
throw new Error(`a first sync's chunk takes a positive number of seconds, not ${this.copyChunkSeconds} (Mason bug: report it; copyChunkSecondsOf reads MASON_COPY_CHUNK_SECONDS)`);
|
|
617
786
|
this.onChunk = options.onChunk;
|
|
618
787
|
this.onEvent = options.onEvent ?? (() => undefined);
|
|
619
|
-
this.diskFree = options.diskFree ??
|
|
788
|
+
this.diskFree = options.diskFree ?? freeBytesAt;
|
|
620
789
|
if (mode === "replication" && replication === null)
|
|
621
790
|
throw new Error("replication mode needs its slot and publication (Mason bug: report it)");
|
|
622
791
|
this.replication = replication;
|
|
@@ -631,6 +800,55 @@ export class Sync {
|
|
|
631
800
|
rebuilding: (source) => this.rebuildingNow?.source === source,
|
|
632
801
|
log: (line) => this.log(line),
|
|
633
802
|
}, { enabled: options.json?.enabled ?? true, ...options.json });
|
|
803
|
+
const generatedLane = () => {
|
|
804
|
+
if (this.generatedLane === null)
|
|
805
|
+
throw new Error("the generated tables' connection is not open (Mason bug: report it)");
|
|
806
|
+
return this.generatedLane;
|
|
807
|
+
};
|
|
808
|
+
this.generated = new GeneratedTables(store, {
|
|
809
|
+
onWriter: (f) => this.onWriter(f),
|
|
810
|
+
connection: () => generatedLane().conn,
|
|
811
|
+
settle: async () => {
|
|
812
|
+
if (this.generatedSession === null)
|
|
813
|
+
throw new Error("the generated tables' connection is not open (Mason bug: report it)");
|
|
814
|
+
await this.generatedSession.settle(NO_ROLE_SESSION);
|
|
815
|
+
},
|
|
816
|
+
checkConnection: () => {
|
|
817
|
+
if (this.generatedCheck === null)
|
|
818
|
+
throw new Error("the generated tables' check connection is not open (Mason bug: report it)");
|
|
819
|
+
return this.generatedCheck.conn;
|
|
820
|
+
},
|
|
821
|
+
settleCheck: async () => {
|
|
822
|
+
if (this.generatedCheck === null)
|
|
823
|
+
throw new Error("the generated tables' check connection is not open (Mason bug: report it)");
|
|
824
|
+
await this.generatedCheck.setup.settle(NO_ROLE_SESSION);
|
|
825
|
+
},
|
|
826
|
+
enqueue: (name) => this.enqueue(name, { kind: "generated" }),
|
|
827
|
+
interruptible: (f) => this.interruptible(generatedLane(), f),
|
|
828
|
+
added: (name) => this.addedAs(name),
|
|
829
|
+
syncedAt: (base) => this.synced.get(base)?.at ?? null,
|
|
830
|
+
rebuildCatalog: () => this.rebuildSchema(),
|
|
831
|
+
schema: () => this.currentSchema,
|
|
832
|
+
retryDelayMs: copyRetryDelayMs,
|
|
833
|
+
stopping: (e) => e instanceof Stopping,
|
|
834
|
+
log: (line) => this.log(line),
|
|
835
|
+
event: (e) => this.event(e),
|
|
836
|
+
}, options.generated ?? {});
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* A BASE IS THERE WHEN THE CATALOG HOLDS IT (issue 387), the generated tables' `added` (src/duck/generated.ts
|
|
840
|
+
* `GeneratedHost.added`): `copied` only when the copy is recorded copied AND the catalog's schema (`currentSchema`, what
|
|
841
|
+
* the listener's catalog is replaced from and every definition is planned over) holds the table; a table the sources
|
|
842
|
+
* file adds that the catalog does not hold yet is `adding`, whatever its copy's phase. The copy records `copied` before
|
|
843
|
+
* its rebuild of the catalog (which reads that record), and the rebuild waits behind any other: said `copied` from the
|
|
844
|
+
* phase alone, a definition planned in between met 42P01 for a synced table and was refused, and a run could be planned
|
|
845
|
+
* over a catalog without its base. The one place this is decided: the check and `ready` read it.
|
|
846
|
+
*/
|
|
847
|
+
addedAs(name) {
|
|
848
|
+
const t = this.tables.get(name);
|
|
849
|
+
if (t !== undefined)
|
|
850
|
+
return this.states.get(name)?.phase === "copied" && this.currentSchema.tables.some((x) => x.schema === t.schema && x.name === t.table) ? "copied" : "adding";
|
|
851
|
+
return this.views.some((v) => `${v.schema ?? "public"}.${v.name}` === name) ? "view" : null;
|
|
634
852
|
}
|
|
635
853
|
/** the rebuilds' connection: open from `open` until `stop` */
|
|
636
854
|
get rebuilder() {
|
|
@@ -645,9 +863,15 @@ export class Sync {
|
|
|
645
863
|
this.onEvent(e);
|
|
646
864
|
}
|
|
647
865
|
catch (x) {
|
|
648
|
-
this.log(`the sync's event (${e.kind}${e.table ? ` ${e.table}` : ""}) could not be recorded: ${
|
|
866
|
+
this.log(`the sync's event (${e.kind}${e.table ? ` ${e.table}` : ""}) could not be recorded: ${this.errorText(x)}`);
|
|
649
867
|
}
|
|
650
868
|
}
|
|
869
|
+
/** A CAUGHT ERROR'S WORDS, AS THE SYNC REPEATS THEM (src/lib/secret-mask.ts `errorText`): every engine or driver
|
|
870
|
+
* error becomes text here, once, where it enters — a log line, an event, a table's `error`, an answer is built from
|
|
871
|
+
* it — a `password=` value masked, nothing else changed (no error the sync meets holds the password: it travels in
|
|
872
|
+
* the temporary secret alone, whose own error is never repeated). A decision never reads these words: it reads
|
|
873
|
+
* `messageOf`, the raw ones. tests/secret-mask.test.ts holds src/duck/sync.ts and stream.ts to both */
|
|
874
|
+
errorText(e, o = {}) { return errorText(e, o); }
|
|
651
875
|
/** a whole-sync failure (`wholeFailure`) is over: said once, as a `recovered` event of no table */
|
|
652
876
|
recovered(what, words) {
|
|
653
877
|
const was = this.wholeFailure[what];
|
|
@@ -699,6 +923,7 @@ export class Sync {
|
|
|
699
923
|
this.recordedSync.set(source, sync);
|
|
700
924
|
}
|
|
701
925
|
await this.readPolledAt();
|
|
926
|
+
await this.scrubLedger();
|
|
702
927
|
const r = await this.store.writer.runAndReadAll(`SELECT source, phase, "rows", bytes, seconds, epoch_ms(copied_at), error, columns::VARCHAR FROM ${MASON_SCHEMA}.copy_state`);
|
|
703
928
|
for (const row of r.getRowsJS()) {
|
|
704
929
|
const [source, phase, rows, bytes, seconds, copiedMs, error, columns] = row;
|
|
@@ -716,9 +941,53 @@ export class Sync {
|
|
|
716
941
|
this.removeStageLeftovers();
|
|
717
942
|
// the stored JSON paths, before the catalog serves anything: each served copy's view, what the file holds
|
|
718
943
|
await this.kept.open();
|
|
944
|
+
// the run's connection is a session's, set up by the one set-up (src/duck/store.ts `SessionSetup`): a plan's SQL reads it
|
|
945
|
+
const conn = await this.store.instance.connect();
|
|
946
|
+
this.generatedSession = await SessionSetup.open(conn, GENERATED_SESSION);
|
|
947
|
+
this.generatedLane = { conn, inFlight: false };
|
|
948
|
+
const checkConn = await this.store.instance.connect();
|
|
949
|
+
this.generatedCheck = { conn: checkConn, setup: await SessionSetup.open(checkConn, GENERATED_SESSION) };
|
|
950
|
+
await this.generated.open();
|
|
719
951
|
this.catalog = await this.store.instance.connect();
|
|
720
952
|
await this.rebuildSchema();
|
|
721
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* THE LEDGER SCRUBBED OF A PASSWORD AN EARLIER MASON WROTE (issue 599): before the engine was given the credentials
|
|
956
|
+
* as a secret, a failed connection's error held `password='…'`, and every table's error (`copy_state.error`) and every
|
|
957
|
+
* event (`sync_events.message`) written then kept it, shown by mason status and the web page for as long as they stand.
|
|
958
|
+
* At every open, those two free-text fields alone are read where they hold a `password=` keyword, its value masked
|
|
959
|
+
* (`maskPasswordKeyword`: the exact leaked form, never a substring of the password now, which a table's name may hold)
|
|
960
|
+
* and written back by bound parameters where that changed them, said in the log with the way out: rotate the password.
|
|
961
|
+
*/
|
|
962
|
+
async scrubLedger() {
|
|
963
|
+
const w = this.store.writer;
|
|
964
|
+
const KEYWORD = "%password%=%";
|
|
965
|
+
let scrubbed = 0;
|
|
966
|
+
const errors = (await w.runAndReadAll(`SELECT source, error FROM ${MASON_SCHEMA}.copy_state WHERE error ILIKE $1`, [KEYWORD])).getRowsJS();
|
|
967
|
+
for (const [source, error] of errors) {
|
|
968
|
+
const masked = maskPasswordKeyword(error);
|
|
969
|
+
if (masked === error)
|
|
970
|
+
continue;
|
|
971
|
+
await w.run(`UPDATE ${MASON_SCHEMA}.copy_state SET error = $1 WHERE source = $2`, [masked, source]);
|
|
972
|
+
scrubbed++;
|
|
973
|
+
}
|
|
974
|
+
// the events' table is SyncEvents' (src/duck/events.ts), opened before the sync by the run; a test's store may have none
|
|
975
|
+
const events = (await w.runAndReadAll(`SELECT count(*) FROM duckdb_tables() WHERE schema_name = $1 AND table_name = 'sync_events'`, [MASON_SCHEMA])).getRowsJS();
|
|
976
|
+
if (Number(events[0][0]) > 0) {
|
|
977
|
+
const rows = (await w.runAndReadAll(`SELECT DISTINCT message FROM ${MASON_SCHEMA}.sync_events WHERE message ILIKE $1`, [KEYWORD])).getRowsJS();
|
|
978
|
+
for (const [message] of rows) {
|
|
979
|
+
const masked = maskPasswordKeyword(message);
|
|
980
|
+
if (masked === message)
|
|
981
|
+
continue;
|
|
982
|
+
const changed = await w.run(`UPDATE ${MASON_SCHEMA}.sync_events SET message = $1 WHERE message = $2`, [masked, message]);
|
|
983
|
+
scrubbed += changed.rowsChanged;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (scrubbed > 0) {
|
|
987
|
+
this.log(`removed the database's password from ${scrubbed} error row(s) an earlier Mason wrote to its record of the sync (issue 599): ` +
|
|
988
|
+
`it was shown by mason status, mason logs --events and the web page, and may stand in .mason/*.log files — change the database user's password (ALTER ROLE … PASSWORD), then delete those log files`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
722
991
|
/** the tables the project adds now (its sources file): a new one is copied at the next pass unless its copy is already
|
|
723
992
|
* here and current (said as its records say it, `addedText`), a removed one dropped.
|
|
724
993
|
* What it returns is the catalog rebuilt over them (never rejected: a failure is said in the log) */
|
|
@@ -759,7 +1028,7 @@ export class Sync {
|
|
|
759
1028
|
this.behind.set(t.source, mark);
|
|
760
1029
|
void this.onWriter(() => this.store.writer.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.catch_up VALUES (${literal(t.source)}, ${literal(why)}, now())`)).catch((e) => {
|
|
761
1030
|
// its next start catches it up again only by this row: said, and the mark kept for this run's own catch-up
|
|
762
|
-
this.log(`${t.source}: its catch-up could not be recorded (a stop before it ends catches it up again only if its sync changes): ${
|
|
1031
|
+
this.log(`${t.source}: its catch-up could not be recorded (a stop before it ends catches it up again only if its sync changes): ${this.errorText(e)}`);
|
|
763
1032
|
});
|
|
764
1033
|
}
|
|
765
1034
|
}
|
|
@@ -788,7 +1057,7 @@ export class Sync {
|
|
|
788
1057
|
this.retries.delete(t.source);
|
|
789
1058
|
}
|
|
790
1059
|
// the tables copied before are served at once, not after this start's first pass (its copies may take hours)
|
|
791
|
-
const built = this.rebuildSchema().catch((e) => this.log(`the catalog could not be rebuilt (it is at the next pass): ${
|
|
1060
|
+
const built = this.rebuildSchema().catch((e) => this.log(`the catalog could not be rebuilt (it is at the next pass): ${this.errorText(e)}`));
|
|
792
1061
|
this.kick();
|
|
793
1062
|
return built;
|
|
794
1063
|
}
|
|
@@ -804,7 +1073,7 @@ export class Sync {
|
|
|
804
1073
|
const copy = st === undefined ? "never synced"
|
|
805
1074
|
: st.phase === "failed" ? `its first sync failed: ${firstLine(st.error)}`
|
|
806
1075
|
: st.phase !== "copied" ? "its first sync has not finished"
|
|
807
|
-
: this.retired.has(t.source) ?
|
|
1076
|
+
: this.retired.has(t.source) ? `it is ${this.retired.get(t.source).short}`
|
|
808
1077
|
: this.readAgainWhy(t);
|
|
809
1078
|
if (copy !== null)
|
|
810
1079
|
return { source: t.source, copy };
|
|
@@ -908,6 +1177,10 @@ export class Sync {
|
|
|
908
1177
|
setViews(views) {
|
|
909
1178
|
this.views = views.map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, ...(v.comment ? { description: v.comment } : {}) }));
|
|
910
1179
|
}
|
|
1180
|
+
/** the source's collations (src/duck/collation.ts): the catalog declares each it lacks, from the next pass */
|
|
1181
|
+
setCollations(collations) {
|
|
1182
|
+
this.collations = collations.map((c) => ({ schema: c.schema, name: c.name, provider: c.provider, collate: c.collate, locale: c.locale, deterministic: c.deterministic }));
|
|
1183
|
+
}
|
|
911
1184
|
/** every table read again from the start: the database was restored to another server (src/cli/dev.ts sourceMove) */
|
|
912
1185
|
async rereadAll() {
|
|
913
1186
|
// from watermark 0 every row is read and written again; the count check deletes what the new server lacks
|
|
@@ -919,7 +1192,7 @@ export class Sync {
|
|
|
919
1192
|
const now = Date.now();
|
|
920
1193
|
// in the project's order, whatever order the copies ran and were recorded in (`copyOrder`)
|
|
921
1194
|
return [...this.tables.keys()].flatMap((source) => this.states.get(source) ?? [])
|
|
922
|
-
.map((s) => this.retired.
|
|
1195
|
+
.map((s) => { const r = this.retired.get(s.source); return r !== undefined ? { ...s, retired: r.layout } : s; })
|
|
923
1196
|
// a copy's chunks as they are NOW: the rows the ones in flight have read by now at the measured rate
|
|
924
1197
|
.map((s) => { const l = this.copyLive.get(s.source); return s.phase === "copying" && l !== undefined ? { ...s, progress: copyProgressNow(l, Date.now()) } : s; })
|
|
925
1198
|
// how it is kept current, and a table the passes leave as copied, from ONE derivation (`keptOf`)
|
|
@@ -954,20 +1227,29 @@ export class Sync {
|
|
|
954
1227
|
// says nothing is pending, `mason sync` of it runs
|
|
955
1228
|
const pending = this.pollable(t) ? null : this.notStreamedWhy(t.source);
|
|
956
1229
|
const next = pending === null ? this.dueAt(t) : null;
|
|
957
|
-
|
|
1230
|
+
const promise = keptPromise({ mode: sync.mode === "off" ? "off" : "poll", intervalSeconds: sync.mode === "poll" ? sync.seconds : 0, lastPollMs: this.lastPollMs.get(t.source) ?? null,
|
|
1231
|
+
lastPassMs: this.lastPassMs, pending, notRunning: null, passFailed: this.passFailed, dueMs: next, now });
|
|
1232
|
+
return { by: "poll", sync, syncedAt: polledAt, nextAt: next === null ? null : new Date(Math.max(next, now)).toISOString(), ...(pending !== null ? { pending } : {}), ...promise };
|
|
958
1233
|
}
|
|
959
1234
|
// copied again whole every pass, whatever its watermark: only a table not copied now (waiting, being copied, its copy
|
|
960
|
-
// failed) is not kept current
|
|
961
|
-
if (this.mode === "recopy")
|
|
962
|
-
|
|
1235
|
+
// failed) is not kept current; its synced data is as of its copy's watermark (`polled_at`, taken before the swap)
|
|
1236
|
+
if (this.mode === "recopy") {
|
|
1237
|
+
const pending = st?.phase !== "copied" ? this.notStreamedWhy(t.source) : null;
|
|
1238
|
+
const promise = keptPromise({ mode: "recopy", intervalSeconds: this.intervalMs / 1000, lastPollMs: null, lastPassMs: this.lastPassMs, pending, notRunning: null,
|
|
1239
|
+
passFailed: this.passFailed, dueMs: null, now });
|
|
1240
|
+
return { by: "recopy", copiedAt: st?.copiedAt ?? null, syncedAt: this.polledAt.get(t.source) ?? null, ...(pending !== null ? { pending } : {}), ...promise };
|
|
1241
|
+
}
|
|
963
1242
|
const s = this.stream;
|
|
964
1243
|
const part = this.streamTable(t, now, s);
|
|
965
1244
|
// current as of its last copy or poll, or — streamed — the last moment the stream had all the database wrote
|
|
966
1245
|
const polled = this.polledAt.get(t.source) ?? null;
|
|
967
1246
|
const streamed = part.state === "streamed" && this.streamCurrentAt !== null ? new Date(this.streamCurrentAt).toISOString() : null;
|
|
968
1247
|
const syncedAt = streamed !== null && (polled === null || streamed > polled) ? streamed : polled;
|
|
1248
|
+
const notRunning = this.streamDownWhy(s, st);
|
|
1249
|
+
const promise = keptPromise({ mode: "replication", intervalSeconds: 0, lastPollMs: null, lastPassMs: this.lastPassMs, pending: part.state === "streamed" ? null : part.why,
|
|
1250
|
+
notRunning, passFailed: false, dueMs: null, now });
|
|
969
1251
|
return { by: "replication", slot: this.replication?.slot ?? "", state: part.state, why: part.why, position: formatLsn(s?.position() ?? this.appliedLsn),
|
|
970
|
-
lastCommit: this.lastBatch === null ? null : new Date(this.lastBatch.committedAt).toISOString(), notRunning
|
|
1252
|
+
lastCommit: this.lastBatch === null ? null : new Date(this.lastBatch.committedAt).toISOString(), notRunning, syncedAt, ...promise };
|
|
971
1253
|
}
|
|
972
1254
|
/** why the stream is not running, in the reader's words (null: it runs): its last failure and when it starts again; the
|
|
973
1255
|
* replication check a pass failed (`prepareReplication`, recorded on each copy's state); a slot not made yet; a sync
|
|
@@ -1071,7 +1353,7 @@ export class Sync {
|
|
|
1071
1353
|
}
|
|
1072
1354
|
this.running = this.pass().then(() => { this.passFailed = false; }, (e) => {
|
|
1073
1355
|
this.passFailed = true;
|
|
1074
|
-
this.log(`sync pass failed: ${
|
|
1356
|
+
this.log(`sync pass failed: ${this.errorText(e)}`);
|
|
1075
1357
|
}).finally(() => {
|
|
1076
1358
|
this.running = null;
|
|
1077
1359
|
if (this.stopped)
|
|
@@ -1174,6 +1456,16 @@ export class Sync {
|
|
|
1174
1456
|
* every table again whole. Its failure is its caller's to say: the copies serve as they were, and the passes go on.
|
|
1175
1457
|
*/
|
|
1176
1458
|
async pollNow(sources) {
|
|
1459
|
+
// its failure is the command server's answer, sent as it is: a refusal is Mason's own words, any other error's are
|
|
1460
|
+
// made Mason's here (`errorText`), where they leave the sync
|
|
1461
|
+
try {
|
|
1462
|
+
return await this.pollNowOf(sources);
|
|
1463
|
+
}
|
|
1464
|
+
catch (e) {
|
|
1465
|
+
throw e instanceof PollRefused || e instanceof Stopping ? e : new Error(this.errorText(e));
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
async pollNowOf(sources) {
|
|
1177
1469
|
if (this.mode === "recopy")
|
|
1178
1470
|
throw new PollRefused("refused", "this run reads every table again whole at each pass (MASON_DUCK_SYNC=recopy), so there is nothing to poll");
|
|
1179
1471
|
const tables = sources.map((s) => {
|
|
@@ -1202,8 +1494,9 @@ export class Sync {
|
|
|
1202
1494
|
const again = this.readAgainWhy(t);
|
|
1203
1495
|
if (again !== null)
|
|
1204
1496
|
throw new PollRefused("nothing_yet", `${t.source} is being read again whole (${again}): mason wait waits for it`);
|
|
1205
|
-
|
|
1206
|
-
|
|
1497
|
+
const r = this.retired.get(t.source);
|
|
1498
|
+
if (r !== undefined)
|
|
1499
|
+
throw new PollRefused("nothing_yet", `${t.source} is ${r.layout} (its synced data serves as it was): it is current once read again — mason wait waits for it`);
|
|
1207
1500
|
this.refuseRebuilding(t);
|
|
1208
1501
|
}
|
|
1209
1502
|
const poll = (async () => {
|
|
@@ -1260,14 +1553,14 @@ export class Sync {
|
|
|
1260
1553
|
}
|
|
1261
1554
|
// the stream, the pass and the rebuild end together: a batch waiting for the writer's turn behind a poll must not
|
|
1262
1555
|
// wait for that poll, so each connection's long statement is interrupted from the start
|
|
1263
|
-
const interrupt = () => { for (const lane of [this.writerLane, this.rebuilderLane, ...this.copyLanes])
|
|
1556
|
+
const interrupt = () => { for (const lane of [this.writerLane, this.rebuilderLane, this.generatedLane, ...this.copyLanes])
|
|
1264
1557
|
if (lane?.inFlight)
|
|
1265
1558
|
lane.conn.interrupt(); };
|
|
1266
1559
|
const again = setInterval(interrupt, 50);
|
|
1267
1560
|
interrupt();
|
|
1268
1561
|
try {
|
|
1269
1562
|
// none of them rejects: each says its own failure
|
|
1270
|
-
await Promise.all([this.stream?.stop(), this.running, this.rebuilding, this.kept.stop()]);
|
|
1563
|
+
await Promise.all([this.stream?.stop(), this.running, this.rebuilding, this.kept.stop(), this.generated.stop()]);
|
|
1271
1564
|
await this.writerTurn;
|
|
1272
1565
|
}
|
|
1273
1566
|
finally {
|
|
@@ -1280,6 +1573,12 @@ export class Sync {
|
|
|
1280
1573
|
this.catalog = null;
|
|
1281
1574
|
this.rebuilderLane?.conn.closeSync();
|
|
1282
1575
|
this.rebuilderLane = null;
|
|
1576
|
+
this.generatedLane?.conn.closeSync();
|
|
1577
|
+
this.generatedLane = null;
|
|
1578
|
+
this.generatedSession = null;
|
|
1579
|
+
// after `generated.stop`, which waited for the review and so for any check on it
|
|
1580
|
+
this.generatedCheck?.conn.closeSync();
|
|
1581
|
+
this.generatedCheck = null;
|
|
1283
1582
|
}
|
|
1284
1583
|
/** a poll asked of a table while it is rebuilt or not yet caught up: refused, by name (it is caught up when that ends) */
|
|
1285
1584
|
refuseRebuilding(t) {
|
|
@@ -1536,6 +1835,33 @@ export class Sync {
|
|
|
1536
1835
|
` (the slot held the database's WAL)${r.owned ? "" : `; publication ${r.publication} is its owner's, left as it is`} — a table set to sync: replication makes the slot again`);
|
|
1537
1836
|
this.onReplication?.(false);
|
|
1538
1837
|
}
|
|
1838
|
+
/**
|
|
1839
|
+
* THE STREAM HAS EVERYTHING THE DATABASE WROTE (a keepalive with nothing pending, or a batch flushed): every streamed
|
|
1840
|
+
* table is current as of now (`keptOf`'s syncedAt), and a copied one's replication error (the stream's failure, the
|
|
1841
|
+
* preparation's, from this run or one before) is over — the same proof a batch applied with a position gives
|
|
1842
|
+
* (`applyChanges`), which an idle database never sends: a healthy table kept a run-old stream error for ever, and
|
|
1843
|
+
* `mason wait` read it as failed. Cleared on the writer's turn, one turn at a time.
|
|
1844
|
+
*/
|
|
1845
|
+
streamCurrent() {
|
|
1846
|
+
this.streamCurrentAt = Date.now();
|
|
1847
|
+
if (this.clearingStreamErrors)
|
|
1848
|
+
return;
|
|
1849
|
+
const stale = (source) => {
|
|
1850
|
+
const st = this.states.get(source);
|
|
1851
|
+
return st !== undefined && st.phase === "copied" && st.error?.startsWith("replication: ") === true && !this.unpublished.has(source) && !this.held.has(source);
|
|
1852
|
+
};
|
|
1853
|
+
if (![...this.joinedNow].some(stale))
|
|
1854
|
+
return;
|
|
1855
|
+
this.clearingStreamErrors = true;
|
|
1856
|
+
void this.onWriter(async () => {
|
|
1857
|
+
for (const source of this.joinedNow)
|
|
1858
|
+
if (stale(source))
|
|
1859
|
+
await this.record({ ...this.states.get(source), error: null });
|
|
1860
|
+
this.recovered("stream", "the stream has everything the database wrote again");
|
|
1861
|
+
}).catch((e) => this.log(`replication: a table's stream error could not be cleared (it is cleared at the next batch): ${this.errorText(e)}`))
|
|
1862
|
+
.finally(() => { this.clearingStreamErrors = false; });
|
|
1863
|
+
}
|
|
1864
|
+
clearingStreamErrors = false;
|
|
1539
1865
|
/**
|
|
1540
1866
|
* The stream started from the slot's acknowledged position, once the slot is known (a pass's `prepareReplication`),
|
|
1541
1867
|
* and never stopped by a pass: a copy runs beside it. What it applies is `streamed`, read at each change, so a table
|
|
@@ -1547,10 +1873,10 @@ export class Sync {
|
|
|
1547
1873
|
const r = this.replication;
|
|
1548
1874
|
this.refreshStreamed();
|
|
1549
1875
|
const s = new ReplicationStream({
|
|
1550
|
-
connect: () => PgConnection.connect({ ...this.pgOptions(), replication: true }), slot: r.slot, publication: r.publication, log: (l) => this.log(l),
|
|
1876
|
+
connect: () => PgConnection.connect({ ...this.pgOptions(), replication: true }), slot: r.slot, publication: r.publication, log: (l) => this.log(l), errorText: (e) => this.errorText(e),
|
|
1551
1877
|
target: { tables: () => this.streamed, apply: (batch, lsn, committedAt) => this.applyStreamed(batch, lsn, committedAt), applied: () => this.appliedLsn,
|
|
1552
1878
|
refuse: (source, missing) => this.refuseStreamed(source, missing), current: () => { if (this.stream === s)
|
|
1553
|
-
this.
|
|
1879
|
+
this.streamCurrent(); } },
|
|
1554
1880
|
});
|
|
1555
1881
|
this.stream = s;
|
|
1556
1882
|
this.streamFailure = null;
|
|
@@ -1571,7 +1897,7 @@ export class Sync {
|
|
|
1571
1897
|
// a numeric wider than its copy's DECIMAL: that table leaves the stream and is copied again, wider, and the stream
|
|
1572
1898
|
// goes on at once without it (it joins again caught up from its new copy) — at most three times a run, past which
|
|
1573
1899
|
// a value the stream keeps replaying is wider than any copy of now
|
|
1574
|
-
const wider = [...this.tables.values()].find((t) => e
|
|
1900
|
+
const wider = [...this.tables.values()].find((t) => widerIn(e, t.source));
|
|
1575
1901
|
const n = wider === undefined ? 0 : (this.widened.get(wider.source) ?? 0) + 1;
|
|
1576
1902
|
if (wider !== undefined && n <= 3) {
|
|
1577
1903
|
this.widened.set(wider.source, n);
|
|
@@ -1581,7 +1907,7 @@ export class Sync {
|
|
|
1581
1907
|
this.kick();
|
|
1582
1908
|
return;
|
|
1583
1909
|
}
|
|
1584
|
-
const m = `replication: ${e
|
|
1910
|
+
const m = `replication: ${this.errorText(e)}`;
|
|
1585
1911
|
// a failure that recurs must not restart the stream every second: each restart decodes the slot's WAL again on
|
|
1586
1912
|
// the source
|
|
1587
1913
|
this.streamFailures++;
|
|
@@ -1707,14 +2033,15 @@ export class Sync {
|
|
|
1707
2033
|
return "it is no longer added (its synced data is dropped)";
|
|
1708
2034
|
const gap = this.unpublished.get(source);
|
|
1709
2035
|
if (gap !== undefined)
|
|
1710
|
-
return gap
|
|
2036
|
+
return gapLine(gap);
|
|
1711
2037
|
const st = this.states.get(source);
|
|
1712
2038
|
if (this.copiedAs.has(source) && this.copiedAs.get(source) !== columnsKey(t.columns))
|
|
1713
2039
|
return "its columns changed: it is read again whole, then caught up by one poll";
|
|
1714
2040
|
if (st?.phase === "copied" && !this.snapshots.has(source))
|
|
1715
2041
|
return "a numeric value outgrew its synced DECIMAL column: it is read again whole, wider, then caught up by one poll";
|
|
1716
|
-
|
|
1717
|
-
|
|
2042
|
+
const r = this.retired.get(source);
|
|
2043
|
+
if (st?.phase === "copied" && r !== undefined)
|
|
2044
|
+
return `it is ${r.short}: it is read again whole, then caught up by one poll`;
|
|
1718
2045
|
// what follows the copy: a catch-up poll, then the stream or the polls; copied again whole, nothing (`keptOf`'s recopy)
|
|
1719
2046
|
const joins = this.mode === "recopy" ? "it is kept current once synced" : "it joins once synced and caught up by one poll";
|
|
1720
2047
|
if (st?.phase === "copying" || this.rebuildingNow?.source === source && this.rebuildingNow.kind === "copy")
|
|
@@ -1790,7 +2117,7 @@ export class Sync {
|
|
|
1790
2117
|
this.slotWal = { wal: slotWalOf(await readSlot(pg, slot), slot, Date.now()), at: Date.now() };
|
|
1791
2118
|
}
|
|
1792
2119
|
catch (e) {
|
|
1793
|
-
this.slotWal = { wal: { error: `the slot could not be read: ${
|
|
2120
|
+
this.slotWal = { wal: { error: `the slot could not be read: ${this.errorText(e)}` }, at: Date.now() };
|
|
1794
2121
|
}
|
|
1795
2122
|
}
|
|
1796
2123
|
}
|
|
@@ -1800,7 +2127,7 @@ export class Sync {
|
|
|
1800
2127
|
}
|
|
1801
2128
|
catch (e) {
|
|
1802
2129
|
// answered as the status's (and the log line's) own words: the position could not be read, and why
|
|
1803
|
-
const m =
|
|
2130
|
+
const m = this.errorText(e);
|
|
1804
2131
|
this.dbPosition = { error: m, at: Date.now() };
|
|
1805
2132
|
if (slotDue)
|
|
1806
2133
|
this.slotWal = { wal: { error: `the database could not be reached: ${m}` }, at: Date.now() };
|
|
@@ -1967,6 +2294,9 @@ export class Sync {
|
|
|
1967
2294
|
for (const [name, changes] of batch) {
|
|
1968
2295
|
if (!this.tables.has(name))
|
|
1969
2296
|
continue;
|
|
2297
|
+
// committed: a generated table over it is behind from here (nothing of this is in the transaction above)
|
|
2298
|
+
if (changes.truncated || changes.keys.size > 0)
|
|
2299
|
+
this.generated.changed(name);
|
|
1970
2300
|
for (const into of [this.appliedCounts, this.sinceLine]) {
|
|
1971
2301
|
const c = into.get(name) ?? noCounts();
|
|
1972
2302
|
addCounts(c, changes.counts);
|
|
@@ -2149,18 +2479,44 @@ export class Sync {
|
|
|
2149
2479
|
await this.store.writer.run(loadPostgresSql(x));
|
|
2150
2480
|
}
|
|
2151
2481
|
catch (e) {
|
|
2152
|
-
throw new Error(extensionFailure(x, e
|
|
2482
|
+
throw new Error(extensionFailure(x, this.errorText(e)));
|
|
2153
2483
|
}
|
|
2154
2484
|
this.loaded = true;
|
|
2155
2485
|
}
|
|
2156
|
-
|
|
2486
|
+
const w = this.store.writer;
|
|
2487
|
+
// the engine's own words are never repeated here: its statement held the password (`sourceSecretSql`)
|
|
2488
|
+
try {
|
|
2489
|
+
await w.run(sourceSecretSql(this.source));
|
|
2490
|
+
}
|
|
2491
|
+
// ITS OWN WORDS ARE NEVER REPEATED: the statement held the password, and an error may quote its statement — its
|
|
2492
|
+
// code alone is said (src/lib/secret-mask.ts errorCode)
|
|
2493
|
+
catch (e) {
|
|
2494
|
+
throw new Error(`the database's credentials could not be set up for the sync (Mason bug: report it, with this code; the error's own words are not shown, as its statement held the password): ${errorCode(e) ?? "no code"}`);
|
|
2495
|
+
}
|
|
2496
|
+
// the engine's failure to connect, in Mason's words (`errorText`) where it enters
|
|
2497
|
+
try {
|
|
2498
|
+
await w.run(attachSqlOf(this.source));
|
|
2499
|
+
}
|
|
2500
|
+
catch (e) {
|
|
2501
|
+
throw new Error(this.errorText(e));
|
|
2502
|
+
}
|
|
2503
|
+
finally {
|
|
2504
|
+
// the attachment keeps what it merged; the secret goes at once, whatever the ATTACH did. One left behind is
|
|
2505
|
+
// the instance's, in memory, unreadable (duckdb_secrets() redacts it) and replaced at the next attach: said
|
|
2506
|
+
try {
|
|
2507
|
+
await w.run(`DROP TEMPORARY SECRET IF EXISTS ${SOURCE_SECRET}`);
|
|
2508
|
+
}
|
|
2509
|
+
catch (e) {
|
|
2510
|
+
this.log(`the database's credentials could not be removed from memory once it was attached (they stay there, never on disk, until the next attach replaces them): ${this.errorText(e)}`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2157
2513
|
this.attached = true;
|
|
2158
2514
|
}
|
|
2159
2515
|
this.sourceUsers++;
|
|
2160
2516
|
}
|
|
2161
2517
|
/**
|
|
2162
2518
|
* THE SOURCE IS ATTACHED ONLY WHILE A PASS OR A REBUILD READS IT. An attached database is every connection's — a wire
|
|
2163
|
-
* session's too, and the rebuilds' — and its path
|
|
2519
|
+
* session's too, and the rebuilds' — and its path (`duckdb_databases()`) is `attachPath`'s, which holds no credential.
|
|
2164
2520
|
* A session runs only planned SQL (src/duck/plan.ts PlannedSql), which cannot name it; detached when its last user
|
|
2165
2521
|
* ends (on the writer's turn, so never under a transaction reading it), it is not there to name either.
|
|
2166
2522
|
*/
|
|
@@ -2174,7 +2530,7 @@ export class Sync {
|
|
|
2174
2530
|
}
|
|
2175
2531
|
// the pass's work stands; still attached, the next pass detaches it again (and attach() sees it is attached)
|
|
2176
2532
|
catch (e) {
|
|
2177
|
-
this.log(`the source could not be detached after the pass (tried again after the next): ${
|
|
2533
|
+
this.log(`the source could not be detached after the pass (tried again after the next): ${this.errorText(e)}`);
|
|
2178
2534
|
}
|
|
2179
2535
|
}
|
|
2180
2536
|
/** one pass: every removed table dropped, every added table needing it queued for a copy (step 0: whole, again) or
|
|
@@ -2229,9 +2585,9 @@ export class Sync {
|
|
|
2229
2585
|
catch (e) {
|
|
2230
2586
|
// the way out on the table's own line: a failure that names none (a slot the database will not make, a role that
|
|
2231
2587
|
// may not replicate) says the line that keeps the tables polled meanwhile — never a bare SQLSTATE's text
|
|
2232
|
-
const said =
|
|
2588
|
+
const said = this.errorText(e);
|
|
2233
2589
|
const names = this.replicatedTables().map((t) => t.source);
|
|
2234
|
-
const m = `replication: ${said}${/sync: poll/.test(
|
|
2590
|
+
const m = `replication: ${said}${/sync: poll/.test(messageOf(e)) ? "" : ` — ${names.join(", ")} ${names.length === 1 ? "is" : "are"} NOT kept current until it is fixed: ` +
|
|
2235
2591
|
`give ${names.length === 1 ? "its entry" : "their entries"} sync: poll <interval> (poll 5m) in the sources file to poll ${names.length === 1 ? "it" : "them"} instead`}`;
|
|
2236
2592
|
this.event({ kind: "stream_failed", table: null, message: m });
|
|
2237
2593
|
this.wholeFailure.prepare = m;
|
|
@@ -2254,7 +2610,7 @@ export class Sync {
|
|
|
2254
2610
|
await this.retireReplication();
|
|
2255
2611
|
}
|
|
2256
2612
|
catch (e) {
|
|
2257
|
-
replicationFailed = new Error(`no table's sync is replication now, but replication slot ${this.replication.slot} could not be dropped (it holds the database's WAL; tried again at the next pass): ${
|
|
2613
|
+
replicationFailed = new Error(`no table's sync is replication now, but replication slot ${this.replication.slot} could not be dropped (it holds the database's WAL; tried again at the next pass): ${this.errorText(e)}`);
|
|
2258
2614
|
}
|
|
2259
2615
|
}
|
|
2260
2616
|
if (this.tables.size === 0) {
|
|
@@ -2267,7 +2623,7 @@ export class Sync {
|
|
|
2267
2623
|
await this.onWriter(() => this.attach());
|
|
2268
2624
|
}
|
|
2269
2625
|
catch (e) {
|
|
2270
|
-
const m =
|
|
2626
|
+
const m = this.errorText(e);
|
|
2271
2627
|
this.event({ kind: "source_unreachable", table: null, message: `could not attach the database: ${m}` });
|
|
2272
2628
|
this.wholeFailure.attach = m;
|
|
2273
2629
|
// a table copied before keeps serving its copy (and its place in the catalog): the source being away is its error,
|
|
@@ -2350,7 +2706,7 @@ export class Sync {
|
|
|
2350
2706
|
this.due.set(t.source, Date.now() + (s.mode === "poll" ? s.seconds * 1000 : this.intervalMs));
|
|
2351
2707
|
// the table serves its copy as it was, and says why it is not current (mason status reads the ledger); the other
|
|
2352
2708
|
// tables are polled as ever
|
|
2353
|
-
const m = `the poll failed, and is tried again at the next one (the synced data serves as it was): ${
|
|
2709
|
+
const m = `the poll failed, and is tried again at the next one (the synced data serves as it was): ${this.errorText(e)}`;
|
|
2354
2710
|
this.log(`${t.source}: ${m}`);
|
|
2355
2711
|
this.event({ kind: "poll_failed", table: t.source, message: m });
|
|
2356
2712
|
await this.onWriter(() => this.record({ ...this.states.get(t.source), error: m }));
|
|
@@ -2380,9 +2736,16 @@ export class Sync {
|
|
|
2380
2736
|
if (next === undefined)
|
|
2381
2737
|
return;
|
|
2382
2738
|
this.queued.delete(next[0]);
|
|
2739
|
+
// a generated table's run reads no source: nothing is attached and no table is marked as rebuilt
|
|
2740
|
+
const kind = next[1].kind;
|
|
2741
|
+
if (kind === "generated") {
|
|
2742
|
+
await this.generated.run(next[0]).catch((e) => { if (!(e instanceof Stopping))
|
|
2743
|
+
throw e; });
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2383
2746
|
const t = this.tables.get(next[0]);
|
|
2384
2747
|
// a catch-up that found a numeric wider than the copy: copied again, next
|
|
2385
|
-
if (t !== undefined && await this.rebuild(t,
|
|
2748
|
+
if (t !== undefined && await this.rebuild(t, kind) === "copy again")
|
|
2386
2749
|
this.enqueue(t.source, { kind: "copy" });
|
|
2387
2750
|
// still needing a copy or a catch-up: a pass now, or when its back-off ends
|
|
2388
2751
|
else
|
|
@@ -2392,7 +2755,7 @@ export class Sync {
|
|
|
2392
2755
|
this.kick();
|
|
2393
2756
|
}
|
|
2394
2757
|
})().catch((e) => {
|
|
2395
|
-
this.log(`a rebuild failed outside its own handling (Mason bug: report it): ${e
|
|
2758
|
+
this.log(`a rebuild failed outside its own handling (Mason bug: report it): ${this.errorText(e, { stack: true })}`);
|
|
2396
2759
|
}).finally(() => {
|
|
2397
2760
|
this.rebuilding = null;
|
|
2398
2761
|
// what was queued behind a rebuild that threw is still queued
|
|
@@ -2404,9 +2767,9 @@ export class Sync {
|
|
|
2404
2767
|
* ONE REBUILD, beside the stream and the polls: the table marked as rebuilt on the writer's turn (from then on no poll
|
|
2405
2768
|
* starts on it), then its copy on the rebuilds' connection, then — a table left behind by it — its catch-up.
|
|
2406
2769
|
*/
|
|
2407
|
-
async rebuild(t,
|
|
2770
|
+
async rebuild(t, kind) {
|
|
2408
2771
|
await this.onWriter(async () => {
|
|
2409
|
-
this.rebuildingNow = { source: t.source, kind
|
|
2772
|
+
this.rebuildingNow = { source: t.source, kind };
|
|
2410
2773
|
this.refreshStreamed();
|
|
2411
2774
|
});
|
|
2412
2775
|
let attached = false;
|
|
@@ -2417,14 +2780,14 @@ export class Sync {
|
|
|
2417
2780
|
}
|
|
2418
2781
|
catch (e) {
|
|
2419
2782
|
// the next pass says the same of every table, and queues it again
|
|
2420
|
-
const m = `could not attach the database: ${
|
|
2421
|
-
this.log(`${
|
|
2422
|
-
this.event({ kind: "source_unreachable", table: t.source, message: `its ${
|
|
2783
|
+
const m = `could not attach the database: ${this.errorText(e)}`;
|
|
2784
|
+
this.log(`${kind} of ${t.source} waits: ${m}`);
|
|
2785
|
+
this.event({ kind: "source_unreachable", table: t.source, message: `its ${kind} waits: ${m}` });
|
|
2423
2786
|
const st = this.states.get(t.source);
|
|
2424
2787
|
await this.onWriter(() => this.record({ ...st, phase: st.phase === "copied" ? "copied" : "failed", error: m }));
|
|
2425
2788
|
return null;
|
|
2426
2789
|
}
|
|
2427
|
-
if (
|
|
2790
|
+
if (kind === "copy")
|
|
2428
2791
|
await this.copy(t);
|
|
2429
2792
|
// the whole read is over, whatever comes next: what the table is said to be doing from here (`notStreamedReason`,
|
|
2430
2793
|
// `refuseRebuilding`, `state`'s `rebuilding`)
|
|
@@ -2470,7 +2833,7 @@ export class Sync {
|
|
|
2470
2833
|
// mason status — and is caught up again then: what was held is read again by that poll (still behind, nothing lost)
|
|
2471
2834
|
const failed = (what, e) => {
|
|
2472
2835
|
const when = this.backOff(t.source, Date.now() - started);
|
|
2473
|
-
const m = `${what} (the synced data serves as it was; it is caught up by one poll again after ${when}): ${
|
|
2836
|
+
const m = `${what} (the synced data serves as it was; it is caught up by one poll again after ${when}): ${this.errorText(e)}`;
|
|
2474
2837
|
this.log(`${t.source}: ${m}`);
|
|
2475
2838
|
this.event({ kind: "catch_up_failed", table: t.source, message: m });
|
|
2476
2839
|
return this.onWriter(() => this.record({ ...this.states.get(t.source), error: m }));
|
|
@@ -2509,10 +2872,10 @@ export class Sync {
|
|
|
2509
2872
|
await this.applyChanges(new Map([[t.source, held]]), "held");
|
|
2510
2873
|
}
|
|
2511
2874
|
catch (e) {
|
|
2512
|
-
const m =
|
|
2875
|
+
const m = this.errorText(e);
|
|
2513
2876
|
this.refreshStreamed();
|
|
2514
2877
|
// a numeric wider than its copy: copied again (`needsCopy`), and caught up after that copy
|
|
2515
|
-
if (
|
|
2878
|
+
if (widerIn(e, t.source)) {
|
|
2516
2879
|
this.snapshots.delete(t.source);
|
|
2517
2880
|
this.log(`${t.source}: the changes the stream held during its catch-up could not be applied: it is read again whole, wider (${m})`);
|
|
2518
2881
|
return "copy again";
|
|
@@ -2582,6 +2945,8 @@ export class Sync {
|
|
|
2582
2945
|
await w.run("ROLLBACK");
|
|
2583
2946
|
throw e;
|
|
2584
2947
|
}
|
|
2948
|
+
// committed: a new copy, so a generated table over it is behind from here
|
|
2949
|
+
this.generated.changed(t.source);
|
|
2585
2950
|
// the copy's view of its visible columns, made again (its own transaction: DuckDB writes one database a transaction)
|
|
2586
2951
|
await this.kept.swapped(t.source);
|
|
2587
2952
|
if (o.snapshot !== null) {
|
|
@@ -2717,13 +3082,13 @@ export class Sync {
|
|
|
2717
3082
|
await w.run("ROLLBACK");
|
|
2718
3083
|
}
|
|
2719
3084
|
catch (r) {
|
|
2720
|
-
this.log(`the poll's ROLLBACK failed too: ${
|
|
3085
|
+
this.log(`the poll's ROLLBACK failed too: ${this.errorText(r)}`);
|
|
2721
3086
|
}
|
|
2722
3087
|
// a stage made in the rolled-back transaction went with it
|
|
2723
3088
|
pieced.length = 0;
|
|
2724
3089
|
// a numeric wider than its copy: that table is copied again (wider) at the next pass, the others polled
|
|
2725
3090
|
// matched against the tables' own names (a name may hold a space)
|
|
2726
|
-
const wider = e instanceof Error ? tables.find((t) => e
|
|
3091
|
+
const wider = e instanceof Error ? tables.find((t) => widerIn(e, t.source))?.source : undefined;
|
|
2727
3092
|
if (wider !== undefined && this.snapshots.delete(wider)) {
|
|
2728
3093
|
this.log(`poll: ${wider} has a numeric value wider than its synced DECIMAL column: it is read again whole at the next pass`);
|
|
2729
3094
|
return [];
|
|
@@ -2774,6 +3139,10 @@ export class Sync {
|
|
|
2774
3139
|
for (const { stage } of pieced)
|
|
2775
3140
|
await w.run(`DROP TABLE IF EXISTS ${stage}`);
|
|
2776
3141
|
}
|
|
3142
|
+
// committed: a generated table over a table that changed is behind from here
|
|
3143
|
+
for (const f of found)
|
|
3144
|
+
if (f.changed > 0 || f.deleted > 0)
|
|
3145
|
+
this.generated.changed(f.table);
|
|
2777
3146
|
return found;
|
|
2778
3147
|
}
|
|
2779
3148
|
/**
|
|
@@ -2840,7 +3209,8 @@ export class Sync {
|
|
|
2840
3209
|
// caught up from the copy's snapshot
|
|
2841
3210
|
const beside = this.replicated(t) ? "beside the stream, which goes on keeping the other tables current: it is streamed once caught up by one poll from the snapshot it was read at"
|
|
2842
3211
|
: this.mode !== "recopy" ? "beside the other tables' polls: once read, it is caught up by one poll from the snapshot it was read at" : "beside the other tables";
|
|
2843
|
-
const
|
|
3212
|
+
const r = this.retired.get(t.source);
|
|
3213
|
+
const again = r !== undefined ? `, again (it is ${r.layout})` : "";
|
|
2844
3214
|
this.log(`syncing ${t.source}${again}: ${t.rows === null ? "rows unknown" : `about ${t.rows} row(s)`}, ${t.bytes} source bytes, ${beside}`);
|
|
2845
3215
|
}
|
|
2846
3216
|
let read;
|
|
@@ -2851,7 +3221,7 @@ export class Sync {
|
|
|
2851
3221
|
if (!(e instanceof StartOver))
|
|
2852
3222
|
throw e;
|
|
2853
3223
|
// once: a second reason to start over is the copy's failure
|
|
2854
|
-
this.log(`starting the sync of ${t.source} over, from its first page: ${e
|
|
3224
|
+
this.log(`starting the sync of ${t.source} over, from its first page: ${this.errorText(e)}`);
|
|
2855
3225
|
kept = await this.forgetCopy(t, side);
|
|
2856
3226
|
read = await this.readChunks(t, side, null, (p) => { kept = p; });
|
|
2857
3227
|
}
|
|
@@ -2897,7 +3267,7 @@ export class Sync {
|
|
|
2897
3267
|
k = await this.forgetCopy(t, side);
|
|
2898
3268
|
}
|
|
2899
3269
|
catch (f) {
|
|
2900
|
-
this.log(`the sync of ${t.source} could not drop what it began (its next attempt continues it): ${
|
|
3270
|
+
this.log(`the sync of ${t.source} could not drop what it began (its next attempt continues it): ${this.errorText(f)}`);
|
|
2901
3271
|
}
|
|
2902
3272
|
}
|
|
2903
3273
|
const through = k === null ? null : readThrough(k.done);
|
|
@@ -2910,7 +3280,7 @@ export class Sync {
|
|
|
2910
3280
|
await this.record({ ...was, phase: "waiting", error: null }, undefined, w);
|
|
2911
3281
|
return;
|
|
2912
3282
|
}
|
|
2913
|
-
const m =
|
|
3283
|
+
const m = this.errorText(e);
|
|
2914
3284
|
// tried again after a wait that grows with each failure in a row: a copy failing for good must not read the
|
|
2915
3285
|
// source at every pass (a 20-minute read of a production replica every 95 minutes) — and then
|
|
2916
3286
|
// continued where it was, never read again from its first page
|
|
@@ -2934,7 +3304,7 @@ export class Sync {
|
|
|
2934
3304
|
return { path: dir, freeBytes: this.diskFree(dir), projectedBytes };
|
|
2935
3305
|
}
|
|
2936
3306
|
catch (e) {
|
|
2937
|
-
return { path: dir, freeBytes: null, projectedBytes, error: `the free space where the synced data is could not be read: ${
|
|
3307
|
+
return { path: dir, freeBytes: null, projectedBytes, error: `the free space where the synced data is could not be read: ${this.errorText(e)}` };
|
|
2938
3308
|
}
|
|
2939
3309
|
}
|
|
2940
3310
|
/** The DuckDB file's bytes with its write-ahead log's (what a copy adds lands in the log until a checkpoint). */
|
|
@@ -2994,6 +3364,11 @@ export class Sync {
|
|
|
2994
3364
|
const named = t.columns.map((c) => ident(c.name)).join(", ");
|
|
2995
3365
|
const started = Date.now();
|
|
2996
3366
|
const lanes = [this.rebuilder];
|
|
3367
|
+
// ITS VALUES OUT OF LINE, from the source's catalog (`chunkCeiling`): read once an attempt, before any transaction
|
|
3368
|
+
// of the copy (a begun copy's first read fixes its snapshot); sizing only, so a resumed copy reads it again
|
|
3369
|
+
const outOfLine = await this.interruptible(this.rebuilder, () => this.outOfLineBytes(pgFrom));
|
|
3370
|
+
const limit = this.store.memoryLimitBytes;
|
|
3371
|
+
const ceilingOf = (pages) => chunkCeiling(pages, t.rows, outOfLine, chunkBudgetBytes(limit, this.copyParallel), this.copyChunkRows);
|
|
2997
3372
|
if (recopy)
|
|
2998
3373
|
await w.run("BEGIN TRANSACTION");
|
|
2999
3374
|
try {
|
|
@@ -3002,7 +3377,7 @@ export class Sync {
|
|
|
3002
3377
|
if (!recopy)
|
|
3003
3378
|
await w.run("BEGIN TRANSACTION");
|
|
3004
3379
|
try {
|
|
3005
|
-
begun = await this.interruptible(this.rebuilder, () => this.beginCopy(t, side, pgFrom));
|
|
3380
|
+
begun = await this.interruptible(this.rebuilder, () => this.beginCopy(t, side, pgFrom, ceilingOf, outOfLine));
|
|
3006
3381
|
if (!recopy) {
|
|
3007
3382
|
await this.writeBegun(t, begun);
|
|
3008
3383
|
await w.run("COMMIT");
|
|
@@ -3023,7 +3398,7 @@ export class Sync {
|
|
|
3023
3398
|
// first chunks are a tenth of it, to measure, and every later one is what a reader reads in copyChunkSeconds at
|
|
3024
3399
|
// the rate measured since — cut from what no committed chunk covers (`copyGaps`, `takeChunk`), so a copy resumed
|
|
3025
3400
|
// reads exactly the ranges it did not commit, whatever size its chunks were
|
|
3026
|
-
const ceiling =
|
|
3401
|
+
const ceiling = ceilingOf(p.pages);
|
|
3027
3402
|
const gaps = copyGaps(p.done);
|
|
3028
3403
|
let rate = null;
|
|
3029
3404
|
// the source's page size, for the bytes a second its readers read (pg_relation_size is in bytes; pages are its unit)
|
|
@@ -3056,6 +3431,10 @@ export class Sync {
|
|
|
3056
3431
|
if (l.inFlight)
|
|
3057
3432
|
l.conn.interrupt();
|
|
3058
3433
|
};
|
|
3434
|
+
// OUT OF MEMORY IN A CHUNK (53200, `pgErrorOf`; never 53400, the spill's ceiling, which is disk): its range is given
|
|
3435
|
+
// back (`giveBack`) and the copy reads on with ONE reader (`sole`), each chunk at most half the one that ran out
|
|
3436
|
+
// (`memoryPages`, which only shrinks in an attempt); a one-page chunk read alone that runs out fails the copy
|
|
3437
|
+
let sole = null, memoryPages = Infinity;
|
|
3059
3438
|
// the chunks since the last minute's line, and how long they took
|
|
3060
3439
|
let lineAt = Date.now(), chunks = 0, chunkMs = 0;
|
|
3061
3440
|
const reader = async (lane, k) => {
|
|
@@ -3063,8 +3442,10 @@ export class Sync {
|
|
|
3063
3442
|
for (;;) {
|
|
3064
3443
|
if (failure !== null)
|
|
3065
3444
|
return;
|
|
3445
|
+
if (sole !== null && sole !== k)
|
|
3446
|
+
return;
|
|
3066
3447
|
const sizedBy = rate;
|
|
3067
|
-
const range = takeChunk(gaps, p.pages, nextChunkPages(ceiling, sizedBy, this.copyChunkSeconds));
|
|
3448
|
+
const range = takeChunk(gaps, p.pages, Math.min(memoryPages, nextChunkPages(ceiling, sizedBy, this.copyChunkSeconds)));
|
|
3068
3449
|
if (range === undefined)
|
|
3069
3450
|
return;
|
|
3070
3451
|
const { first, end } = range;
|
|
@@ -3072,6 +3453,8 @@ export class Sync {
|
|
|
3072
3453
|
const chunkSize = Math.max(1, (end ?? p.pages) - first);
|
|
3073
3454
|
const slot = { since: Date.now(), pages: chunkSize };
|
|
3074
3455
|
live.reading.push(slot);
|
|
3456
|
+
// read with no other chunk in flight, by the one reader the copy reads with
|
|
3457
|
+
const alone = live.reading.length === 1 && (sole === k || readers === 1);
|
|
3075
3458
|
live.untaken = gapPages(gaps, p.pages);
|
|
3076
3459
|
live.chunkPages = nextChunkPages(ceiling, rate, this.copyChunkSeconds);
|
|
3077
3460
|
const told = { source: t.source, first, end, reader: k, pages: chunkSize, ceiling, sizedBy };
|
|
@@ -3133,13 +3516,47 @@ export class Sync {
|
|
|
3133
3516
|
const at = live.reading.indexOf(slot);
|
|
3134
3517
|
if (at >= 0)
|
|
3135
3518
|
live.reading.splice(at, 1);
|
|
3136
|
-
|
|
3519
|
+
// rolled back, so its connection reads on: a ROLLBACK out of memory itself (measured, with other readers'
|
|
3520
|
+
// chunks in flight) is tried again while the others' memory is given back, for about two seconds
|
|
3521
|
+
let rolledBack = !inTx;
|
|
3522
|
+
for (let attempt = 0; inTx && !rolledBack; attempt++) {
|
|
3137
3523
|
try {
|
|
3138
3524
|
await c.run("ROLLBACK");
|
|
3525
|
+
rolledBack = true;
|
|
3139
3526
|
}
|
|
3140
3527
|
catch (r) {
|
|
3141
|
-
|
|
3528
|
+
const said = this.errorText(r);
|
|
3529
|
+
// the engine ended the transaction itself (measured: "cannot rollback - no transaction is active" after a
|
|
3530
|
+
// COMMIT, or a read, out of memory): nothing of the chunk is left to roll back
|
|
3531
|
+
if (endedTransaction(r)) {
|
|
3532
|
+
rolledBack = true;
|
|
3533
|
+
break;
|
|
3534
|
+
}
|
|
3535
|
+
if (attempt < 20 && pgErrorOf(r, "").fields.code === "53200") {
|
|
3536
|
+
await new Promise((ok) => setTimeout(ok, 100));
|
|
3537
|
+
continue;
|
|
3538
|
+
}
|
|
3539
|
+
this.log(`a chunk of ${t.source}'s sync could not be rolled back after it failed (its connection is closed with the sync): ${said}`);
|
|
3540
|
+
break;
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
if (failure === null && !recopy && rolledBack && pgErrorOf(e, "").fields.code === "53200") {
|
|
3544
|
+
giveBack(gaps, range);
|
|
3545
|
+
live.untaken = gapPages(gaps, p.pages);
|
|
3546
|
+
if (chunkSize <= 1 && alone) {
|
|
3547
|
+
const rpp = live.rowsPerPage;
|
|
3548
|
+
fail(new Error(tooLargeText(t.source, `one page of its table${rpp === null ? "" : ` (about ${Math.max(1, Math.round(rpp)).toLocaleString("en-US")} row(s))`}, read alone, ran out of memory`, limit)));
|
|
3549
|
+
return;
|
|
3142
3550
|
}
|
|
3551
|
+
const was = { sole, memoryPages };
|
|
3552
|
+
sole ??= k;
|
|
3553
|
+
memoryPages = Math.max(1, Math.min(memoryPages, Math.floor(chunkSize / 2)));
|
|
3554
|
+
live.readers = 1;
|
|
3555
|
+
live.chunkPages = Math.min(memoryPages, live.chunkPages);
|
|
3556
|
+
if (was.sole === null || was.memoryPages !== memoryPages) {
|
|
3557
|
+
this.log(`a chunk of ${chunkSize} page(s) of ${t.source} ran out of memory: reading on one chunk at a time, ${memoryPages} page(s) each`);
|
|
3558
|
+
}
|
|
3559
|
+
continue;
|
|
3143
3560
|
}
|
|
3144
3561
|
fail(e);
|
|
3145
3562
|
return;
|
|
@@ -3169,12 +3586,17 @@ export class Sync {
|
|
|
3169
3586
|
l.conn.interrupt(); }, 50);
|
|
3170
3587
|
try {
|
|
3171
3588
|
await Promise.all(lanes.map((lane, k) => reader(lane, k)));
|
|
3589
|
+
// a range given back once the other readers had finished: the one reader left reads it
|
|
3590
|
+
while (failure === null && sole !== null && gaps.length > 0)
|
|
3591
|
+
await reader(lanes[sole], sole);
|
|
3172
3592
|
}
|
|
3173
3593
|
finally {
|
|
3174
3594
|
clearInterval(again);
|
|
3175
3595
|
}
|
|
3176
3596
|
if (failure !== null)
|
|
3177
3597
|
throw failure.error;
|
|
3598
|
+
if (gaps.length > 0)
|
|
3599
|
+
throw new Error(`the sync of ${t.source} ended with pages ${JSON.stringify(gaps)} unread (Mason bug: report it, with mason logs)`);
|
|
3178
3600
|
if (recopy)
|
|
3179
3601
|
await w.run("COMMIT");
|
|
3180
3602
|
return { rows: p.rows, seconds: (Date.now() - started) / 1000, progress: p };
|
|
@@ -3197,25 +3619,40 @@ export class Sync {
|
|
|
3197
3619
|
* extension's REPEATABLE READ snapshot; its xmin is the copy's watermark, as the change reader records one before its
|
|
3198
3620
|
* copy), the exact numerics' widths, the side table (NO KEY: no PRIMARY KEY or UNIQUE, so no index), and the heap's
|
|
3199
3621
|
* pages now (`pg_relation_size`, exact where `relpages` is an estimate) */
|
|
3200
|
-
async beginCopy(t, side, pgFrom) {
|
|
3622
|
+
async beginCopy(t, side, pgFrom, ceilingOf, outOfLine) {
|
|
3201
3623
|
const w = this.rebuilder.conn;
|
|
3202
3624
|
const snapshot = this.mode !== "recopy" ? (await w.runAndReadAll(`SELECT * FROM ${pq(SNAPSHOT_SQL)}`)).getRowsJS()[0][0] : null;
|
|
3203
3625
|
const described = await w.runAndReadAll(`DESCRIBE SELECT ${t.columns.map((c) => ident(c.name)).join(", ")} FROM ${SRC}.${ident(t.schema)}.${ident(t.table)}`);
|
|
3204
3626
|
const scanned = new Map(described.getRowsJS().map((r) => [String(r[0]), String(r[1])]));
|
|
3627
|
+
// a domain's column as its base type reads: the extension's scan of the table does not know a domain
|
|
3628
|
+
const domains = t.columns.filter((c) => c.domain === true);
|
|
3629
|
+
if (domains.length > 0) {
|
|
3630
|
+
const read = await w.runAndReadAll(`DESCRIBE SELECT * FROM ${pq(`SELECT ${domains.map((c) => pgColumnSql(c, new Map(), new Set())).join(", ")} FROM ${pgFrom} LIMIT 0`)}`);
|
|
3631
|
+
for (const r of read.getRowsJS())
|
|
3632
|
+
scanned.set(String(r[0]), String(r[1]));
|
|
3633
|
+
}
|
|
3205
3634
|
// the numerics the extension would read as doubles: their widths, in the copy's snapshot (NUMERIC_TYPE)
|
|
3206
3635
|
const exact = await this.exactNumerics(t, scanned, pgFrom);
|
|
3207
3636
|
const defs = t.columns.map((c) => `${ident(c.name)} ${exact.has(c.name) ? `DECIMAL(38,${exact.get(c.name)})` : storedType(c, scanned.get(c.name) ?? "VARCHAR")}`);
|
|
3208
3637
|
await w.run(`CREATE SCHEMA IF NOT EXISTS ${ident(t.schema)}`);
|
|
3209
3638
|
await w.run(`CREATE OR REPLACE TABLE ${side} (${defs.join(", ")})`);
|
|
3210
3639
|
const pages = Number((await w.runAndReadAll(`SELECT * FROM ${pq(`SELECT (pg_relation_size(${pgLiteral(pgFrom)}::regclass) / current_setting('block_size')::int)::bigint`)}`)).getRowsJS()[0][0]);
|
|
3211
|
-
const ceiling =
|
|
3640
|
+
const ceiling = ceilingOf(pages);
|
|
3641
|
+
const byBytes = ceiling < chunkPages(pages, t.rows, this.copyChunkRows) && outOfLine !== null
|
|
3642
|
+
? ` for its ${humanBytes(outOfLine)} of values out of line in ${humanBytes(this.store.memoryLimitBytes)} of memory` : "";
|
|
3212
3643
|
const firstSize = nextChunkPages(ceiling, null, this.copyChunkSeconds);
|
|
3213
3644
|
const readers = this.mode === "recopy" ? 1 : Math.min(this.copyParallel, Math.max(1, Math.ceil(pages / firstSize)));
|
|
3214
3645
|
this.log(`syncing ${t.source}${snapshot === null ? "" : ` as of the source's snapshot ${snapshot}: what commits after it is caught up by one poll once read`}; ` +
|
|
3215
|
-
`${pages} page(s) in chunks sized to ~${this.copyChunkSeconds} s of reading each at the rate measured here (MASON_COPY_CHUNK_SECONDS; at most ${ceiling} page(s)), ` +
|
|
3646
|
+
`${pages} page(s) in chunks sized to ~${this.copyChunkSeconds} s of reading each at the rate measured here (MASON_COPY_CHUNK_SECONDS; at most ${ceiling} page(s)${byBytes}), ` +
|
|
3216
3647
|
`the first of ${firstSize} page(s) to measure it, ${readers === 1 ? "one at a time" : `${readers} at a time (MASON_COPY_PARALLEL), each a backend on the database`}`);
|
|
3217
3648
|
return { snapshot, columns: columnsKey(t.columns), exact, filenode: null, pages, done: [], rows: 0 };
|
|
3218
3649
|
}
|
|
3650
|
+
/** THE SOURCE TABLE'S VALUES OUT OF LINE, in bytes: its TOAST table's (`pg_table_size`, no index), 0 with none; the
|
|
3651
|
+
* one reading of it (`chunkCeiling`) */
|
|
3652
|
+
async outOfLineBytes(pgFrom) {
|
|
3653
|
+
const r = await this.rebuilder.conn.runAndReadAll(`SELECT * FROM ${pq(`SELECT COALESCE((SELECT pg_table_size(reltoastrelid) FROM pg_class WHERE oid = ${pgLiteral(pgFrom)}::regclass AND reltoastrelid <> 0), 0)::bigint`)}`);
|
|
3654
|
+
return Number(r.getRowsJS()[0][0]);
|
|
3655
|
+
}
|
|
3219
3656
|
/** what a copy was begun with, in its beginning's transaction (`CopyProgress`) */
|
|
3220
3657
|
async writeBegun(t, p) {
|
|
3221
3658
|
await this.rebuilder.conn.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.copy_progress VALUES (${literal(t.source)}, ${p.snapshot === null ? "NULL" : literal(p.snapshot)}, ` +
|
|
@@ -3298,9 +3735,52 @@ export class Sync {
|
|
|
3298
3735
|
for (const [source] of r.getRowsJS()) {
|
|
3299
3736
|
if (this.states.get(source)?.phase !== "copied")
|
|
3300
3737
|
continue;
|
|
3301
|
-
this.retired.
|
|
3738
|
+
this.retired.set(source, { layout: RETIRED_LAYOUT, short: "in an older Mason's layout, key order with an index" });
|
|
3302
3739
|
this.log(`${source}: it is ${RETIRED_LAYOUT} — its rows are sorted on disk, and only the source has them in its own order, so it is read again whole; its synced data serves as it was until then`);
|
|
3303
3740
|
}
|
|
3741
|
+
await this.findHeldLayout();
|
|
3742
|
+
}
|
|
3743
|
+
/**
|
|
3744
|
+
* THE COPIES AN OLDER MASON MADE BEFORE A TYPE WAS HELD AS ITS TEXT (`HELD_LAYOUT`), read at `open` from what each copy
|
|
3745
|
+
* physically is, never from its record alone: a record written before `typeKind` was read says neither that a column
|
|
3746
|
+
* is an enum nor how a held one was stored. Each copied column is held to what `storedType` makes of it now: an ENUM is
|
|
3747
|
+
* an enum (its record completed, so its key is the one a fresh read makes, and it is not read again for nothing); a
|
|
3748
|
+
* column held as its text that is not a VARCHAR (a `point` as the extension's STRUCT) marks the table, said by name and
|
|
3749
|
+
* copied again through the normal path (`needsCopy`), serving as it was until its swap.
|
|
3750
|
+
*/
|
|
3751
|
+
async findHeldLayout() {
|
|
3752
|
+
const r = await this.store.writer.runAndReadAll(`SELECT schema_name || '.' || table_name, column_name, data_type FROM duckdb_columns() ` +
|
|
3753
|
+
`WHERE database_name = current_database() AND schema_name <> ${literal(MASON_SCHEMA)}`);
|
|
3754
|
+
const physical = new Map();
|
|
3755
|
+
for (const [source, column, type] of r.getRowsJS()) {
|
|
3756
|
+
const m = physical.get(source) ?? new Map();
|
|
3757
|
+
m.set(column, type);
|
|
3758
|
+
physical.set(source, m);
|
|
3759
|
+
}
|
|
3760
|
+
for (const [source, columns] of this.copiedColumns) {
|
|
3761
|
+
const types = physical.get(source);
|
|
3762
|
+
if (this.states.get(source)?.phase !== "copied" || types === undefined || this.retired.has(source))
|
|
3763
|
+
continue;
|
|
3764
|
+
const mis = [];
|
|
3765
|
+
const completed = columns.map((c) => {
|
|
3766
|
+
const type = types.get(c.name);
|
|
3767
|
+
if (type === undefined)
|
|
3768
|
+
return c;
|
|
3769
|
+
if (c.typeKind === undefined && type.startsWith("ENUM("))
|
|
3770
|
+
return { ...c, typeKind: "e" };
|
|
3771
|
+
const hold = holdOf(c.type, c.typeKind);
|
|
3772
|
+
const want = hold === "text" ? "VARCHAR" : hold === "text[]" ? "VARCHAR[]" : null;
|
|
3773
|
+
if (want !== null && type !== want)
|
|
3774
|
+
mis.push(`${c.name} (${c.type})`);
|
|
3775
|
+
return c;
|
|
3776
|
+
});
|
|
3777
|
+
this.copiedColumns.set(source, completed);
|
|
3778
|
+
this.copiedAs.set(source, columnsKey(completed));
|
|
3779
|
+
if (mis.length === 0)
|
|
3780
|
+
continue;
|
|
3781
|
+
this.retired.set(source, { layout: HELD_LAYOUT, short: `in an older Mason's layout, ${mis.join(", ")} not held as the source's text` });
|
|
3782
|
+
this.log(`${source}: it is ${HELD_LAYOUT} (${mis.join(", ")}) — it is read again whole; its synced data serves as it was until then`);
|
|
3783
|
+
}
|
|
3304
3784
|
}
|
|
3305
3785
|
/** what a copy of the retired layout left beside the file (in that layout a copy was staged in a file of its own,
|
|
3306
3786
|
* `<file>.stage-<table>.db`, and partitioned into parquet parts beside it, `….parts`): removed, said */
|
|
@@ -3371,7 +3851,7 @@ export class Sync {
|
|
|
3371
3851
|
await c.run(sql);
|
|
3372
3852
|
}
|
|
3373
3853
|
catch (e) {
|
|
3374
|
-
this.log(`recording ${s.source}'s sync state failed: ${
|
|
3854
|
+
this.log(`recording ${s.source}'s sync state failed: ${this.errorText(e)}`);
|
|
3375
3855
|
}
|
|
3376
3856
|
}
|
|
3377
3857
|
/** the catalog rebuilt (`schemaBuilt`), after any rebuild already under way */
|
|
@@ -3409,11 +3889,24 @@ export class Sync {
|
|
|
3409
3889
|
...(uniqueKeys.length ? { uniqueKeys } : {}),
|
|
3410
3890
|
...(t.rowTypeOid !== undefined && columns.length === cols.length ? { rowTypeOid: t.rowTypeOid } : {}) });
|
|
3411
3891
|
}
|
|
3412
|
-
|
|
3413
|
-
|
|
3892
|
+
// ONE SOURCE OF AN ENUM'S LABELS, the synced table's ENUM: a generated table stores the ENUM it was built with, which
|
|
3893
|
+
// a value added since (ALTER TYPE … ADD VALUE, re-copied) is not in, so its column declares the type by the synced
|
|
3894
|
+
// labels; its own only where no synced column has the type
|
|
3895
|
+
const synced = new Map(tables.flatMap((t) => t.columns.flatMap((x) => x.enumLabels !== undefined && x.spelled !== undefined ? [[x.spelled.replace(/\[\]$/, ""), x.enumLabels]] : [])));
|
|
3896
|
+
// each generated table served, as a copy of its output's types: no key, no joins
|
|
3897
|
+
for (const g of this.generated.served()) {
|
|
3898
|
+
const types = await c.prepare(`SELECT * FROM ${ident(g.schema)}.${ident(g.table)} LIMIT 0`);
|
|
3899
|
+
const duckTypes = new Map(Array.from({ length: types.columnCount }, (_, i) => [types.columnName(i), types.columnType(i)]));
|
|
3900
|
+
types.destroySync();
|
|
3901
|
+
const columns = g.columns.filter((x) => duckTypes.has(x.name)).map((x) => pgColumn({ name: x.name, type: x.type, notNull: false, comment: null }, duckTypes.get(x.name), undefined))
|
|
3902
|
+
.map((x) => { const labels = x.enumLabels !== undefined && x.spelled !== undefined ? synced.get(x.spelled.replace(/\[\]$/, "")) : undefined; return labels === undefined ? x : { ...x, enumLabels: labels }; });
|
|
3903
|
+
tables.push({ name: g.table, schema: g.schema, primaryKey: [], columns, joins: [], ...(g.comment !== null ? { description: g.comment } : {}) });
|
|
3904
|
+
}
|
|
3905
|
+
const views = this.views, collations = this.collations;
|
|
3906
|
+
const key = JSON.stringify([tables, views, collations]);
|
|
3414
3907
|
if (key !== this.schemaKey) {
|
|
3415
3908
|
this.schemaKey = key;
|
|
3416
|
-
this.currentSchema = { database: this.currentSchema.database, tables, ...(views.length > 0 ? { views } : {}) };
|
|
3909
|
+
this.currentSchema = { database: this.currentSchema.database, tables, ...(views.length > 0 ? { views } : {}), ...(collations.length > 0 ? { collations } : {}) };
|
|
3417
3910
|
}
|
|
3418
3911
|
}
|
|
3419
3912
|
}
|
|
@@ -3432,33 +3925,14 @@ async function enumLists(c, table) {
|
|
|
3432
3925
|
function pgColumnSql(c, exact, lists) {
|
|
3433
3926
|
if (exact.has(c.name))
|
|
3434
3927
|
return `${pgIdent(c.name)}::text AS ${pgIdent(c.name)}`;
|
|
3435
|
-
|
|
3928
|
+
const hold = holdOf(c.type, c.typeKind);
|
|
3929
|
+
if (KEPT_ARRAYS.has(c.type) || lists.has(c.name) || hold === "text[]")
|
|
3436
3930
|
return `${pgIdent(c.name)}::text[] AS ${pgIdent(c.name)}`;
|
|
3437
|
-
|
|
3438
|
-
}
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
const array = spelled.endsWith("[]");
|
|
3442
|
-
const base = spelled.replace(/\[\]$/, "").replace(/\(.*\)/, "").trim();
|
|
3443
|
-
const oid = {
|
|
3444
|
-
smallint: 21, integer: 23, bigint: 20, real: 700, "double precision": 701, numeric: 1700, text: 25, "character varying": 25, character: 25,
|
|
3445
|
-
boolean: 16, uuid: 2950, date: 1082, "time without time zone": 1083, "timestamp without time zone": 1114, "timestamp with time zone": 1184,
|
|
3446
|
-
interval: 1186, json: 3802, jsonb: 3802, bytea: 17,
|
|
3447
|
-
// held as DuckDB text, declared as themselves: the catalog Postgres resolves their operators (citext's
|
|
3448
|
-
// case-insensitive ones) and the wire sends their OID (src/duck/types.ts)
|
|
3449
|
-
inet: 25, citext: 25,
|
|
3450
|
-
};
|
|
3451
|
-
const o = oid[base];
|
|
3452
|
-
if (o === undefined)
|
|
3453
|
-
return null;
|
|
3454
|
-
return array ? -o : o;
|
|
3931
|
+
if (hold === "text")
|
|
3932
|
+
return `${pgIdent(c.name)}::text AS ${pgIdent(c.name)}`;
|
|
3933
|
+
// a domain's value as its base type, which the extension reads
|
|
3934
|
+
return c.domain === true ? `${pgIdent(c.name)}::${c.type} AS ${pgIdent(c.name)}` : pgIdent(c.name);
|
|
3455
3935
|
}
|
|
3456
|
-
/** the Postgres spelling of the wire's OID: what the catalog says when the source's spelling would disagree with the wire */
|
|
3457
|
-
const SPELLING_OF_OID = {
|
|
3458
|
-
21: "smallint", 23: "integer", 20: "bigint", 700: "real", 701: "double precision", 1700: "numeric", 25: "text", 16: "boolean", 2950: "uuid",
|
|
3459
|
-
1082: "date", 1083: "time without time zone", 1114: "timestamp without time zone", 1184: "timestamp with time zone", 1186: "interval", 3802: "jsonb", 17: "bytea",
|
|
3460
|
-
};
|
|
3461
|
-
const ELEMENT_OF_ARRAY = { 1000: 16, 1001: 17, 1016: 20, 1005: 21, 1007: 23, 1009: 25, 1021: 700, 1022: 701, 1182: 1082, 1183: 1083, 1115: 1114, 1185: 1184, 1187: 1186, 1231: 1700, 2951: 2950, 3807: 3802 };
|
|
3462
3936
|
const API_TYPE = { 21: "int2", 23: "int4", 20: "int8", 700: "float4", 701: "float8", 1700: "numeric", 25: "text", 16: "bool", 2950: "uuid", 1082: "date", 1184: "timestamptz", 3802: "jsonb" };
|
|
3463
3937
|
/** an ENUM's labels, of the column or of its list's element; null for any other type */
|
|
3464
3938
|
function enumLabelsOf(t) {
|
|
@@ -3466,28 +3940,30 @@ function enumLabelsOf(t) {
|
|
|
3466
3940
|
return own instanceof DuckDBEnumType ? own.values : null;
|
|
3467
3941
|
}
|
|
3468
3942
|
/**
|
|
3469
|
-
* A copied column in the catalog: the
|
|
3470
|
-
*
|
|
3471
|
-
*
|
|
3943
|
+
* A copied column in the catalog: ALWAYS the source's type, as the source spells it (`numeric(12,2)`, `character
|
|
3944
|
+
* varying(255)`, `time with time zone`, `point`), so a tool sees the source's own types whatever the copy holds
|
|
3945
|
+
* (src/duck/held-types.ts) — but for the one the copy computes as another: a `numeric` past DuckDB's DECIMAL, read as
|
|
3946
|
+
* DOUBLE, is `double precision` on the catalog as on the wire (the limits `numeric-over-38-digits`, `numeric-array`).
|
|
3472
3947
|
*/
|
|
3473
3948
|
function pgColumn(c, duck, now) {
|
|
3474
3949
|
const wire = wireColumn(c.name, duck).oid;
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
// an array DuckDB holds as VARCHAR[] whose elements the wire writes as the source's type (src/duck/types.ts: a json's
|
|
3478
|
-
// text, a jsonb's canonical text, an inet's output, a citext's bytes): declared as the source spells it
|
|
3479
|
-
// an enum (or an array of one) DuckDB holds as its ENUM, in the declared order: declared as the source's type, with
|
|
3480
|
-
// its labels, so Postgres orders and compares it by them
|
|
3950
|
+
// an enum (or an array of one) DuckDB holds as its ENUM, in the declared order: declared with its labels, so Postgres
|
|
3951
|
+
// orders and compares it by them
|
|
3481
3952
|
const labels = enumLabelsOf(duck);
|
|
3482
|
-
const
|
|
3483
|
-
|
|
3953
|
+
const double = c.type.startsWith("numeric") ? wire === 701 ? "double precision" : wire === 1022 ? "double precision[]" : null : null;
|
|
3954
|
+
const spelled = double ?? c.type;
|
|
3955
|
+
const kind = (now ?? c).typeKind ?? c.typeKind;
|
|
3956
|
+
// a type a database made, held as its text: the catalog declares it as a type with nothing over it
|
|
3957
|
+
const made = c.typeOid !== undefined && labels === null && holdOf(c.type, kind) !== "same" ? kind === "c" ? "row" : true : undefined;
|
|
3484
3958
|
// `type` is the ClickHouse path's; the catalog reads `spelled`
|
|
3485
3959
|
const type = API_TYPE[wire] ?? "text";
|
|
3486
3960
|
const facts = now ?? c;
|
|
3487
|
-
return { name: c.name, type, spelled, ...(c.comment ? { description: c.comment } : {}), ...(labels !== null ? { enumLabels: labels } : {}),
|
|
3961
|
+
return { name: c.name, type, spelled, ...(c.comment ? { description: c.comment } : {}), ...(labels !== null ? { enumLabels: labels } : {}), ...(made !== undefined ? { made } : {}),
|
|
3488
3962
|
...(facts.notNull ? { notNull: true } : {}), ...(facts.default !== undefined ? { default: facts.default } : {}),
|
|
3489
3963
|
...(facts.identity !== undefined ? { identity: facts.identity } : {}), ...(facts.sequence !== undefined ? { sequence: facts.sequence } : {}),
|
|
3490
|
-
...(
|
|
3964
|
+
...(facts.collation !== undefined ? { collation: facts.collation } : {}),
|
|
3965
|
+
// a number, whatever spelling the column's record kept (an oid is a string in Postgres's JSON, and a copy's columns are JSON)
|
|
3966
|
+
...(c.typeOid !== undefined && double === null ? { typeOid: Number(c.typeOid), ...(c.typeArrayOid !== undefined ? { typeArrayOid: Number(c.typeArrayOid) } : {}) } : {}) };
|
|
3491
3967
|
}
|
|
3492
3968
|
/** a span as the stream's line says it: `2.1 s`, `3 min`, `2 h` */
|
|
3493
3969
|
function secondsText(ms) {
|