@mason-data/cli 0.0.0-stage → 0.7.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/LICENSE +202 -0
- package/README.md +268 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +477 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1771 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +588 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +198 -0
- package/dist/src/cli/command.js +173 -0
- package/dist/src/cli/commands.js +1277 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +669 -0
- package/dist/src/cli/doctor.js +260 -0
- package/dist/src/cli/guides.js +158 -0
- package/dist/src/cli/init.js +366 -0
- package/dist/src/cli/machine.js +162 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +347 -0
- package/dist/src/cli/project.js +129 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +220 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +311 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +301 -0
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +564 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +268 -0
- package/dist/src/duck/executor.js +567 -0
- package/dist/src/duck/explain.js +238 -0
- package/dist/src/duck/functions.js +819 -0
- package/dist/src/duck/gaps.js +94 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +188 -0
- package/dist/src/duck/kept-route.js +299 -0
- package/dist/src/duck/kept.js +964 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +378 -0
- package/dist/src/duck/plan.js +259 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +5101 -0
- package/dist/src/duck/store.js +907 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2929 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +1001 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +237 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +76 -0
- package/dist/src/web/app.js +491 -0
- package/dist/src/web/index.html +44 -0
- package/package.json +22 -4
|
@@ -0,0 +1,2929 @@
|
|
|
1
|
+
import { MASON_SCHEMA } from "./store.js";
|
|
2
|
+
import { KeptJsonPaths } from "./kept.js";
|
|
3
|
+
import { DuckDBEnumType, DuckDBListType } from "@duckdb/node-api";
|
|
4
|
+
import { wireColumn } from "./types.js";
|
|
5
|
+
import { nextPollDelayMs, SNAPSHOT_SQL, snapshotXmin, xmin8Sql, xminBeforeHolds, xminBeforeSql } from "../cdc/poll.js";
|
|
6
|
+
import { pgIdent } from "../cdc/pg-wire.js";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { PgConnection, formatLsn, parseLsn, pgLiteral } from "../cdc/pg-wire.js";
|
|
10
|
+
import { createSlot, currentLsn, dropSlot, readSlot } from "../cdc/source.js";
|
|
11
|
+
import { addCounts, countsText, duckValue, foldInto, noChanges, noCounts, ReplicationStream } from "./stream.js";
|
|
12
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
13
|
+
import { namedPublicationVerdict, readNamedPublication } from "./publication.js";
|
|
14
|
+
export function syncModeOf(env) {
|
|
15
|
+
const v = env.MASON_DUCK_SYNC || "poll";
|
|
16
|
+
if (v !== "poll" && v !== "recopy")
|
|
17
|
+
throw new Error(`MASON_DUCK_SYNC must be poll or recopy, got ${JSON.stringify(v)}`);
|
|
18
|
+
return v;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* THE POLL'S ROW LIMIT: in poll mode a table the source estimates at more than this many rows
|
|
22
|
+
* (`AddedTable.rows`, the catalog's estimate; unknown counts as small) is copied and served, but the passes do not poll
|
|
23
|
+
* it. A poll scans its table whole (no index covers xmin) and counts it, and when the counts differ reads every key over
|
|
24
|
+
* the wire: on a test server a 22 M-row table cost the production replica 12 s of scan and 2 s of count at every poll.
|
|
25
|
+
* `mason sync <table>` polls one now (`Sync.pollNow`); `mason start --replication` keeps every table current, whatever
|
|
26
|
+
* its size.
|
|
27
|
+
*/
|
|
28
|
+
export const POLL_ROW_LIMIT = 1_000_000;
|
|
29
|
+
/** why the passes do not keep a table over the limit current (`CopyState.unsynced`) */
|
|
30
|
+
export const OVER_POLL_LIMIT = `over the ${POLL_ROW_LIMIT.toLocaleString("en-US")}-row sync limit`;
|
|
31
|
+
const overPollLimit = (t) => t.rows !== null && t.rows > POLL_ROW_LIMIT;
|
|
32
|
+
/** a poll asked for that cannot run (`Sync.pollNow`), its reason in the asker's words: `code` is the CLI's failure code */
|
|
33
|
+
export class PollRefused extends Error {
|
|
34
|
+
code;
|
|
35
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
36
|
+
}
|
|
37
|
+
/** a statement interrupted, or never started, because the sync is stopping (`Sync.stop`): a stop, never a failure */
|
|
38
|
+
export class Stopping extends Error {
|
|
39
|
+
constructor() { super("Mason is stopping"); }
|
|
40
|
+
}
|
|
41
|
+
export const ident = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
42
|
+
export const literal = (s) => `'${s.replaceAll("'", "''")}'`;
|
|
43
|
+
export const SRC = "_mason_source";
|
|
44
|
+
/** a poll's changed rows, staged per table (`poll`): a TEMP table, the writer's own */
|
|
45
|
+
const POLL_STAGE = "_mason_polled_";
|
|
46
|
+
/**
|
|
47
|
+
* THE MOST CHANGED ROWS ONE POLL WRITES TO A TABLE IN ONE STATEMENT AND TRANSACTION (`poll`): past it, the rows go in
|
|
48
|
+
* committed pieces of this many, a piece a range of the stage's rowids (a pushed-down filter of its scan). Set
|
|
49
|
+
* when the copy had a primary key: at 64 MB on 1 M uuid keys one INSERT OR REPLACE of 600,000 changed rows
|
|
50
|
+
* ran out of memory (its index). Without one (heap order, no index, measured on DuckDB 2.0.0) a piece is a
|
|
51
|
+
* DELETE of its keys joined against the table's key column, then an INSERT (`writeByKey`): a scan of the key column per
|
|
52
|
+
* piece, the price of having no index — 50,000 keys into 1 M uuid rows at 64 MB took 3 ms to delete and 8 ms to
|
|
53
|
+
* insert, into 10 M at 2 GB 15 ms and 11 ms. The pieces stay: they bound what one transaction holds.
|
|
54
|
+
*/
|
|
55
|
+
const POLL_PIECE_ROWS = 50_000;
|
|
56
|
+
/** the table a copy builds beside the one that serves (`copy`), swapped in when whole */
|
|
57
|
+
const SIDE_SUFFIX = "__mason_copy";
|
|
58
|
+
/** the keys and rows one write by key deletes and inserts (`writeByKey`), TEMP tables of the writer's */
|
|
59
|
+
const WRITE_KEYS = "__mason_write_keys";
|
|
60
|
+
const WRITE_ROWS = "__mason_write_rows";
|
|
61
|
+
/** an unchanged-TOAST update's rows, the key and the columns it carries (`updateCarried`), a TEMP table of the
|
|
62
|
+
* writer's, and the column numbering its rows */
|
|
63
|
+
const WRITE_PARTIAL = "__mason_write_partial";
|
|
64
|
+
const WRITE_ROW = "__mason_row";
|
|
65
|
+
/**
|
|
66
|
+
* A WRITE BY KEY WITHOUT AN INDEX: the rows of `target` whose key is one of `relation`'s (a table or a parenthesised
|
|
67
|
+
* SELECT, its key columns named `named`, in the key's order) deleted by ONE join against the table's key column — a
|
|
68
|
+
* scan of that column, the price of having no index (`POLL_PIECE_ROWS` has the measurements; a bigserial key prunes,
|
|
69
|
+
* heap order putting its zonemaps in order). Answers how many went. A table with no key deletes nothing.
|
|
70
|
+
*/
|
|
71
|
+
async function deleteKeys(w, t, target, relation, named) {
|
|
72
|
+
if (t.key.length === 0)
|
|
73
|
+
return 0;
|
|
74
|
+
const on = t.key.map((k, i) => `t.${ident(k)} = k.${ident(named[i])}`).join(" AND ");
|
|
75
|
+
return Number((await w.run(`DELETE FROM ${target} AS t USING ${relation} AS k WHERE ${on}`)).rowsChanged);
|
|
76
|
+
}
|
|
77
|
+
/** a poll's staged rows (`where`: a range of them, or all) written by key into the table: their keys deleted, then the
|
|
78
|
+
* rows inserted. The stage's columns are named by position (`c0`, `c1`, …), the table's order */
|
|
79
|
+
export async function writeStaged(w, t, target, stage, where, kept) {
|
|
80
|
+
const named = t.key.map((k) => `c${t.columns.findIndex((c) => c.name === k)}`);
|
|
81
|
+
await deleteKeys(w, t, target, `(SELECT ${named.map(ident).join(", ")} FROM ${stage}${where})`, named);
|
|
82
|
+
// the table's columns named, and its stored JSON paths computed from the staged JSON (src/duck/kept.ts)
|
|
83
|
+
const stored = kept.stored(t.source, (c) => ident(`c${t.columns.findIndex((x) => x.name === c)}`));
|
|
84
|
+
await w.run(`INSERT INTO ${target} (${[...t.columns.map((c) => ident(c.name)), ...stored.names.map(ident)].join(", ")}) ` +
|
|
85
|
+
`SELECT ${[...t.columns.map((_, i) => ident(`c${i}`)), ...stored.values].join(", ")} FROM ${stage}${where}`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A COPY IN AN EARLIER LAYOUT (before copies were kept in heap order with no index): sorted by its primary
|
|
89
|
+
* key, with the key's index. Its rows are physically in key order, and only the source has them in its own: it is
|
|
90
|
+
* never converted in place, but copied again through the normal path (`needsCopy`), serving as it was meanwhile. Found
|
|
91
|
+
* by its constraint (`duckdb_constraints()`), which every copy of that layout carries and no copy of this one does.
|
|
92
|
+
*/
|
|
93
|
+
export const RETIRED_LAYOUT = "stored in key order with an index, by an older Mason: copied again in heap order";
|
|
94
|
+
/**
|
|
95
|
+
* How long a failed copy waits before it is tried again: four times the attempt, at least 5 minutes, doubled for each
|
|
96
|
+
* failure in a row, at most 6 hours (a copy whose sort failed for good read a production replica whole for
|
|
97
|
+
* 20 minutes at every pass). A restart, or a change of the table's columns or key, tries it at once.
|
|
98
|
+
*/
|
|
99
|
+
export function copyRetryDelayMs(failures, attemptMs) {
|
|
100
|
+
return Math.min(6 * 3_600_000, Math.max(300_000, 4 * attemptMs) * 2 ** Math.max(0, failures - 1));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* THE ORDER TABLES ARE COPIED IN (the test server: three small tables — 776, 15 k and 22 M rows — waited the
|
|
104
|
+
* whole 23 minutes of a 196 M-row table's copy before they were current): the smallest first, by the catalog's row
|
|
105
|
+
* estimate (`AddedTable.rows`; unknown last), ties by the source's bytes, then by name — so most tables are current
|
|
106
|
+
* within seconds while a big one copies. A first copy and a copy again (a retired layout, changed columns) alike.
|
|
107
|
+
*/
|
|
108
|
+
export function copyOrder(tables) {
|
|
109
|
+
const rank = (rows) => rows === null || rows < 0 ? Infinity : rows;
|
|
110
|
+
// Infinity - Infinity is NaN, falsy: two unknown estimates fall through to their bytes
|
|
111
|
+
return [...tables].sort((a, b) => rank(a.rows) - rank(b.rows) || a.bytes - b.bytes || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* THE LINE A START (OR AN ADDED TABLE) LOGS ABOUT ITS TABLES: what each one's records say it is, never a promise
|
|
115
|
+
* of a copy that is not coming (the test server: every start said `added <all four tables>: they are copied at the
|
|
116
|
+
* next pass` while all four were copied and streamed from the acknowledged position — read as a long re-copy that never
|
|
117
|
+
* came). ONE line: the tables grouped by state, each group `<n> table(s) <state>: <tables>`, the current ones first,
|
|
118
|
+
* those copied at the next pass last, each with its reason. `kept` is how a current copy is kept so (`keptWords`).
|
|
119
|
+
*/
|
|
120
|
+
export function addedText(tables, kept) {
|
|
121
|
+
const groups = new Map();
|
|
122
|
+
for (const t of tables) {
|
|
123
|
+
const [rank, state] = "copy" in t ? [3, `copied at the next pass (${t.copy})`]
|
|
124
|
+
: "behind" in t ? [2, `already copied, caught up by one poll first (${t.behind})`]
|
|
125
|
+
: "refused" in t ? [4, `not copied or streamed (${t.refused})`]
|
|
126
|
+
: t.unsynced ? [1, `already copied, NOT kept current (${OVER_POLL_LIMIT}: mason sync polls one now, mason start --replication keeps it current)`]
|
|
127
|
+
: [0, `already copied and ${kept}`];
|
|
128
|
+
const g = groups.get(state) ?? { rank, sources: [] };
|
|
129
|
+
g.sources.push(t.source);
|
|
130
|
+
groups.set(state, g);
|
|
131
|
+
}
|
|
132
|
+
return [...groups].sort(([, a], [, b]) => a.rank - b.rank)
|
|
133
|
+
.map(([state, g]) => `${g.sources.length} table${g.sources.length === 1 ? "" : "s"} ${state}: ${g.sources.join(", ")}`).join("; ");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* THE LARGEST CHUNK OF A COPY (`copy`): about this many rows by the catalog's estimate — on the test server a copy read ~140,000
|
|
137
|
+
* rows a second, so 2 M rows is ~15 s, and on this laptop's disposable Postgres 3 M rows read in 2.7 to 5.5 s in two
|
|
138
|
+
* chunks. A chunk costs ~30 ms beyond its rows (its commit, snapshot and file check: 15 chunks of that table read in 2.4
|
|
139
|
+
* to 2.5 s, 2 in 2.0 to 2.1; docs/DESIGN.md "The copy in chunks"). Bounded in pages: at least one, at most
|
|
140
|
+
* `COPY_CHUNK_MAX_PAGES` (1 GiB of 8 kB pages), and that many where the estimate is unknown or the heap is wide. It is
|
|
141
|
+
* the CEILING: the chunks themselves are sized by the time they take here (`nextChunkPages`).
|
|
142
|
+
*/
|
|
143
|
+
export const COPY_CHUNK_ROWS = 2_000_000;
|
|
144
|
+
const COPY_CHUNK_MAX_PAGES = 131_072;
|
|
145
|
+
/** the pages of a chunk of a table of `pages` heap pages and about `rows` rows (`COPY_CHUNK_ROWS`): the ceiling */
|
|
146
|
+
export function chunkPages(pages, rows, targetRows = COPY_CHUNK_ROWS) {
|
|
147
|
+
if (pages <= 0 || rows === null || rows <= 0)
|
|
148
|
+
return COPY_CHUNK_MAX_PAGES;
|
|
149
|
+
return Math.min(COPY_CHUNK_MAX_PAGES, Math.max(1, Math.round(targetRows / (rows / pages))));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* HOW LONG A CHUNK SHOULD TAKE TO READ, in seconds (`MASON_COPY_CHUNK_SECONDS`, 1 to 300, default 12:
|
|
153
|
+
* a laptop copying a 20 M-row table from a database over the internet showed `0 of ~20,126,276 rows` for minutes, then
|
|
154
|
+
* jumped by 27 %). A chunk is the unit that commits — what a stop rolls back and what `mason wait` can count — and the
|
|
155
|
+
* same 2 M rows that take ~6 s on the test server's replica took minutes over a WAN, so a chunk is sized by the rate
|
|
156
|
+
* measured HERE, never by a row estimate alone (`nextChunkPages`).
|
|
157
|
+
*/
|
|
158
|
+
export const COPY_CHUNK_SECONDS = 12;
|
|
159
|
+
const COPY_CHUNK_SECONDS_MIN = 1, COPY_CHUNK_SECONDS_MAX = 300;
|
|
160
|
+
export function copyChunkSecondsOf(env) {
|
|
161
|
+
const v = env.MASON_COPY_CHUNK_SECONDS;
|
|
162
|
+
if (v === undefined || v === "")
|
|
163
|
+
return COPY_CHUNK_SECONDS;
|
|
164
|
+
const n = Number(v);
|
|
165
|
+
if (!/^\d+(\.\d+)?$/.test(v.trim()) || n < COPY_CHUNK_SECONDS_MIN || n > COPY_CHUNK_SECONDS_MAX) {
|
|
166
|
+
throw new Error(`MASON_COPY_CHUNK_SECONDS=${JSON.stringify(v)} is not a number of seconds from ${COPY_CHUNK_SECONDS_MIN} to ${COPY_CHUNK_SECONDS_MAX}: each of a copy's chunks is sized to take ` +
|
|
167
|
+
`about that long to read, and a stop reads at most that much again, so set it lower for a copy that moves more often or higher for fewer, larger chunks, or unset it for ${COPY_CHUNK_SECONDS}`);
|
|
168
|
+
}
|
|
169
|
+
return n;
|
|
170
|
+
}
|
|
171
|
+
/** the smallest chunk, in pages (1 MiB of 8 kB pages), unless the ceiling is smaller: below it a chunk's own round
|
|
172
|
+
* trips (its snapshot, file check, ledger row and commit) are what it measures */
|
|
173
|
+
export const COPY_CHUNK_MIN_PAGES = 128;
|
|
174
|
+
/** how much of the ceiling a chunk is before anything is measured: enough to measure, small enough to move soon */
|
|
175
|
+
const COPY_FIRST_CHUNK_SHARE = 0.1;
|
|
176
|
+
/** the weight of the newest chunk in a copy's rate (`nextRate`), a moving average: 0.7 when it read slower than the rate
|
|
177
|
+
* so far, 0.3 when faster — quick to shrink, slow to grow, because a chunk sized for a rate the link no longer has is
|
|
178
|
+
* minutes nobody sees move, and one too small costs ~30 ms */
|
|
179
|
+
export const COPY_RATE_WEIGHT = { slower: 0.7, faster: 0.3 };
|
|
180
|
+
/** the rate after a chunk of `pages` heap pages and `rows` rows read in `seconds`: the first chunk's own, then an
|
|
181
|
+
* exponentially weighted moving average (`COPY_RATE_WEIGHT`, by whether the chunk read slower or faster in pages) */
|
|
182
|
+
export function nextRate(prev, m) {
|
|
183
|
+
const s = Math.max(0.001, m.seconds), pages = Math.max(1, m.pages);
|
|
184
|
+
const now = { pagesPerSecond: pages / s, rowsPerSecond: m.rows / s, bytesPerSecond: (pages * m.blockSize) / s };
|
|
185
|
+
if (prev === null)
|
|
186
|
+
return { ...now, chunks: 1 };
|
|
187
|
+
const w = now.pagesPerSecond < prev.pagesPerSecond ? COPY_RATE_WEIGHT.slower : COPY_RATE_WEIGHT.faster;
|
|
188
|
+
const mix = (a, b) => (1 - w) * a + w * b;
|
|
189
|
+
return { pagesPerSecond: mix(prev.pagesPerSecond, now.pagesPerSecond), rowsPerSecond: mix(prev.rowsPerSecond, now.rowsPerSecond),
|
|
190
|
+
bytesPerSecond: mix(prev.bytesPerSecond, now.bytesPerSecond), chunks: prev.chunks + 1 };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* THE PAGES OF A COPY'S NEXT CHUNK: before anything is measured a tenth of the ceiling (`chunkPages`), at least
|
|
194
|
+
* `COPY_CHUNK_MIN_PAGES`; then what one reader reads in `seconds` at the measured rate, between those two. Re-sized at
|
|
195
|
+
* every chunk taken, so a copy that slows (a WAN, a busy source) cuts smaller chunks, and one that speeds up larger.
|
|
196
|
+
*/
|
|
197
|
+
export function nextChunkPages(ceiling, rate, seconds) {
|
|
198
|
+
const floor = Math.min(ceiling, COPY_CHUNK_MIN_PAGES);
|
|
199
|
+
if (rate === null)
|
|
200
|
+
return Math.max(floor, Math.round(ceiling * COPY_FIRST_CHUNK_SHARE));
|
|
201
|
+
return Math.min(ceiling, Math.max(floor, Math.round(rate.pagesPerSecond * seconds)));
|
|
202
|
+
}
|
|
203
|
+
/** the most of a chunk's expected rows its reading may count before it commits (`copyProgressNow`) */
|
|
204
|
+
export const READING_SHARE_CAP = 0.9;
|
|
205
|
+
/** What a copy has done and is doing at `now` (ms): the committed chunks, plus the chunks in flight at the measured rate. */
|
|
206
|
+
export function copyProgressNow(l, now) {
|
|
207
|
+
const left = l.untaken <= 0 ? 0 : Math.ceil(l.untaken / Math.max(1, l.chunkPages));
|
|
208
|
+
const oldest = l.reading.reduce((o, r) => o === null || r.since < o ? r.since : o, null);
|
|
209
|
+
const view = { chunksDone: l.chunksDone, chunksTotal: l.chunksDone + l.reading.length + left, reading: l.reading.length,
|
|
210
|
+
readingSeconds: oldest === null ? null : Math.max(0, (now - oldest) / 1000) };
|
|
211
|
+
const share = (pages) => Math.min(0.99, Math.max(0, pages / Math.max(1, l.pages)));
|
|
212
|
+
if (l.rate === null)
|
|
213
|
+
return { ...view, rowsReading: 0, rowsPerSecond: null, bytesPerSecond: null, secondsLeft: null, share: share(l.pagesDone) };
|
|
214
|
+
const rate = l.rate;
|
|
215
|
+
let rowsReading = 0, pagesReading = 0;
|
|
216
|
+
for (const r of l.reading) {
|
|
217
|
+
const s = Math.max(0, (now - r.since) / 1000);
|
|
218
|
+
pagesReading += Math.min(READING_SHARE_CAP * r.pages, rate.pagesPerSecond * s);
|
|
219
|
+
rowsReading += l.rowsPerPage === null ? 0 : Math.min(READING_SHARE_CAP * r.pages * l.rowsPerPage, rate.rowsPerSecond * s);
|
|
220
|
+
}
|
|
221
|
+
// the readers at work while the pages left are read: no more than the chunks left to read
|
|
222
|
+
const active = Math.max(1, Math.min(l.readers, l.reading.length + left));
|
|
223
|
+
const pagesLeft = Math.max(0, l.untaken + l.reading.reduce((n, r) => n + r.pages, 0) - pagesReading);
|
|
224
|
+
return { ...view, rowsReading: Math.floor(rowsReading), rowsPerSecond: rate.rowsPerSecond * active, bytesPerSecond: rate.bytesPerSecond * active,
|
|
225
|
+
secondsLeft: pagesLeft / (rate.pagesPerSecond * active), share: share(l.pagesDone + pagesReading) };
|
|
226
|
+
}
|
|
227
|
+
/** The pages a copy has read: every committed range's, the open-ended one to the heap's end. */
|
|
228
|
+
export function pagesRead(done, pages) {
|
|
229
|
+
return done.reduce((n, d) => n + (d.end ?? pages) - d.first, 0);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* WHAT A COPY'S PAGES LEFT WOULD ADD ON DISK, from what the pages this attempt read added to the file (the
|
|
233
|
+
* test server's copy of a 194 M-row table was at 49 GB after 38% of it, with 75 GB free, and nothing said so until the
|
|
234
|
+
* disk filled). Null until this attempt read 1% of the heap (at least a page), or when the file shrank meanwhile (a
|
|
235
|
+
* checkpoint): too early to say. `readChunks` holds `needed` to what is free where the file is, and fails the copy
|
|
236
|
+
* past it with the way out, its chunks kept, as a failed copy is tried again (tests/duck-copy-disk.test.ts;
|
|
237
|
+
* tests/integration/duck-poll.itest.ts).
|
|
238
|
+
*/
|
|
239
|
+
export function copyDiskProjection(c) {
|
|
240
|
+
const read = pagesRead(c.done, c.pages);
|
|
241
|
+
const since = read - pagesRead(c.doneAtStart, c.pages);
|
|
242
|
+
const added = c.bytesNow - c.bytesAtStart;
|
|
243
|
+
if (since < Math.max(1, c.pages / 100) || added <= 0)
|
|
244
|
+
return null;
|
|
245
|
+
const perPage = added / since;
|
|
246
|
+
const left = Math.max(0, c.pages - read);
|
|
247
|
+
return { needed: perPage * left, perPage, left };
|
|
248
|
+
}
|
|
249
|
+
/** Bytes for a person: `1 B`, `3.2 MiB`, `49.1 GiB`. */
|
|
250
|
+
export function humanBytes(n) {
|
|
251
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
252
|
+
let i = 0, v = n;
|
|
253
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
254
|
+
v /= 1024;
|
|
255
|
+
i++;
|
|
256
|
+
}
|
|
257
|
+
return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* THE CHUNKS A COPY HAS STILL TO READ (`readChunks`): every range of the heap no committed chunk (`done`) covers, in
|
|
261
|
+
* page order, cut into chunks of `size` pages — the gap before the heap's end into chunks of its own (they meet a
|
|
262
|
+
* committed chunk exactly), the rest up to `pages` with its last chunk open-ended. A copy of one reader leaves no gap
|
|
263
|
+
* (its chunks commit in order), one of several may: a chunk committed past one in flight that died.
|
|
264
|
+
*/
|
|
265
|
+
export function chunksToRead(pages, size, done) {
|
|
266
|
+
const gaps = copyGaps(done), out = [];
|
|
267
|
+
for (let r = takeChunk(gaps, pages, size); r !== undefined; r = takeChunk(gaps, pages, size))
|
|
268
|
+
out.push(r);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
/** every range of the heap no committed chunk (`done`) covers, in page order, whole: the last one open-ended */
|
|
272
|
+
export function copyGaps(done) {
|
|
273
|
+
const out = [];
|
|
274
|
+
let cursor = 0;
|
|
275
|
+
for (const d of [...done].sort((a, b) => a.first - b.first)) {
|
|
276
|
+
if (cursor === null)
|
|
277
|
+
break;
|
|
278
|
+
if (d.first > cursor)
|
|
279
|
+
out.push({ first: cursor, end: d.first });
|
|
280
|
+
cursor = d.end === null ? null : Math.max(cursor, d.end);
|
|
281
|
+
}
|
|
282
|
+
if (cursor !== null)
|
|
283
|
+
out.push({ first: cursor, end: null });
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/** THE NEXT CHUNK of `size` pages, cut from the first of `gaps` (which it shortens): a gap that meets a committed chunk
|
|
287
|
+
* is cut up to it exactly, the open-ended one into closed chunks while pages before `pages` are left, then open-ended.
|
|
288
|
+
* Undefined when nothing is left. Chunks of one size are `chunksToRead`'s; `readChunks` takes each at the size the
|
|
289
|
+
* measured rate says (`nextChunkPages`). */
|
|
290
|
+
export function takeChunk(gaps, pages, size) {
|
|
291
|
+
const g = gaps[0];
|
|
292
|
+
if (g === undefined)
|
|
293
|
+
return undefined;
|
|
294
|
+
const n = Math.max(1, size);
|
|
295
|
+
if (g.end !== null) {
|
|
296
|
+
const end = Math.min(g.end, g.first + n);
|
|
297
|
+
if (end >= g.end)
|
|
298
|
+
gaps.shift();
|
|
299
|
+
else
|
|
300
|
+
gaps[0] = { first: end, end: g.end };
|
|
301
|
+
return { first: g.first, end };
|
|
302
|
+
}
|
|
303
|
+
if (g.first + n < pages) {
|
|
304
|
+
gaps[0] = { first: g.first + n, end: null };
|
|
305
|
+
return { first: g.first, end: g.first + n };
|
|
306
|
+
}
|
|
307
|
+
gaps.shift();
|
|
308
|
+
return { first: g.first, end: null };
|
|
309
|
+
}
|
|
310
|
+
/** the pages of `gaps` before the heap's end (`pages`): what no chunk has taken yet */
|
|
311
|
+
export const gapPages = (gaps, pages) => gaps.reduce((n, g) => n + Math.max(0, (g.end ?? pages) - g.first), 0);
|
|
312
|
+
/** the page before which every page of a copy is read (`done`, its committed chunks, from page 0 with no gap), or null
|
|
313
|
+
* when every page is: its swap is next */
|
|
314
|
+
export function readThrough(done) {
|
|
315
|
+
let cursor = 0;
|
|
316
|
+
for (const d of [...done].sort((a, b) => a.first - b.first)) {
|
|
317
|
+
if (d.first > cursor)
|
|
318
|
+
break;
|
|
319
|
+
if (d.end === null)
|
|
320
|
+
return null;
|
|
321
|
+
cursor = Math.max(cursor, d.end);
|
|
322
|
+
}
|
|
323
|
+
return cursor;
|
|
324
|
+
}
|
|
325
|
+
/** the committed chunks past the first gap, said beside `readThrough`'s page: `` when there are none */
|
|
326
|
+
export function pastPages(done) {
|
|
327
|
+
const at = readThrough(done);
|
|
328
|
+
const past = at === null ? 0 : done.filter((d) => d.first > at).length;
|
|
329
|
+
return past === 0 ? "" : ` (and ${past} chunk(s) past it)`;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* HOW MANY OF A COPY'S CHUNKS ARE READ AT ONCE (`MASON_COPY_PARALLEL`, 1 to 8: the load stays on Mason's
|
|
333
|
+
* side, and the customer's Postgres is never overloaded): each reader is a DuckDB connection of its
|
|
334
|
+
* own, so ONE backend on the source. A chunk's time is one backend's throughput — on the test server's read replica one 2 M-row
|
|
335
|
+
* chunk of a table with two wide text columns took 6.1 s, of which the TID range scan was 0.6 s and the rest that
|
|
336
|
+
* backend detoasting, formatting and sending at ~150 MB/s — so one reader took the 22 M-row table 72 s where the
|
|
337
|
+
* extension's attached scan (about ten backends at once) took 28 s. A bounded few is the point between: about the old
|
|
338
|
+
* scan's time while the source carries that many backends, never ten (docs/DESIGN.md "Chunks read N at a time").
|
|
339
|
+
*/
|
|
340
|
+
export const COPY_PARALLEL = 3;
|
|
341
|
+
const COPY_PARALLEL_MAX = 8;
|
|
342
|
+
export function copyParallelOf(env) {
|
|
343
|
+
const v = env.MASON_COPY_PARALLEL;
|
|
344
|
+
if (v === undefined || v === "")
|
|
345
|
+
return COPY_PARALLEL;
|
|
346
|
+
const n = Number(v);
|
|
347
|
+
if (!/^\d+$/.test(v.trim()) || n < 1 || n > COPY_PARALLEL_MAX) {
|
|
348
|
+
throw new Error(`MASON_COPY_PARALLEL=${JSON.stringify(v)} is not a number of readers from 1 to ${COPY_PARALLEL_MAX}: each reads a copy's chunks on a backend of the database's own, ` +
|
|
349
|
+
`so set it to 1 (one at a time, the lightest on the database) up to ${COPY_PARALLEL_MAX}, or unset it for ${COPY_PARALLEL}`);
|
|
350
|
+
}
|
|
351
|
+
return n;
|
|
352
|
+
}
|
|
353
|
+
/** a copy begun before that cannot be continued as it stands (`copy`): started over once, said with this reason */
|
|
354
|
+
class StartOver extends Error {
|
|
355
|
+
}
|
|
356
|
+
/** a time as the log and `mason status` say a next attempt's: `14:05` (UTC) */
|
|
357
|
+
const clock = (ms) => new Date(ms).toISOString().slice(11, 16);
|
|
358
|
+
/** How long a failed replication stream waits before it starts again: the interval, doubled for each failure in a row
|
|
359
|
+
* (an applied batch resets the count), at most 5 minutes — each start decodes the slot's WAL again on the source */
|
|
360
|
+
export function streamRetryDelayMs(intervalMs, failures) {
|
|
361
|
+
return Math.min(300_000, intervalMs * 2 ** Math.max(0, failures - 1));
|
|
362
|
+
}
|
|
363
|
+
/** what Mason's backends on the source are called (pg_stat_activity.application_name): the copy's readers and the polls */
|
|
364
|
+
export const SOURCE_APPLICATION_NAME = "mason-data";
|
|
365
|
+
/** libpq's keyword form of the source, for DuckDB's ATTACH (a value quoted as libpq reads it) */
|
|
366
|
+
export function libpqString(s) {
|
|
367
|
+
const v = (x) => `'${String(x).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
368
|
+
return [`host=${v(s.host)}`, `port=${v(s.port)}`, `dbname=${v(s.database)}`, `user=${v(s.user)}`,
|
|
369
|
+
...(s.password ? [`password=${v(s.password)}`] : []), `sslmode=${v(s.sslmode)}`, `application_name=${v(SOURCE_APPLICATION_NAME)}`].join(" ");
|
|
370
|
+
}
|
|
371
|
+
/** a table's columns as a copy is made with them: their names and source types, in order */
|
|
372
|
+
const columnsKey = (columns) => JSON.stringify(columns.map((c) => [c.name, c.type]));
|
|
373
|
+
/** `postgres_query` over the attached source: a read of Postgres's own SQL in the DuckDB transaction's snapshot */
|
|
374
|
+
const pq = (sql) => `postgres_query(${literal(SRC)}, ${literal(sql)})`;
|
|
375
|
+
/**
|
|
376
|
+
* A NUMERIC THE EXTENSION WOULD READ AS A DOUBLE — one with no precision, or one past DuckDB's 38 digits (a Rails
|
|
377
|
+
* `decimal`, Prisma's `Decimal` numeric(65,30)) — is copied EXACTLY instead: read as its text and stored as
|
|
378
|
+
* DECIMAL(38, s) when every value fits 38 digits (one aggregate over the column, in the copy's own snapshot, says). For
|
|
379
|
+
* an unconstrained column s is the most decimals a value has; for a declared scale, the most its values USE (19.99 in a
|
|
380
|
+
* numeric(65,30) is stored as DECIMAL(38,2): at 30 a sum past 10^8 would overflow), and Postgres's 30 are written back by
|
|
381
|
+
* the wire and the rewrite (src/duck/types.ts `padDecimals`, src/duck/rewrite.ts `displayScale`). One that does not fit
|
|
382
|
+
* stays a double, said in the log. A later value with more digits or decimals than s re-copies the table at its new
|
|
383
|
+
* width (`WIDER`) rather than being rounded. Postgres keeps each value's own scale where an unconstrained column's
|
|
384
|
+
* differ (`1` beside `1.50`); the copy answers them all with s decimals, also said.
|
|
385
|
+
*/
|
|
386
|
+
const NUMERIC_TYPE = /^numeric(?:\((\d+)(?:,\s*(\d+))?\))?$/;
|
|
387
|
+
const WIDER = "mason: a numeric value wider than its copy";
|
|
388
|
+
/** a source numeric the extension reads as a DOUBLE: no precision, or more than DuckDB's 38 digits */
|
|
389
|
+
function readsAsDouble(type) {
|
|
390
|
+
const m = NUMERIC_TYPE.exec(type);
|
|
391
|
+
return m !== null && (m[1] === undefined || Number(m[1]) > 38);
|
|
392
|
+
}
|
|
393
|
+
/** a DuckDB check on a numeric's text that it fits DECIMAL(38, scale): an error naming the table where it does not */
|
|
394
|
+
function fitsDecimal(column, scale, source, significant) {
|
|
395
|
+
const v = ident(column);
|
|
396
|
+
// a declared scale's text has its trailing zeros (19.990000…): only the decimals a value uses must fit
|
|
397
|
+
const decimals = significant ? `rtrim(regexp_extract(${v}, '\\.(\\d+)', 1), '0')` : `regexp_extract(${v}, '\\.(\\d+)', 1)`;
|
|
398
|
+
return `CASE WHEN length(${decimals}) > ${scale} OR length(regexp_extract(${v}, '^-?(\\d+)', 1)) > ${38 - scale} ` +
|
|
399
|
+
`OR ${v} IN ('NaN', 'Infinity', '-Infinity') THEN error(${literal(`${WIDER} [${source}]`)}) ELSE ${v} END AS ${v}`;
|
|
400
|
+
}
|
|
401
|
+
/** a numeric's text that does not fit DECIMAL(38, scale): `fitsDecimal`'s rule, for a value the stream brings */
|
|
402
|
+
function widerThan(text, scale, significant) {
|
|
403
|
+
if (text === "NaN" || text === "Infinity" || text === "-Infinity")
|
|
404
|
+
return true;
|
|
405
|
+
const m = /^-?(\d*)(?:\.(\d+))?$/.exec(text);
|
|
406
|
+
if (m === null)
|
|
407
|
+
return true;
|
|
408
|
+
const decimals = significant ? (m[2] ?? "").replace(/0+$/, "") : m[2] ?? "";
|
|
409
|
+
return decimals.length > scale || m[1].length > 38 - scale;
|
|
410
|
+
}
|
|
411
|
+
/** 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). */
|
|
412
|
+
/** a char(n) column (format_type's `character(n)`): the extension reads its values with the padding trimmed */
|
|
413
|
+
const CHAR_TYPE = /^character\((\d+)\)$/;
|
|
414
|
+
/** a column as it is stored: a char(n) value padded back to n, as Postgres holds it */
|
|
415
|
+
function storedValue(c) {
|
|
416
|
+
const m = CHAR_TYPE.exec(c.type);
|
|
417
|
+
return m ? `rpad(${ident(c.name)}, ${m[1]}, ' ')` : ident(c.name);
|
|
418
|
+
}
|
|
419
|
+
function storedType(c, scanned) {
|
|
420
|
+
return /^jsonb?$/.test(c.type) ? "JSON" : scanned;
|
|
421
|
+
}
|
|
422
|
+
/** the order the queue takes them in: a catch-up is one poll that makes a copied table current */
|
|
423
|
+
const REBUILD_ORDER = ["catch-up", "copy"];
|
|
424
|
+
export class Sync {
|
|
425
|
+
store;
|
|
426
|
+
source;
|
|
427
|
+
log;
|
|
428
|
+
intervalMs;
|
|
429
|
+
mode;
|
|
430
|
+
/** each polled table's watermark: the snapshot of the last poll that stored it */
|
|
431
|
+
snapshots = new Map();
|
|
432
|
+
/** how long the last pass took: the next waits four times that, at least the interval */
|
|
433
|
+
lastPassMs = 0;
|
|
434
|
+
/** each table whose copy failed: the failures in a row, and when it is tried again (`copyRetryDelayMs`) */
|
|
435
|
+
retries = new Map();
|
|
436
|
+
/** each copied table in a layout this Mason no longer keeps (`RETIRED_LAYOUT`), found at `open`: copied again */
|
|
437
|
+
retired = new Set();
|
|
438
|
+
/** the catalog's own connection (`rebuildSchema`): it sees only what a copy or poll has committed, and is never
|
|
439
|
+
* held by a copy's statement, so a table is served the moment its copy commits */
|
|
440
|
+
catalog = null;
|
|
441
|
+
/** the catalog's rebuilds, one at a time */
|
|
442
|
+
schemaBuild = Promise.resolve();
|
|
443
|
+
tables = new Map();
|
|
444
|
+
views = [];
|
|
445
|
+
states = new Map();
|
|
446
|
+
/** each copied table's columns as its copy was made with them (`columnsKey`): a change copies it again */
|
|
447
|
+
copiedAs = new Map();
|
|
448
|
+
/** THE COPY'S OWN COLUMNS, as `_mason.copy_state.columns` records them: what every write to it is shaped by (`asCopied`),
|
|
449
|
+
* never the sources file's columns of the moment nor the publication's */
|
|
450
|
+
copiedColumns = new Map();
|
|
451
|
+
/** the pass a failed rebuild's back-off waits for (`wakeAt`): replication mode runs a pass only when kicked */
|
|
452
|
+
wake = null;
|
|
453
|
+
timer = null;
|
|
454
|
+
running = null;
|
|
455
|
+
attached = false;
|
|
456
|
+
loaded = false;
|
|
457
|
+
stopped = false;
|
|
458
|
+
/** REPLICATION MODE: the publication and slot it reads (`owned`: the publication is Mason's own, made and kept equal
|
|
459
|
+
* to the added tables; else the operator named it, and it is only read — src/duck/publication.ts), the stream while it
|
|
460
|
+
* runs, the last commit DuckDB applied */
|
|
461
|
+
replication;
|
|
462
|
+
stream = null;
|
|
463
|
+
appliedLsn = 0n;
|
|
464
|
+
/** the slot has a row in `_mason.stream_state`: what this copy applied of it is known */
|
|
465
|
+
slotKnown = false;
|
|
466
|
+
/** THE TABLES COPIED BUT NOT YET CAUGHT UP (`_mason.catch_up`, so a run that stops first catches them up at its next
|
|
467
|
+
* start), and why: left out of the polls and the stream until one poll from their watermark (`catchUp`). Each mark is
|
|
468
|
+
* an object of its own, so a catch-up clears only the mark it caught up (a slot made again meanwhile marks anew) */
|
|
469
|
+
behind = new Map();
|
|
470
|
+
/** REPLICATION: the changes the stream decoded of each table being caught up, held until its catch-up poll commits
|
|
471
|
+
* and applied after it (src/duck/stream.ts "why replaying is safe") */
|
|
472
|
+
held = new Map();
|
|
473
|
+
/** REPLICATION: what the stream applies, by source — the tables joined, and those held — read at each change */
|
|
474
|
+
streamed = new Map();
|
|
475
|
+
/** a NAMED publication's gaps: each added table it does not carry, with the refusal (neither copied nor streamed) */
|
|
476
|
+
unpublished = new Map();
|
|
477
|
+
/** a named publication that does not publish TRUNCATE has been said (once a run) */
|
|
478
|
+
truncateSaid = false;
|
|
479
|
+
/** the last pass failed: in replication mode it is tried again after the interval, the stream started by it */
|
|
480
|
+
passFailed = false;
|
|
481
|
+
/** a table copied again because a numeric arrived wider than its copy, this run: bounded (`startStream`) */
|
|
482
|
+
widened = new Map();
|
|
483
|
+
/** each copied table's column types as DuckDB holds them (DESCRIBE), read once per copy */
|
|
484
|
+
duckTypes = new Map();
|
|
485
|
+
/** the stream's failures in a row: each restart waits twice the one before (`streamRetryDelayMs`), until `streamAt`
|
|
486
|
+
* (a pass kicked sooner leaves it stopped) */
|
|
487
|
+
streamFailures = 0;
|
|
488
|
+
streamAt = 0;
|
|
489
|
+
/** when each streamed table's rows were last counted for the ledger */
|
|
490
|
+
countedAt = new Map();
|
|
491
|
+
/** when each table was last copied or polled (`_mason.poll_state.polled_at`), as an unsynced table's state says it */
|
|
492
|
+
polledAt = new Map();
|
|
493
|
+
/** the tables over the poll's row limit already said in the log (said once, when first left out) */
|
|
494
|
+
leftOut = new Set();
|
|
495
|
+
/** the store's writer: the stream's batches, the polls, the source's attachment and every swap, one turn at a time */
|
|
496
|
+
writerLane;
|
|
497
|
+
/** the rebuilds' own connection (`open`): a copy or a catch-up, beside the writer's work */
|
|
498
|
+
rebuilderLane = null;
|
|
499
|
+
/** the writer's turns (`onWriter`): each job runs once every one queued before it has ended */
|
|
500
|
+
writerTurn = Promise.resolve();
|
|
501
|
+
/** the rebuilds waiting, by source; the one running and its loop (`drainRebuilds`) */
|
|
502
|
+
queued = new Map();
|
|
503
|
+
rebuildingNow = null;
|
|
504
|
+
rebuilding = null;
|
|
505
|
+
/** the source attached for this many users (a pass, a rebuild): DETACHed when the last ends (`detach`) */
|
|
506
|
+
sourceUsers = 0;
|
|
507
|
+
currentSchema;
|
|
508
|
+
database;
|
|
509
|
+
/** what the catalog last built from: replaced (a new object) only when it changes */
|
|
510
|
+
schemaKey = "";
|
|
511
|
+
/** REPLICATION, WHERE THE STREAM IS (`streamStatus`, the stream's log): the changes it applied this run by table, and
|
|
512
|
+
* since its last periodic line (`reportStream`, every `streamReportMs` when something moved) */
|
|
513
|
+
appliedCounts = new Map();
|
|
514
|
+
sinceLine = new Map();
|
|
515
|
+
lineAt = Date.now();
|
|
516
|
+
streamReportMs;
|
|
517
|
+
/** about how many rows a copy's largest chunk reads (`chunkPages`, the ceiling): COPY_CHUNK_ROWS, or a test's own */
|
|
518
|
+
copyChunkRows;
|
|
519
|
+
/** how many of a copy's chunks are read at once, each on a connection of its own (`COPY_PARALLEL`) */
|
|
520
|
+
copyParallel;
|
|
521
|
+
/** how long a copy's chunk should take to read, in seconds (`COPY_CHUNK_SECONDS`, `nextChunkPages`) */
|
|
522
|
+
copyChunkSeconds;
|
|
523
|
+
/** each copy whose chunks are being read, as they are (`CopyLive`): `state` renders it at the moment it is asked */
|
|
524
|
+
copyLive = new Map();
|
|
525
|
+
/** a test's: told what each chunk did (`ChunkEvent`), inside the chunk's transaction before it commits ("read") and
|
|
526
|
+
* once it has ("committed") */
|
|
527
|
+
onChunk;
|
|
528
|
+
/** bytes free on the disk the file is on (a test's: a full disk) */
|
|
529
|
+
diskFree;
|
|
530
|
+
/** the copy's readers past the rebuilds' own connection, open while it reads (`readChunks`): what `stop` interrupts too */
|
|
531
|
+
copyLanes = new Set();
|
|
532
|
+
reportTimer = null;
|
|
533
|
+
/** the last batch applied with changes: when, and when its newest transaction committed on the database (ms) */
|
|
534
|
+
lastBatch = null;
|
|
535
|
+
/** the tables the stream applies as they come, as `refreshStreamed` last made them: a table joining or leaving is said */
|
|
536
|
+
joinedNow = new Set();
|
|
537
|
+
/** why a table is about to join, said on its join line (`catchUp`) */
|
|
538
|
+
joinWhy = new Map();
|
|
539
|
+
/** the table being caught up, and what its catch-up is doing */
|
|
540
|
+
catchingUp = null;
|
|
541
|
+
/** the stream's last failure and when it starts again; cleared when it starts */
|
|
542
|
+
streamFailure = null;
|
|
543
|
+
/** the database's position as last read (`databasePosition`), and the read in flight */
|
|
544
|
+
dbPosition = null;
|
|
545
|
+
dbPositionRead = null;
|
|
546
|
+
/** THE STORED JSON PATHS (src/duck/kept.ts): every write below computes them; the plans read them */
|
|
547
|
+
kept;
|
|
548
|
+
constructor(store, source, database, log, intervalMs, mode = "poll", replication = null, options = {}) {
|
|
549
|
+
this.store = store;
|
|
550
|
+
this.source = source;
|
|
551
|
+
this.database = database;
|
|
552
|
+
this.log = log;
|
|
553
|
+
this.intervalMs = intervalMs;
|
|
554
|
+
this.mode = mode;
|
|
555
|
+
this.streamReportMs = options.streamReportMs ?? 60_000;
|
|
556
|
+
this.copyChunkRows = options.copyChunkRows ?? COPY_CHUNK_ROWS;
|
|
557
|
+
this.copyParallel = options.copyParallel ?? COPY_PARALLEL;
|
|
558
|
+
if (!Number.isInteger(this.copyParallel) || this.copyParallel < 1 || this.copyParallel > COPY_PARALLEL_MAX) {
|
|
559
|
+
throw new Error(`a copy reads with 1 to ${COPY_PARALLEL_MAX} readers, not ${this.copyParallel} (Mason bug: report it; copyParallelOf reads MASON_COPY_PARALLEL)`);
|
|
560
|
+
}
|
|
561
|
+
this.copyChunkSeconds = options.copyChunkSeconds ?? COPY_CHUNK_SECONDS;
|
|
562
|
+
if (!(this.copyChunkSeconds > 0))
|
|
563
|
+
throw new Error(`a copy's chunk takes a positive number of seconds, not ${this.copyChunkSeconds} (Mason bug: report it; copyChunkSecondsOf reads MASON_COPY_CHUNK_SECONDS)`);
|
|
564
|
+
this.onChunk = options.onChunk;
|
|
565
|
+
this.diskFree = options.diskFree ?? ((dir) => { const st = fs.statfsSync(dir); return st.bavail * st.bsize; });
|
|
566
|
+
if (mode === "replication" && replication === null)
|
|
567
|
+
throw new Error("replication mode needs its slot and publication (Mason bug: report it)");
|
|
568
|
+
this.replication = replication;
|
|
569
|
+
this.currentSchema = { database, tables: [] };
|
|
570
|
+
this.writerLane = { conn: store.writer, inFlight: false };
|
|
571
|
+
this.kept = new KeptJsonPaths(store, {
|
|
572
|
+
onWriter: (f) => this.onWriter(f),
|
|
573
|
+
// every copy served, by its own columns (a table removed serves until the pass drops it)
|
|
574
|
+
served: () => new Map([...this.states.values()].filter((s) => s.phase === "copied" && this.copiedColumns.has(s.source))
|
|
575
|
+
.map((s) => [s.source, this.copiedColumns.get(s.source).map((c) => ({ name: c.name, type: c.type }))])),
|
|
576
|
+
rebuilding: (source) => this.rebuildingNow?.source === source,
|
|
577
|
+
log: (line) => this.log(line),
|
|
578
|
+
}, { enabled: options.json?.enabled ?? true, ...options.json });
|
|
579
|
+
}
|
|
580
|
+
/** the rebuilds' connection: open from `open` until `stop` */
|
|
581
|
+
get rebuilder() {
|
|
582
|
+
if (this.rebuilderLane === null)
|
|
583
|
+
throw new Error("the sync's rebuild connection is not open (Mason bug: report it)");
|
|
584
|
+
return this.rebuilderLane;
|
|
585
|
+
}
|
|
586
|
+
/** `f` on the writer's next turn: once every job queued before it has ended, so no other job's statement lands inside
|
|
587
|
+
* its transaction. A job never queues another and waits for it (it would wait for itself) */
|
|
588
|
+
onWriter(f) {
|
|
589
|
+
const turn = this.writerTurn.then(f);
|
|
590
|
+
// the next job waits for this one either way: its failure is its caller's
|
|
591
|
+
this.writerTurn = turn.then(() => undefined, () => undefined);
|
|
592
|
+
return turn;
|
|
593
|
+
}
|
|
594
|
+
async open() {
|
|
595
|
+
this.rebuilderLane = { conn: await this.store.instance.connect(), inFlight: false };
|
|
596
|
+
await this.store.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.copy_state (source VARCHAR PRIMARY KEY, phase VARCHAR, "rows" BIGINT, bytes BIGINT, seconds DOUBLE, copied_at TIMESTAMPTZ, error VARCHAR, columns JSON)`);
|
|
597
|
+
// the re-sort's count of rows written out of key order (in the earlier layout): gone with the key order
|
|
598
|
+
for (const gone of ["written", "counted"])
|
|
599
|
+
await this.store.writer.run(`ALTER TABLE ${MASON_SCHEMA}.copy_state DROP COLUMN IF EXISTS ${gone}`);
|
|
600
|
+
await this.store.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.poll_state (source VARCHAR PRIMARY KEY, snapshot VARCHAR, polled_at TIMESTAMPTZ)`);
|
|
601
|
+
// replication mode's record: per slot, the end of the last commit applied, in the same transaction as its rows
|
|
602
|
+
await this.store.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.stream_state (slot VARCHAR PRIMARY KEY, lsn VARCHAR, applied_at TIMESTAMPTZ)`);
|
|
603
|
+
// the tables copied (or under a new slot) and not yet caught up by their one poll (`behind`), written
|
|
604
|
+
// with the swap and removed with the catch-up: a run that stops between catches them up at its next start
|
|
605
|
+
await this.store.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.catch_up (source VARCHAR PRIMARY KEY, why VARCHAR, since TIMESTAMPTZ)`);
|
|
606
|
+
// a copy in progress (`CopyProgress`): what it was begun with, and a row per chunk committed — each in that chunk's
|
|
607
|
+
// transaction, so a copy that dies continues where it was. Removed with the swap. A chunk's row is APPENDED, never
|
|
608
|
+
// an update of one row: several readers commit at once, and DuckDB lets transactions append to one table side by
|
|
609
|
+
// side; the key refuses a chunk committed twice (a Mason bug) at its commit
|
|
610
|
+
await this.store.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.copy_progress (source VARCHAR PRIMARY KEY, snapshot VARCHAR, columns VARCHAR, exact VARCHAR, ` +
|
|
611
|
+
`pages BIGINT, updated_at TIMESTAMPTZ)`);
|
|
612
|
+
await this.store.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.copy_chunks (source VARCHAR, first_page BIGINT, end_page BIGINT, "rows" BIGINT, filenode BIGINT, ` +
|
|
613
|
+
`committed_at TIMESTAMPTZ, PRIMARY KEY (source, first_page))`);
|
|
614
|
+
await this.oneReaderProgress();
|
|
615
|
+
for (const [source, why] of (await this.store.writer.runAndReadAll(`SELECT source, why FROM ${MASON_SCHEMA}.catch_up`)).getRowsJS())
|
|
616
|
+
this.behind.set(source, { why });
|
|
617
|
+
if (this.replication !== null) {
|
|
618
|
+
const r = (await this.store.writer.runAndReadAll(`SELECT lsn FROM ${MASON_SCHEMA}.stream_state WHERE slot = $1`, [this.replication.slot])).getRowsJS();
|
|
619
|
+
this.slotKnown = r.length > 0;
|
|
620
|
+
this.appliedLsn = r.length > 0 ? parseLsn(String(r[0][0])) : 0n;
|
|
621
|
+
}
|
|
622
|
+
for (const [source, snapshot] of (await this.store.writer.runAndReadAll(`SELECT source, snapshot FROM ${MASON_SCHEMA}.poll_state`)).getRowsJS()) {
|
|
623
|
+
this.snapshots.set(source, snapshot);
|
|
624
|
+
}
|
|
625
|
+
await this.readPolledAt();
|
|
626
|
+
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`);
|
|
627
|
+
for (const row of r.getRowsJS()) {
|
|
628
|
+
const [source, phase, rows, bytes, seconds, copiedMs, error, columns] = row;
|
|
629
|
+
// an instant as a run writes it (ISO, UTC), never DuckDB's text of it in the connection's zone
|
|
630
|
+
const copiedAt = copiedMs === null ? null : new Date(Number(copiedMs)).toISOString();
|
|
631
|
+
if (columns !== null) {
|
|
632
|
+
const c = JSON.parse(columns);
|
|
633
|
+
this.copiedAs.set(source, columnsKey(c));
|
|
634
|
+
this.copiedColumns.set(source, c);
|
|
635
|
+
}
|
|
636
|
+
this.states.set(source, { source, phase: phase === "copying" ? "waiting" : phase, rows: rows === null ? null : Number(rows), bytes: bytes === null ? null : Number(bytes), seconds, copiedAt, error });
|
|
637
|
+
}
|
|
638
|
+
await this.findRetired();
|
|
639
|
+
this.removeStageLeftovers();
|
|
640
|
+
// the stored JSON paths, before the catalog serves anything: each served copy's view, what the file holds
|
|
641
|
+
await this.kept.open();
|
|
642
|
+
this.catalog = await this.store.instance.connect();
|
|
643
|
+
await this.rebuildSchema();
|
|
644
|
+
}
|
|
645
|
+
/** the tables the project adds now (its sources file): a new one is copied at the next pass unless its copy is already
|
|
646
|
+
* here and current (said as its records say it, `addedText`), a removed one dropped.
|
|
647
|
+
* What it returns is the catalog rebuilt over them (never rejected: a failure is said in the log) */
|
|
648
|
+
setTables(tables) {
|
|
649
|
+
// (called at mason start and at mason add / remove only: the catalog's keys and constraints are the source's as of
|
|
650
|
+
// then — docs/DESIGN.md "A count of distinct keys")
|
|
651
|
+
const before = this.tables;
|
|
652
|
+
this.tables = new Map(tables.map((t) => [t.source, t]));
|
|
653
|
+
// said as it happens: what is added, each table as its records say it is (`addedText`), and what is no longer
|
|
654
|
+
// (dropped at the next pass)
|
|
655
|
+
const added = tables.filter((t) => !before.has(t.source));
|
|
656
|
+
const removed = [...before.keys()].filter((s) => !this.tables.has(s));
|
|
657
|
+
if (added.length)
|
|
658
|
+
this.log(addedText(added.map((t) => this.addedStatus(t)), this.keptWords()));
|
|
659
|
+
// loud: the copy goes with the table's entry, and comes back only by reading the source again (the test server: a 53 GB
|
|
660
|
+
// copy, 21 minutes of a production replica, dropped by a project file moved aside)
|
|
661
|
+
if (removed.length)
|
|
662
|
+
this.log(`removed ${removed.join(", ")}: no longer added, so ${removed.length === 1 ? "its copy is" : "their copies are"} DROPPED at the next pass — mason add copies ${removed.length === 1 ? "it" : "them"} again from the source, whole`);
|
|
663
|
+
for (const t of tables)
|
|
664
|
+
if (!this.states.has(t.source))
|
|
665
|
+
this.states.set(t.source, { source: t.source, phase: "waiting", rows: null, bytes: t.bytes, seconds: null, copiedAt: null, error: null });
|
|
666
|
+
// a failed copy whose table changed (its columns or key) is tried at once: the change may be its fix
|
|
667
|
+
const shape = (t) => JSON.stringify([columnsKey(t.columns), t.key]);
|
|
668
|
+
for (const t of tables) {
|
|
669
|
+
const b = before.get(t.source);
|
|
670
|
+
if (b !== undefined && shape(b) !== shape(t))
|
|
671
|
+
this.retries.delete(t.source);
|
|
672
|
+
}
|
|
673
|
+
// the tables copied before are served at once, not after this start's first pass (its copies may take hours)
|
|
674
|
+
const built = this.rebuildSchema().catch((e) => this.log(`the catalog could not be rebuilt (it is at the next pass): ${e instanceof Error ? e.message : String(e)}`));
|
|
675
|
+
this.kick();
|
|
676
|
+
return built;
|
|
677
|
+
}
|
|
678
|
+
/** an added table as the sync's records say it is, before the pass that acts on it (`addedText`): `needsCopy`'s reasons
|
|
679
|
+
* first, in `notStreamedReason`'s words, then a catch-up still owed (`behind`; in replication mode every copy is owed
|
|
680
|
+
* one under a slot this copy has no record of, `prepareReplication`), then a table the polls leave out */
|
|
681
|
+
addedStatus(t) {
|
|
682
|
+
const st = this.states.get(t.source);
|
|
683
|
+
const firstLine = (s) => (s ?? "mason logs says why").split("\n")[0];
|
|
684
|
+
// a named publication's gap is said again by the pass, which refuses the table by name until its owner adds it
|
|
685
|
+
if (st?.error?.startsWith("replication: "))
|
|
686
|
+
return { source: t.source, refused: firstLine(st.error.slice("replication: ".length)) };
|
|
687
|
+
const copy = st === undefined ? "never copied"
|
|
688
|
+
: st.phase === "failed" ? `its last copy failed: ${firstLine(st.error)}`
|
|
689
|
+
: st.phase !== "copied" ? "its copy has not finished"
|
|
690
|
+
: this.retired.has(t.source) ? "its copy is in an older Mason's layout, key order with an index"
|
|
691
|
+
: this.copiedAs.get(t.source) !== columnsKey(t.columns) ? "its columns changed"
|
|
692
|
+
: !this.snapshots.has(t.source) ? "a numeric value outgrew its copy's DECIMAL"
|
|
693
|
+
: null;
|
|
694
|
+
if (copy !== null)
|
|
695
|
+
return { source: t.source, copy };
|
|
696
|
+
const b = this.behind.get(t.source);
|
|
697
|
+
if (b !== undefined)
|
|
698
|
+
return { source: t.source, behind: b.why };
|
|
699
|
+
if (this.replication !== null && !this.slotKnown)
|
|
700
|
+
return { source: t.source, behind: `slot ${this.replication.slot} is new to this copy` };
|
|
701
|
+
return { source: t.source, unsynced: this.mode === "poll" && overPollLimit(t) };
|
|
702
|
+
}
|
|
703
|
+
/** how a copy that is current is kept so, in this run's mode: the start line's words (`addedText`) */
|
|
704
|
+
keptWords() {
|
|
705
|
+
const every = `${Number((this.intervalMs / 1000).toFixed(1))} s`;
|
|
706
|
+
return this.mode === "replication" ? `streamed from ${formatLsn(this.appliedLsn)}`
|
|
707
|
+
: this.mode === "poll" ? `polled every ${every}` : `copied again whole every ${every}`;
|
|
708
|
+
}
|
|
709
|
+
/** the source's views (src/cli/preflight.ts VIEWS_SQL), served over the copied tables: in the catalog from the next
|
|
710
|
+
* pass. A view over a table that is not copied is left out of it (said in the catalog's log) */
|
|
711
|
+
setViews(views) {
|
|
712
|
+
this.views = views.map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, ...(v.comment ? { description: v.comment } : {}) }));
|
|
713
|
+
}
|
|
714
|
+
/** every table read again from the start: the database was restored to another server (src/cli/dev.ts sourceMove) */
|
|
715
|
+
async rereadAll() {
|
|
716
|
+
// from watermark 0 every row is read and written again; the count check deletes what the new server lacks
|
|
717
|
+
await this.onWriter(() => this.store.writer.run(`UPDATE ${MASON_SCHEMA}.poll_state SET snapshot = '0:0:'`));
|
|
718
|
+
for (const s of this.snapshots.keys())
|
|
719
|
+
this.snapshots.set(s, "0:0:");
|
|
720
|
+
}
|
|
721
|
+
state() {
|
|
722
|
+
const now = Date.now();
|
|
723
|
+
// in the project's order, whatever order the copies ran and were recorded in (`copyOrder`)
|
|
724
|
+
return [...this.tables.keys()].flatMap((source) => this.states.get(source) ?? [])
|
|
725
|
+
.map((s) => this.retired.has(s.source) ? { ...s, retired: RETIRED_LAYOUT } : s)
|
|
726
|
+
// a copy's chunks as they are NOW: the rows the ones in flight have read by now at the measured rate
|
|
727
|
+
.map((s) => { const l = this.copyLive.get(s.source); return s.phase === "copying" && l !== undefined ? { ...s, progress: copyProgressNow(l, Date.now()) } : s; })
|
|
728
|
+
// how it is kept current, and a table the passes leave as copied, from ONE derivation (`keptOf`)
|
|
729
|
+
.map((s) => {
|
|
730
|
+
const t = this.tables.get(s.source);
|
|
731
|
+
if (t === undefined)
|
|
732
|
+
return s;
|
|
733
|
+
const kept = this.keptOf(t, now);
|
|
734
|
+
return { ...s, kept, ...(kept.by === "poll" && kept.unsynced !== undefined ? { unsynced: { reason: kept.unsynced, asOf: kept.polledAt } } : {}) };
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* HOW AN ADDED TABLE IS KEPT CURRENT, NOW (`KeptCurrent`): the run's mode, then in replication mode its part in the
|
|
739
|
+
* stream (`streamTable`, what `streamStatus` says of it) and where the stream is; in poll mode its last poll and
|
|
740
|
+
* whether the passes leave it as copied. The one place it is derived: `state` carries it to `mason status` and the
|
|
741
|
+
* listener's EXPLAIN alike.
|
|
742
|
+
*/
|
|
743
|
+
keptOf(t, now) {
|
|
744
|
+
const st = this.states.get(t.source);
|
|
745
|
+
if (this.mode === "poll") {
|
|
746
|
+
const polledAt = this.polledAt.get(t.source) ?? null;
|
|
747
|
+
// a copy the passes do not keep current now, and why: not copied (waiting, being copied, its copy failed:
|
|
748
|
+
// `needsCopy` holds for every phase but copied), to be copied again (its columns changed, a numeric outgrew it, a
|
|
749
|
+
// retired layout), or caught up first (`pass`'s `polled`, and a table not yet caught up on the writer's turn)
|
|
750
|
+
const pending = this.needsCopy(t) || this.behind.has(t.source) ? this.notStreamedWhy(t.source) : null;
|
|
751
|
+
return { by: "poll", polledAt, ...(overPollLimit(t) ? { unsynced: OVER_POLL_LIMIT } : {}), ...(pending !== null ? { pending } : {}) };
|
|
752
|
+
}
|
|
753
|
+
// copied again whole every pass, whatever its watermark: only a table not copied now (waiting, being copied, its copy
|
|
754
|
+
// failed) is not kept current
|
|
755
|
+
if (this.mode === "recopy")
|
|
756
|
+
return { by: "recopy", copiedAt: st?.copiedAt ?? null, ...(st?.phase !== "copied" ? { pending: this.notStreamedWhy(t.source) } : {}) };
|
|
757
|
+
const s = this.stream;
|
|
758
|
+
const part = this.streamTable(t, now, s);
|
|
759
|
+
return { by: "replication", slot: this.replication.slot, state: part.state, why: part.why, position: formatLsn(s?.position() ?? this.appliedLsn),
|
|
760
|
+
lastCommit: this.lastBatch === null ? null : new Date(this.lastBatch.committedAt).toISOString(), notRunning: this.streamDownWhy(s, st) };
|
|
761
|
+
}
|
|
762
|
+
/** why the stream is not running, in the reader's words (null: it runs): its last failure and when it starts again; the
|
|
763
|
+
* replication check a pass failed (`prepareReplication`, recorded on each copy's state); a slot not made yet; a sync
|
|
764
|
+
* stopping; else it starts at the next pass */
|
|
765
|
+
streamDownWhy(s, st) {
|
|
766
|
+
if (s !== null)
|
|
767
|
+
return null;
|
|
768
|
+
const firstLine = (e) => e.replace(/^replication: /, "").split("\n")[0];
|
|
769
|
+
if (this.streamFailure !== null)
|
|
770
|
+
return `${firstLine(this.streamFailure.error)} · it starts again after ${clock(this.streamFailure.retryAt)} UTC`;
|
|
771
|
+
if (st?.error?.startsWith("replication: "))
|
|
772
|
+
return firstLine(st.error);
|
|
773
|
+
if (!this.slotKnown)
|
|
774
|
+
return `slot ${this.replication.slot} is not made yet: it starts once it is`;
|
|
775
|
+
if (this.stopped)
|
|
776
|
+
return "Mason is stopping";
|
|
777
|
+
return "it has not started yet: it starts at the next pass";
|
|
778
|
+
}
|
|
779
|
+
/** an added table's part in the stream (`StreamTableStatus` but what it applied): streamed, being caught up, waiting
|
|
780
|
+
* for its copy, or left out of the publication, and why — what `streamStatus` and `keptOf` both say, each of the
|
|
781
|
+
* stream `s` it read once */
|
|
782
|
+
streamTable(t, now, s) {
|
|
783
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
784
|
+
if (this.unpublished.has(t.source))
|
|
785
|
+
return { state: "left-out", why: this.notStreamedWhy(t.source) };
|
|
786
|
+
const b = this.behind.get(t.source);
|
|
787
|
+
if (!this.needsCopy(t) && b !== undefined) {
|
|
788
|
+
const c = this.catchingUp?.source === t.source ? this.catchingUp : null;
|
|
789
|
+
const held = this.held.get(t.source)?.counts;
|
|
790
|
+
const at = this.retries.get(t.source)?.at ?? 0;
|
|
791
|
+
return { state: "catching-up", why: `caught up by one poll ${b.why}${at > now ? ` — its last catch-up failed, tried again after ${clock(at)} UTC` : ""}`,
|
|
792
|
+
catchUp: { phase: c?.phase ?? "queued", from: this.polledAt.get(t.source) ?? null, startedAt: c ? iso(c.startedAt) : null, held: held ? held.inserts + held.updates + held.deletes + held.truncates : 0,
|
|
793
|
+
retryAt: c === null && at > now ? iso(at) : null } };
|
|
794
|
+
}
|
|
795
|
+
if (this.joinedNow.has(t.source) || (s === null && !this.needsCopy(t)))
|
|
796
|
+
return { state: "streamed", why: null };
|
|
797
|
+
return { state: "waiting", why: this.notStreamedWhy(t.source) };
|
|
798
|
+
}
|
|
799
|
+
/** each table's last copy or poll, from the ledger (a copy writes its watermark there too) */
|
|
800
|
+
async readPolledAt() {
|
|
801
|
+
const r = await this.store.writer.runAndReadAll(`SELECT source, epoch_ms(polled_at) FROM ${MASON_SCHEMA}.poll_state WHERE polled_at IS NOT NULL`);
|
|
802
|
+
this.polledAt.clear();
|
|
803
|
+
for (const [source, ms] of r.getRowsJS())
|
|
804
|
+
this.polledAt.set(source, new Date(Number(ms)).toISOString());
|
|
805
|
+
}
|
|
806
|
+
schema() { return this.currentSchema; }
|
|
807
|
+
start() { this.kick(); }
|
|
808
|
+
/** a pass now (or right after the one running) */
|
|
809
|
+
kick() {
|
|
810
|
+
if (this.stopped)
|
|
811
|
+
return;
|
|
812
|
+
if (this.timer !== null) {
|
|
813
|
+
clearTimeout(this.timer);
|
|
814
|
+
this.timer = null;
|
|
815
|
+
}
|
|
816
|
+
if (this.running !== null) {
|
|
817
|
+
void this.running.then(() => this.kick());
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.running = this.pass().then(() => { this.passFailed = false; }, (e) => {
|
|
821
|
+
this.passFailed = true;
|
|
822
|
+
this.log(`sync pass failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
823
|
+
}).finally(() => {
|
|
824
|
+
this.running = null;
|
|
825
|
+
if (this.stopped)
|
|
826
|
+
return;
|
|
827
|
+
// replication mode: the stream keeps the copies current between passes, and a pass runs when the tables change —
|
|
828
|
+
// or after the interval, when it failed or a table waits for a copy or catch-up nothing has queued (a copy that
|
|
829
|
+
// failed waits its turn, `copyRetryDelayMs`)
|
|
830
|
+
if (this.mode !== "replication")
|
|
831
|
+
this.timer = setTimeout(() => this.kick(), nextPollDelayMs(this.intervalMs, this.lastPassMs));
|
|
832
|
+
else if (this.passFailed || this.waiting())
|
|
833
|
+
this.timer = setTimeout(() => this.kick(), this.intervalMs);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/** an added table needs a copy or a catch-up that is neither queued nor running: a later pass must queue it */
|
|
837
|
+
waiting() {
|
|
838
|
+
const busy = (s) => this.queued.has(s) || this.rebuildingNow?.source === s;
|
|
839
|
+
// a table a named publication does not carry waits for its owner, never for the interval (prepareReplication)
|
|
840
|
+
// a table waiting out a failure's back-off is `wakeAt`'s, never a pass every interval (each one reads the source)
|
|
841
|
+
const backingOff = (s) => (this.retries.get(s)?.at ?? 0) > Date.now();
|
|
842
|
+
return [...this.tables.values()].some((t) => !busy(t.source) && !backingOff(t.source) && !this.unpublished.has(t.source) && (this.needsCopy(t) || this.behind.has(t.source)));
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* A FAILED COPY, CATCH-UP OR REFUSED STREAM OF A TABLE WAITS before its next attempt (`copyRetryDelayMs`: four times the
|
|
846
|
+
* attempt, at least 5 minutes, doubled per failure in a row, at most 6 hours — at once after a restart or a column/key
|
|
847
|
+
* change, `setTables`), and a pass is kicked then (`wakeAt`). Answers the next attempt's words for its log line and
|
|
848
|
+
* `mason status`.
|
|
849
|
+
*/
|
|
850
|
+
backOff(source, attemptMs) {
|
|
851
|
+
const failures = (this.retries.get(source)?.failures ?? 0) + 1;
|
|
852
|
+
const at = Date.now() + copyRetryDelayMs(failures, attemptMs);
|
|
853
|
+
this.retries.set(source, { failures, at });
|
|
854
|
+
this.wakeAt(at);
|
|
855
|
+
return `${clock(at)} UTC (failure ${failures} in a row), or at once when Mason starts again or its columns or key change`;
|
|
856
|
+
}
|
|
857
|
+
/** a pass at `at` (the earliest asked for wins): in replication mode nothing else runs one when a back-off ends */
|
|
858
|
+
wakeAt(at) {
|
|
859
|
+
if (this.stopped || (this.wake !== null && this.wake.at <= at))
|
|
860
|
+
return;
|
|
861
|
+
if (this.wake !== null)
|
|
862
|
+
clearTimeout(this.wake.timer);
|
|
863
|
+
const timer = setTimeout(() => { this.wake = null; this.kick(); }, Math.max(0, at - Date.now()));
|
|
864
|
+
this.wake = { timer, at };
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* AFTER EVERY REBUILD, ITS TABLE'S NEXT STEP: a table that still needs a copy (its columns changed while it was copied, or
|
|
868
|
+
* its copy failed) or a catch-up (its catch-up failed, a slot was made again) gets a pass now — never left for one that
|
|
869
|
+
* may not come: in replication mode a pass runs only when kicked, and a change of its columns that arrived while the table was
|
|
870
|
+
* rebuilt kicked one that passed it over (the test server: a failed catch-up left a 22 M-row table neither streamed
|
|
871
|
+
* nor copied, for good). A pass, not a copy queued here: it re-points Mason's own publication at the added columns
|
|
872
|
+
* first. One backing off waits for its time (`backOff`).
|
|
873
|
+
*/
|
|
874
|
+
followUp(source) {
|
|
875
|
+
const t = this.tables.get(source);
|
|
876
|
+
if (t === undefined || this.stopped || this.unpublished.has(source) || (!this.needsCopy(t) && !this.behind.has(source)))
|
|
877
|
+
return;
|
|
878
|
+
const at = this.retries.get(source)?.at ?? 0;
|
|
879
|
+
if (at > Date.now())
|
|
880
|
+
this.wakeAt(at);
|
|
881
|
+
else
|
|
882
|
+
this.kick();
|
|
883
|
+
}
|
|
884
|
+
/** one pass, now, and its end awaited (the one running first, if any), and the copies and catch-ups it queued: what
|
|
885
|
+
* a test or a reread waits on */
|
|
886
|
+
async once() {
|
|
887
|
+
while (this.running !== null)
|
|
888
|
+
await this.running;
|
|
889
|
+
this.kick();
|
|
890
|
+
if (this.running !== null)
|
|
891
|
+
await this.running;
|
|
892
|
+
while (this.rebuilding !== null)
|
|
893
|
+
await this.rebuilding;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* A POLL OF EXACTLY THESE TABLES, NOW, whatever their size (`mason sync`; a table over POLL_ROW_LIMIT is polled only
|
|
897
|
+
* so): a pass of its own, queued behind the one running, in one source snapshot as every poll is. Refused by name
|
|
898
|
+
* (`PollRefused`) for a table not added or not copied yet, and in a run whose copies are kept current another way.
|
|
899
|
+
* Its failure is its caller's to say: the copies serve as they were, and the passes go on.
|
|
900
|
+
*/
|
|
901
|
+
async pollNow(sources) {
|
|
902
|
+
if (this.mode !== "poll") {
|
|
903
|
+
throw new PollRefused("refused", this.mode === "replication" ? "this run keeps every table current by logical replication, so there is nothing to poll"
|
|
904
|
+
: "this run copies every table again whole at each pass (MASON_DUCK_SYNC=recopy), so there is nothing to poll");
|
|
905
|
+
}
|
|
906
|
+
const tables = sources.map((s) => {
|
|
907
|
+
const t = this.tables.get(s);
|
|
908
|
+
if (t === undefined)
|
|
909
|
+
throw new PollRefused("not_found", `${s} is not added (mason add adds it)${didYouMean(s, [...this.tables.keys()])}`);
|
|
910
|
+
return t;
|
|
911
|
+
});
|
|
912
|
+
while (this.running !== null)
|
|
913
|
+
await this.running;
|
|
914
|
+
if (this.stopped)
|
|
915
|
+
throw new Stopping();
|
|
916
|
+
// asked after the pass ahead of it: that pass may have copied it, or failed its copy
|
|
917
|
+
for (const t of tables) {
|
|
918
|
+
const st = this.states.get(t.source);
|
|
919
|
+
if (st?.phase !== "copied" || !this.snapshots.has(t.source) || this.copiedAs.get(t.source) !== columnsKey(t.columns)) {
|
|
920
|
+
throw new PollRefused("nothing_yet", `${t.source} is not copied yet (${st?.phase === "failed" ? `its copy failed: ${st.error ?? "mason logs says why"}` : "its copy has not finished"}): mason wait waits for it`);
|
|
921
|
+
}
|
|
922
|
+
if (this.retired.has(t.source))
|
|
923
|
+
throw new PollRefused("nothing_yet", `${t.source} is ${RETIRED_LAYOUT} (its copy serves as it was): it is current once copied — mason wait waits for it`);
|
|
924
|
+
this.refuseRebuilding(t);
|
|
925
|
+
}
|
|
926
|
+
const poll = (async () => {
|
|
927
|
+
await this.onWriter(() => this.attach());
|
|
928
|
+
try {
|
|
929
|
+
// a transaction per table, as the passes poll; a table whose rebuild started since it was asked is refused
|
|
930
|
+
const found = [];
|
|
931
|
+
for (const t of tables)
|
|
932
|
+
found.push(...await this.onWriter(async () => { this.refuseRebuilding(t); return this.poll(this.writerLane, [t]); }));
|
|
933
|
+
return found;
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
await this.onWriter(async () => { await this.detach(); await this.readPolledAt(); });
|
|
937
|
+
}
|
|
938
|
+
})();
|
|
939
|
+
// the pass slot is held until it ends (kick and once queue behind it); its failure reaches the caller through `poll`
|
|
940
|
+
this.running = poll.then(() => undefined, () => undefined).finally(() => { this.running = null; });
|
|
941
|
+
const found = await poll;
|
|
942
|
+
const missing = tables.filter((t) => !found.some((f) => f.table === t.source));
|
|
943
|
+
if (missing.length)
|
|
944
|
+
throw new Error(`${missing.map((t) => t.source).join(", ")} has a numeric value wider than its copy's DECIMAL: it is copied again at the next pass, and current once copied`);
|
|
945
|
+
return { at: new Date().toISOString(), tables: found };
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* The sync stopped: no pass starts, and the one running ends at once — its long statement (a copy's scan, a poll's
|
|
949
|
+
* reads; `interruptible`) interrupted, rolled back and said as stopped, never failed (`mason
|
|
950
|
+
* stop` waited for a 30 GB table's sort). DuckDB loses an interrupt that arrives before its statement has started
|
|
951
|
+
* (measured: the statement ran to its end), so it is sent again every 50 ms until the pass has ended.
|
|
952
|
+
*/
|
|
953
|
+
async stop() {
|
|
954
|
+
this.stopped = true;
|
|
955
|
+
if (this.timer !== null)
|
|
956
|
+
clearTimeout(this.timer);
|
|
957
|
+
if (this.wake !== null) {
|
|
958
|
+
clearTimeout(this.wake.timer);
|
|
959
|
+
this.wake = null;
|
|
960
|
+
}
|
|
961
|
+
if (this.reportTimer !== null) {
|
|
962
|
+
clearInterval(this.reportTimer);
|
|
963
|
+
this.reportTimer = null;
|
|
964
|
+
}
|
|
965
|
+
// the stream, the pass and the rebuild end together: a batch waiting for the writer's turn behind a poll must not
|
|
966
|
+
// wait for that poll, so each connection's long statement is interrupted from the start
|
|
967
|
+
const interrupt = () => { for (const lane of [this.writerLane, this.rebuilderLane, ...this.copyLanes])
|
|
968
|
+
if (lane?.inFlight)
|
|
969
|
+
lane.conn.interrupt(); };
|
|
970
|
+
const again = setInterval(interrupt, 50);
|
|
971
|
+
interrupt();
|
|
972
|
+
try {
|
|
973
|
+
// none of them rejects: each says its own failure
|
|
974
|
+
await Promise.all([this.stream?.stop(), this.running, this.rebuilding, this.kept.stop()]);
|
|
975
|
+
await this.writerTurn;
|
|
976
|
+
}
|
|
977
|
+
finally {
|
|
978
|
+
clearInterval(again);
|
|
979
|
+
}
|
|
980
|
+
this.stream = null;
|
|
981
|
+
// the last catalog rebuild ended, either way, before its connection closes: a failure of it is said by its caller
|
|
982
|
+
await new Promise((done) => { this.schemaBuild.then(done, done); });
|
|
983
|
+
this.catalog?.closeSync();
|
|
984
|
+
this.catalog = null;
|
|
985
|
+
this.rebuilderLane?.conn.closeSync();
|
|
986
|
+
this.rebuilderLane = null;
|
|
987
|
+
}
|
|
988
|
+
/** 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) */
|
|
989
|
+
refuseRebuilding(t) {
|
|
990
|
+
const now = this.rebuildingNow?.source === t.source ? this.rebuildingNow.kind : null;
|
|
991
|
+
if (now === null && !this.behind.has(t.source))
|
|
992
|
+
return;
|
|
993
|
+
const doing = now === "copy" ? "being copied again" : `being caught up by one poll ${this.behind.get(t.source)?.why ?? ""}`.trimEnd();
|
|
994
|
+
throw new PollRefused("nothing_yet", `${t.source} is ${doing} (its copy serves as it was): it is caught up by one poll when that ends, so there is nothing to poll — mason wait waits for it`);
|
|
995
|
+
}
|
|
996
|
+
/** the source's connection, as the sync's own reads and replication make it (never DuckDB's: that is `attach`) */
|
|
997
|
+
pgOptions() {
|
|
998
|
+
const s = this.source;
|
|
999
|
+
return { host: s.host, port: s.port, database: s.database, user: s.user, password: s.password, sslmode: s.sslmode, applicationName: "mason-data", connectTimeoutMs: 10_000 };
|
|
1000
|
+
}
|
|
1001
|
+
/** an added table with no copy to keep current (never copied, or to be copied again: its columns changed, a numeric
|
|
1002
|
+
* outgrew its copy, or its copy is in a retired layout, `RETIRED_LAYOUT`). A table a named publication does not carry (`unpublished`) needs one too, but waits for
|
|
1003
|
+
* its owner, never for the interval: its callers skip it */
|
|
1004
|
+
needsCopy(t) {
|
|
1005
|
+
return this.states.get(t.source)?.phase !== "copied" || !this.snapshots.has(t.source) || this.copiedAs.get(t.source) !== columnsKey(t.columns) || this.retired.has(t.source);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* THE TABLE AS ITS COPY HOLDS IT: its columns the copy's own (`copiedColumns`), the rest the added table's. Every write to a
|
|
1009
|
+
* copy — a stream batch, the changes held during a catch-up, an unchanged-TOAST update, a delete by key, a catch-up poll
|
|
1010
|
+
* — is shaped by it, never by the added columns of the moment (which change while a copy runs, as on the test server)
|
|
1011
|
+
* nor by what pgoutput's relation carries. A table not copied yet is as it is added.
|
|
1012
|
+
*/
|
|
1013
|
+
asCopied(t) {
|
|
1014
|
+
const columns = this.copiedColumns.get(t.source);
|
|
1015
|
+
return columns === undefined ? t : { ...t, columns };
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* REPLICATION, BEFORE A PASS COPIES: the publication carries every added table, and the slot exists — so a table
|
|
1019
|
+
* copied now has its changes in the stream from before it joins (src/duck/stream.ts "why replaying is safe"). A slot
|
|
1020
|
+
* made now, or one this copy has no record of, carries nothing from before it: the tables already copied are caught
|
|
1021
|
+
* up by one poll each before the stream applies their changes (`behind`, recorded with the slot in one transaction).
|
|
1022
|
+
* A slot Postgres invalidated (`wal_status = lost`, past `max_slot_wal_keep_size`) lost changes: it is dropped and
|
|
1023
|
+
* made again, and the copies are caught up the same way.
|
|
1024
|
+
*/
|
|
1025
|
+
async prepareReplication() {
|
|
1026
|
+
const r = this.replication;
|
|
1027
|
+
const tables = [...this.tables.values()];
|
|
1028
|
+
const pg = await PgConnection.connect(this.pgOptions());
|
|
1029
|
+
try {
|
|
1030
|
+
const identities = new Map((await pg.query(`SELECT n.nspname || '.' || c.relname, c.relreplident FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace ` +
|
|
1031
|
+
`WHERE n.nspname || '.' || c.relname IN (${tables.map((t) => pgLiteral(t.source)).join(", ")})`)).rows.map((x) => [x[0], x[1]]));
|
|
1032
|
+
// a table that identifies no row would make the app's own UPDATEs and DELETEs on it fail once a publication of
|
|
1033
|
+
// updates carries it: never added
|
|
1034
|
+
const nothing = tables.filter((t) => identities.get(t.source) === "n").map((t) => t.source);
|
|
1035
|
+
if (nothing.length) {
|
|
1036
|
+
throw new Error(`${nothing.join(", ")} ${nothing.length === 1 ? "has" : "have"} REPLICA IDENTITY NOTHING: a publication carrying ${nothing.length === 1 ? "it" : "them"} ` +
|
|
1037
|
+
`would make the app's own UPDATEs and DELETEs fail, so Mason does not add ${nothing.length === 1 ? "it" : "them"}. ALTER TABLE … REPLICA IDENTITY DEFAULT, or mason start --poll`);
|
|
1038
|
+
}
|
|
1039
|
+
if (r.owned)
|
|
1040
|
+
await this.ensurePublication(pg, tables, identities);
|
|
1041
|
+
else
|
|
1042
|
+
await this.verifyPublication(pg, tables, identities);
|
|
1043
|
+
const slot = await readSlot(pg, r.slot);
|
|
1044
|
+
let made = false;
|
|
1045
|
+
if (slot.exists && slot.walStatus === "lost") {
|
|
1046
|
+
this.log(`replication slot ${r.slot} was invalidated by the database (wal_status lost: it held more WAL than max_slot_wal_keep_size), so changes are missing from it: made again, and the copies caught up by one poll`);
|
|
1047
|
+
if (slot.active)
|
|
1048
|
+
throw new Error(`replication slot ${r.slot} is lost and still in use by pid ${slot.activePid}`);
|
|
1049
|
+
await dropSlot(pg, r.slot);
|
|
1050
|
+
}
|
|
1051
|
+
else if (slot.exists && slot.plugin !== "pgoutput") {
|
|
1052
|
+
throw new Error(`replication slot ${r.slot} exists with the plugin ${slot.plugin}, not pgoutput: it is not Mason's (drop it, or name another project)`);
|
|
1053
|
+
}
|
|
1054
|
+
if (!slot.exists || slot.walStatus === "lost") {
|
|
1055
|
+
const repl = await PgConnection.connect({ ...this.pgOptions(), replication: true });
|
|
1056
|
+
try {
|
|
1057
|
+
this.log(`made replication slot ${r.slot} at ${await createSlot(repl, r.slot)} (it holds the database's WAL until Mason reads it: mason destroy, or mason start --poll, drops it)`);
|
|
1058
|
+
}
|
|
1059
|
+
finally {
|
|
1060
|
+
repl.close();
|
|
1061
|
+
}
|
|
1062
|
+
made = true;
|
|
1063
|
+
}
|
|
1064
|
+
if (made || !this.slotKnown) {
|
|
1065
|
+
const copied = tables.filter((t) => this.states.get(t.source)?.phase === "copied" && this.snapshots.has(t.source));
|
|
1066
|
+
await this.onWriter(async () => {
|
|
1067
|
+
const w = this.store.writer;
|
|
1068
|
+
await w.run("BEGIN TRANSACTION");
|
|
1069
|
+
try {
|
|
1070
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.stream_state VALUES ($1, '0/0', now())`, [r.slot]);
|
|
1071
|
+
for (const t of copied)
|
|
1072
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.catch_up VALUES (${literal(t.source)}, 'after the slot was made', now())`);
|
|
1073
|
+
await w.run("COMMIT");
|
|
1074
|
+
}
|
|
1075
|
+
catch (e) {
|
|
1076
|
+
await w.run("ROLLBACK");
|
|
1077
|
+
throw e;
|
|
1078
|
+
}
|
|
1079
|
+
this.appliedLsn = 0n;
|
|
1080
|
+
this.slotKnown = true;
|
|
1081
|
+
for (const t of copied)
|
|
1082
|
+
this.behind.set(t.source, { why: "after the slot was made" });
|
|
1083
|
+
this.refreshStreamed();
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
finally {
|
|
1088
|
+
pg.close();
|
|
1089
|
+
}
|
|
1090
|
+
// what the last stream's failure said is the past once the publication and slot are as they should be; a table a
|
|
1091
|
+
// named publication does not carry is refused on its own line (a copy it never had is failed), the others go on
|
|
1092
|
+
await this.onWriter(async () => {
|
|
1093
|
+
for (const t of tables) {
|
|
1094
|
+
const st = this.states.get(t.source);
|
|
1095
|
+
const gap = this.unpublished.get(t.source);
|
|
1096
|
+
if (gap !== undefined) {
|
|
1097
|
+
const error = `replication: ${gap}`;
|
|
1098
|
+
if (st.error !== error)
|
|
1099
|
+
this.log(`${t.source} is not copied or streamed: ${error}`);
|
|
1100
|
+
await this.record({ ...st, phase: st.phase === "copied" ? "copied" : "failed", error });
|
|
1101
|
+
}
|
|
1102
|
+
else if (st.error?.startsWith("replication: "))
|
|
1103
|
+
await this.record({ ...st, error: null });
|
|
1104
|
+
}
|
|
1105
|
+
// a table the publication stopped carrying leaves the stream; one it carries now may join
|
|
1106
|
+
this.refreshStreamed();
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* A publication the operator NAMED: read as it is, verified, never created, altered or dropped (src/duck/publication.ts).
|
|
1111
|
+
* One that cannot be read through at all refuses the pass; a table it does not carry is left out of the copy and the
|
|
1112
|
+
* stream (`unpublished`), every other table going on.
|
|
1113
|
+
*/
|
|
1114
|
+
async verifyPublication(pg, tables, identities) {
|
|
1115
|
+
const name = this.replication.publication;
|
|
1116
|
+
const v = namedPublicationVerdict(await readNamedPublication(pg, name, tables, this.database), tables, identities);
|
|
1117
|
+
if (v.refused !== null)
|
|
1118
|
+
throw new Error(v.refused);
|
|
1119
|
+
this.unpublished.clear();
|
|
1120
|
+
for (const [source, m] of v.tables)
|
|
1121
|
+
this.unpublished.set(source, m);
|
|
1122
|
+
if (v.noTruncate && !this.truncateSaid) {
|
|
1123
|
+
this.log(`publication ${name} does not publish truncate: a TRUNCATE on the database does not reach the copy (the poll's count would see it; mason start --poll)`);
|
|
1124
|
+
this.truncateSaid = true;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Mason's own publication made, or re-pointed, to carry exactly the added tables, with inserts, updates, deletes and
|
|
1129
|
+
* truncates. A table with the default identity (its primary key) carries its added columns only (Postgres 15's
|
|
1130
|
+
* column lists): what the stream sends is what the copy keeps. Any other identity carries the whole table, since a
|
|
1131
|
+
* column list that misses an identity column makes the app's UPDATEs fail; a generated column is never listed (Postgres
|
|
1132
|
+
* refuses it there; the stream reads it from the source, `refetch`).
|
|
1133
|
+
*/
|
|
1134
|
+
async ensurePublication(pg, tables, identities) {
|
|
1135
|
+
const name = this.replication.publication;
|
|
1136
|
+
const lists = Number((await pg.query("SHOW server_version_num")).rows[0]?.[0] ?? 0) >= 150000;
|
|
1137
|
+
const want = new Map(tables.map((t) => [t.source, lists && identities.get(t.source) === "d" ? t.columns.filter((c) => !c.generated).map((c) => c.name).sort() : null]));
|
|
1138
|
+
const flags = (await pg.query(`SELECT pubinsert AND pubupdate AND pubdelete AND pubtruncate, puballtables FROM pg_publication WHERE pubname = ${pgLiteral(name)}`)).rows[0];
|
|
1139
|
+
const now = new Map((await pg.query(lists
|
|
1140
|
+
? `SELECT n.nspname || '.' || c.relname, CASE WHEN r.prattrs IS NULL THEN NULL ELSE (SELECT string_agg(a.attname, ',' ORDER BY a.attname) FROM pg_attribute a WHERE a.attrelid = r.prrelid AND a.attnum = ANY(r.prattrs)) END ` +
|
|
1141
|
+
`FROM pg_publication_rel r JOIN pg_publication p ON p.oid = r.prpubid JOIN pg_class c ON c.oid = r.prrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE p.pubname = ${pgLiteral(name)}`
|
|
1142
|
+
: `SELECT schemaname || '.' || tablename, NULL FROM pg_publication_tables WHERE pubname = ${pgLiteral(name)}`)).rows.map((x) => [x[0], x[1] ?? null]));
|
|
1143
|
+
const same = flags !== undefined && flags[0] === "t" && flags[1] === "f" && now.size === want.size &&
|
|
1144
|
+
[...want].every(([source, cols]) => now.has(source) && now.get(source) === (cols === null ? null : [...cols].sort((a, b) => a < b ? -1 : a > b ? 1 : 0).join(",")));
|
|
1145
|
+
if (same)
|
|
1146
|
+
return;
|
|
1147
|
+
const list = tables.map((t) => { const cols = want.get(t.source); return `${pgIdent(t.schema)}.${pgIdent(t.table)}${cols ? ` (${cols.map(pgIdent).join(", ")})` : ""}`; }).join(", ");
|
|
1148
|
+
if (flags === undefined) {
|
|
1149
|
+
await pg.query(`CREATE PUBLICATION ${pgIdent(name)} FOR TABLE ${list} WITH (publish = 'insert, update, delete, truncate')`);
|
|
1150
|
+
this.log(`made publication ${name} for ${tables.map((t) => t.source).join(", ")}`);
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
if (flags[1] === "t")
|
|
1154
|
+
throw new Error(`publication ${name} is FOR ALL TABLES: it is not the one Mason made (drop it, or name another project)`);
|
|
1155
|
+
await pg.query(`ALTER PUBLICATION ${pgIdent(name)} SET TABLE ${list}`);
|
|
1156
|
+
await pg.query(`ALTER PUBLICATION ${pgIdent(name)} SET (publish = 'insert, update, delete, truncate')`);
|
|
1157
|
+
this.log(`re-pointed publication ${name} at ${tables.map((t) => t.source).join(", ")}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* The stream started from the slot's acknowledged position, once the slot is known (a pass's `prepareReplication`),
|
|
1162
|
+
* and never stopped by a pass: a copy runs beside it. What it applies is `streamed`, read at each change, so a table
|
|
1163
|
+
* joins once caught up and leaves when copied again without a restart.
|
|
1164
|
+
*/
|
|
1165
|
+
startStream() {
|
|
1166
|
+
if (this.stream !== null || this.stopped || this.mode !== "replication" || !this.slotKnown || Date.now() < this.streamAt)
|
|
1167
|
+
return;
|
|
1168
|
+
const r = this.replication;
|
|
1169
|
+
this.refreshStreamed();
|
|
1170
|
+
const s = new ReplicationStream({
|
|
1171
|
+
connect: () => PgConnection.connect({ ...this.pgOptions(), replication: true }), slot: r.slot, publication: r.publication, log: (l) => this.log(l),
|
|
1172
|
+
target: { tables: () => this.streamed, apply: (batch, lsn, committedAt) => this.applyStreamed(batch, lsn, committedAt), applied: () => this.appliedLsn,
|
|
1173
|
+
refuse: (source, missing) => this.refuseStreamed(source, missing) },
|
|
1174
|
+
});
|
|
1175
|
+
this.stream = s;
|
|
1176
|
+
this.streamFailure = null;
|
|
1177
|
+
this.lineAt = Date.now();
|
|
1178
|
+
// the stream's periodic line: at most every streamReportMs, and only when it applied something (`reportStream`)
|
|
1179
|
+
if (this.reportTimer === null) {
|
|
1180
|
+
this.reportTimer = setInterval(() => this.reportStream(), this.streamReportMs);
|
|
1181
|
+
this.reportTimer.unref();
|
|
1182
|
+
}
|
|
1183
|
+
const joined = [...this.streamed.keys()].filter((x) => !this.held.has(x));
|
|
1184
|
+
this.log(`streaming ${joined.length ? joined.join(", ") : "no table yet"} from slot ${r.slot} (publication ${r.publication}), applied through ${formatLsn(this.appliedLsn)}` +
|
|
1185
|
+
`${joined.length < this.tables.size ? "; a table copied or caught up meanwhile joins it once current" : ""}`);
|
|
1186
|
+
void s.done.then(async (e) => {
|
|
1187
|
+
if (this.stream === s)
|
|
1188
|
+
this.stream = null;
|
|
1189
|
+
if (e === null || this.stopped)
|
|
1190
|
+
return;
|
|
1191
|
+
// a numeric wider than its copy's DECIMAL: that table leaves the stream and is copied again, wider, and the stream
|
|
1192
|
+
// goes on at once without it (it joins again caught up from its new copy) — at most three times a run, past which
|
|
1193
|
+
// a value the stream keeps replaying is wider than any copy of now
|
|
1194
|
+
const wider = [...this.tables.values()].find((t) => e.message.includes(`${WIDER} [${t.source}]`));
|
|
1195
|
+
const n = wider === undefined ? 0 : (this.widened.get(wider.source) ?? 0) + 1;
|
|
1196
|
+
if (wider !== undefined && n <= 3) {
|
|
1197
|
+
this.widened.set(wider.source, n);
|
|
1198
|
+
await this.onWriter(async () => { this.snapshots.delete(wider.source); this.refreshStreamed(); });
|
|
1199
|
+
this.log(`replication: ${wider.source} has a numeric value wider than its copy's DECIMAL: it is copied again, then streamed`);
|
|
1200
|
+
this.startStream();
|
|
1201
|
+
this.kick();
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const m = `replication: ${e.message}`;
|
|
1205
|
+
// a failure that recurs must not restart the stream every second: each restart decodes the slot's WAL again on
|
|
1206
|
+
// the source
|
|
1207
|
+
this.streamFailures++;
|
|
1208
|
+
const wait = streamRetryDelayMs(this.intervalMs, this.streamFailures);
|
|
1209
|
+
// a timer may fire a little early: the pass it kicks starts the stream
|
|
1210
|
+
this.streamAt = Date.now() + wait - 50;
|
|
1211
|
+
this.streamFailure = { error: m, retryAt: this.streamAt };
|
|
1212
|
+
this.log(`${m} (the stream starts again in ${wait / 1000} s, failure ${this.streamFailures} in a row; the copies serve as they are)`);
|
|
1213
|
+
// on the tables the stream kept current when it failed (one being caught up says its own)
|
|
1214
|
+
await this.onWriter(async () => { for (const source of this.streamed.keys())
|
|
1215
|
+
if (!this.held.has(source))
|
|
1216
|
+
await this.record({ ...this.states.get(source), error: m }); });
|
|
1217
|
+
this.timer = setTimeout(() => this.kick(), wait);
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* A STREAMED TABLE WHOSE RELATION NO LONGER CARRIES A COLUMN ITS COPY HOLDS (a DDL on the database, or a publication's
|
|
1222
|
+
* column list narrowed while Mason streams; at the start `checkPublication` refuses the same): refused by name with the
|
|
1223
|
+
* way out, the other tables streaming on. It leaves the stream and is marked behind (`_mason.catch_up`, on the writer's
|
|
1224
|
+
* turn, so before the stream acknowledges a change it skipped), and caught up by one poll from its watermark once its
|
|
1225
|
+
* back-off ends (`backOff`) — that poll, or the next pass's publication check, says whether the column is back; added
|
|
1226
|
+
* without it, the table is copied again at once. The stream waits for it: one that cannot be recorded ends the stream with its
|
|
1227
|
+
* error, before anything past it is acknowledged.
|
|
1228
|
+
*/
|
|
1229
|
+
refuseStreamed(source, missing) {
|
|
1230
|
+
return this.onWriter(async () => {
|
|
1231
|
+
const t = this.tables.get(source);
|
|
1232
|
+
if (t === undefined || !this.streamed.has(source))
|
|
1233
|
+
return;
|
|
1234
|
+
const cols = missing.join(", ");
|
|
1235
|
+
const why = `after the stream stopped carrying ${cols}`;
|
|
1236
|
+
await this.store.writer.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.catch_up VALUES (${literal(source)}, ${literal(why)}, now())`);
|
|
1237
|
+
this.behind.set(source, { why });
|
|
1238
|
+
this.held.delete(source);
|
|
1239
|
+
const when = this.backOff(source, 0);
|
|
1240
|
+
const pub = this.replication.publication;
|
|
1241
|
+
const m = `replication: the stream no longer carries ${source}'s ${cols}, which its copy holds (a column dropped or renamed on the database, or the publication's column list narrowed), ` +
|
|
1242
|
+
`so its changes are not applied: it is caught up by one poll again after ${when}. The way out: remove ${cols} from its columns in the sources file (mason add ${source} --columns … ` +
|
|
1243
|
+
`picks the columns it has; it is copied again at once)${this.replication.owned ? "" : `, or publish ${missing.length === 1 ? "it" : "them"} again (ALTER PUBLICATION ${pub} …)`}`;
|
|
1244
|
+
this.log(`${source}: ${m}`);
|
|
1245
|
+
await this.record({ ...this.states.get(source), error: m });
|
|
1246
|
+
this.refreshStreamed();
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* What the stream applies, made again from the tables' state (on the writer's turn, or before the stream exists): a
|
|
1251
|
+
* table copied at its present columns and caught up, and a table being caught up (its changes held, `held`). A table
|
|
1252
|
+
* that is not — never copied, being copied again, or not caught up — is skipped: its copy's snapshot, or its catch-up,
|
|
1253
|
+
* covers what the stream passes over (src/duck/stream.ts "why replaying is safe").
|
|
1254
|
+
*/
|
|
1255
|
+
refreshStreamed() {
|
|
1256
|
+
this.streamed.clear();
|
|
1257
|
+
const now = new Set();
|
|
1258
|
+
for (const t of this.tables.values()) {
|
|
1259
|
+
// a table a named publication does not carry is never streamed (`unpublished`): its changes are not in the slot
|
|
1260
|
+
const joined = !this.behind.has(t.source) && !this.needsCopy(t) && !this.unpublished.has(t.source);
|
|
1261
|
+
// in the COPY's columns: a held table's columns may have changed since its copy began (`asCopied`)
|
|
1262
|
+
const c = this.asCopied(t).columns;
|
|
1263
|
+
if (joined || this.held.has(t.source)) {
|
|
1264
|
+
this.streamed.set(t.source, { columns: c.map((x) => x.name), key: t.key, generated: c.filter((x) => x.generated).map((x) => x.name), big: t.rows !== null && t.rows > POLL_ROW_LIMIT });
|
|
1265
|
+
}
|
|
1266
|
+
if (joined)
|
|
1267
|
+
now.add(t.source);
|
|
1268
|
+
}
|
|
1269
|
+
// A TABLE JOINING OR LEAVING THE STREAM IS SAID, with the position it does so at — never a line per batch. Said only
|
|
1270
|
+
// while a stream runs: its start names what it streams. What it returns is the tables whose join was said
|
|
1271
|
+
const said = [];
|
|
1272
|
+
const s = this.stream;
|
|
1273
|
+
if (s !== null && this.mode === "replication") {
|
|
1274
|
+
const at = formatLsn(s.position());
|
|
1275
|
+
for (const source of this.joinedNow)
|
|
1276
|
+
if (!now.has(source))
|
|
1277
|
+
this.log(`${source} leaves the stream at ${at}: ${this.notStreamedWhy(source)}`);
|
|
1278
|
+
for (const source of now) {
|
|
1279
|
+
if (this.joinedNow.has(source))
|
|
1280
|
+
continue;
|
|
1281
|
+
const why = this.joinWhy.get(source);
|
|
1282
|
+
this.log(why === undefined ? `${source} joins the stream at ${at}: the stream carries what follows` : `${why}: it joins the stream at ${at}, which carries what follows`);
|
|
1283
|
+
said.push(source);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
this.joinWhy.clear();
|
|
1287
|
+
this.joinedNow = now;
|
|
1288
|
+
return said;
|
|
1289
|
+
}
|
|
1290
|
+
/** why an added table is not streamed now (or a removed one no longer), in the reader's words, with what it waits for
|
|
1291
|
+
* next: a failure's back-off (its time), or the rebuild it is queued behind (one runs at a time) */
|
|
1292
|
+
notStreamedWhy(source) {
|
|
1293
|
+
const why = this.notStreamedReason(source);
|
|
1294
|
+
const at = this.retries.get(source)?.at ?? 0;
|
|
1295
|
+
if (at > Date.now())
|
|
1296
|
+
return `${why} — after a failure, tried again after ${clock(at)} UTC`;
|
|
1297
|
+
const ahead = this.rebuildingNow;
|
|
1298
|
+
if (this.queued.has(source) && ahead !== null && ahead.source !== source)
|
|
1299
|
+
return `${why} — queued behind ${ahead.source}'s ${ahead.kind === "copy" ? "copy" : "catch-up"} (one rebuild at a time)`;
|
|
1300
|
+
return why;
|
|
1301
|
+
}
|
|
1302
|
+
notStreamedReason(source) {
|
|
1303
|
+
const t = this.tables.get(source);
|
|
1304
|
+
if (t === undefined)
|
|
1305
|
+
return "it is no longer added (its copy is dropped)";
|
|
1306
|
+
const gap = this.unpublished.get(source);
|
|
1307
|
+
if (gap !== undefined)
|
|
1308
|
+
return gap.split("\n")[0];
|
|
1309
|
+
const st = this.states.get(source);
|
|
1310
|
+
if (this.copiedAs.has(source) && this.copiedAs.get(source) !== columnsKey(t.columns))
|
|
1311
|
+
return "its columns changed: it is copied again, then caught up by one poll";
|
|
1312
|
+
if (st?.phase === "copied" && !this.snapshots.has(source))
|
|
1313
|
+
return "a numeric value outgrew its copy's DECIMAL: it is copied again, wider, then caught up by one poll";
|
|
1314
|
+
if (st?.phase === "copied" && this.retired.has(source))
|
|
1315
|
+
return "its copy is in an older Mason's layout, key order with an index: it is copied again, then caught up by one poll";
|
|
1316
|
+
// what follows the copy: a catch-up poll, then the stream or the polls; copied again whole, nothing (`keptOf`'s recopy)
|
|
1317
|
+
const joins = this.mode === "recopy" ? "it is kept current once copied" : "it joins once copied and caught up by one poll";
|
|
1318
|
+
if (st?.phase === "copying" || this.rebuildingNow?.source === source && this.rebuildingNow.kind === "copy")
|
|
1319
|
+
return `it is being copied: ${joins}`;
|
|
1320
|
+
if (st?.phase === "failed")
|
|
1321
|
+
return `its copy failed (${(st.error ?? "").split("\n")[0]}): ${joins}`;
|
|
1322
|
+
const b = this.behind.get(source);
|
|
1323
|
+
if (!this.needsCopy(t) && b !== undefined)
|
|
1324
|
+
return `it is caught up by one poll ${b.why} first`;
|
|
1325
|
+
return `it waits for its copy: ${joins}`;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* WHERE THE STREAM IS (`StreamStatus`): its position, the database's, the lag, the last batch, and each added table's
|
|
1329
|
+
* part in it with what it applied this run. Null outside replication mode. The database's position costs a
|
|
1330
|
+
* connection at most every 5 s (`databasePosition`); everything else is the run's own.
|
|
1331
|
+
*/
|
|
1332
|
+
async streamStatus() {
|
|
1333
|
+
if (this.mode !== "replication")
|
|
1334
|
+
return null;
|
|
1335
|
+
const r = this.replication;
|
|
1336
|
+
const s = this.stream;
|
|
1337
|
+
const position = s?.position() ?? this.appliedLsn;
|
|
1338
|
+
const db = await this.databasePosition();
|
|
1339
|
+
const now = Date.now();
|
|
1340
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
1341
|
+
// each table's part, as `keptOf` says it for EXPLAIN (`streamTable`), with what it applied this run
|
|
1342
|
+
const tables = [...this.tables.values()].map((t) => ({ table: t.source, ...this.streamTable(t, now, s), applied: { ...(this.appliedCounts.get(t.source) ?? noCounts()) } }));
|
|
1343
|
+
return {
|
|
1344
|
+
slot: r.slot, publication: r.publication, running: s !== null,
|
|
1345
|
+
failure: s === null && this.streamFailure !== null ? { error: this.streamFailure.error, retryAt: iso(this.streamFailure.retryAt) } : null,
|
|
1346
|
+
position: formatLsn(position), applied: formatLsn(this.appliedLsn),
|
|
1347
|
+
database: "lsn" in db ? { lsn: formatLsn(db.lsn), at: iso(db.at) } : { error: db.error },
|
|
1348
|
+
lag: { bytes: "lsn" in db ? Number(db.lsn > position ? db.lsn - position : 0n) : null,
|
|
1349
|
+
seconds: this.lastBatch === null ? null : Math.max(0, now - this.lastBatch.committedAt) / 1000 },
|
|
1350
|
+
lastBatch: this.lastBatch === null ? null : { appliedAt: iso(this.lastBatch.appliedAt), committedAt: iso(this.lastBatch.committedAt) },
|
|
1351
|
+
tables,
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* THE DATABASE'S OWN POSITION, read over a connection of its own at most every 5 s however often it is asked (`mason
|
|
1356
|
+
* wait` asks every second), waited for at most 3 s (the last read is answered meanwhile, with its time). Not the
|
|
1357
|
+
* stream's keepalive: that carries the walsender's own read position, not the database's, so it cannot say how far
|
|
1358
|
+
* behind the stream is when that matters — while it decodes a backlog. A read that fails is answered as its error.
|
|
1359
|
+
*/
|
|
1360
|
+
async databasePosition() {
|
|
1361
|
+
const fresh = this.dbPosition !== null && Date.now() - this.dbPosition.at < 5_000;
|
|
1362
|
+
if (!fresh && this.dbPositionRead === null) {
|
|
1363
|
+
this.dbPositionRead = (async () => {
|
|
1364
|
+
try {
|
|
1365
|
+
const pg = await PgConnection.connect(this.pgOptions());
|
|
1366
|
+
try {
|
|
1367
|
+
this.dbPosition = { lsn: await currentLsn(pg), at: Date.now() };
|
|
1368
|
+
}
|
|
1369
|
+
finally {
|
|
1370
|
+
pg.close();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
catch (e) {
|
|
1374
|
+
// answered as the status's (and the log line's) own words: the position could not be read, and why
|
|
1375
|
+
this.dbPosition = { error: e instanceof Error ? e.message : String(e), at: Date.now() };
|
|
1376
|
+
}
|
|
1377
|
+
finally {
|
|
1378
|
+
this.dbPositionRead = null;
|
|
1379
|
+
}
|
|
1380
|
+
})();
|
|
1381
|
+
}
|
|
1382
|
+
if (!fresh && this.dbPositionRead !== null)
|
|
1383
|
+
await Promise.race([this.dbPositionRead, new Promise((done) => { setTimeout(done, 3_000).unref(); })]);
|
|
1384
|
+
return this.dbPosition ?? { error: "the database did not answer within 3 s", at: Date.now() };
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* THE STREAM'S PERIODIC LINE, every `streamReportMs` (a minute) and only when it applied something since the last:
|
|
1388
|
+
* where it is, what it applied by table, and the lag. Silent when idle; never a line per batch.
|
|
1389
|
+
*/
|
|
1390
|
+
reportStream() {
|
|
1391
|
+
const s = this.stream;
|
|
1392
|
+
if (this.sinceLine.size === 0 || s === null)
|
|
1393
|
+
return;
|
|
1394
|
+
const moved = [...this.sinceLine].map(([source, c]) => `${source} ${countsText(c)}`).join(", ");
|
|
1395
|
+
const span = Date.now() - this.lineAt;
|
|
1396
|
+
this.sinceLine.clear();
|
|
1397
|
+
this.lineAt = Date.now();
|
|
1398
|
+
const position = s.position();
|
|
1399
|
+
const last = this.lastBatch;
|
|
1400
|
+
void this.databasePosition().then((db) => {
|
|
1401
|
+
const behind = "lsn" in db ? `${(db.lsn > position ? db.lsn - position : 0n).toLocaleString("en-US")} bytes behind the database` : `the database's position could not be read (${db.error})`;
|
|
1402
|
+
const age = last === null ? "" : `; the newest change applied was committed ${secondsText(Date.now() - last.committedAt)} ago, and applied ${secondsText(last.appliedAt - last.committedAt)} after its commit`;
|
|
1403
|
+
this.log(`replication: applied through ${formatLsn(position)} · in the last ${secondsText(span)}: ${moved} · ${behind}${age}`);
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* THE STREAM'S BATCH, ON THE WRITER'S TURN: a joined table's changes applied (`applyChanges`, which records the
|
|
1408
|
+
* position even when nothing is applied), a table being caught up's held for after its poll (`foldInto`), and one
|
|
1409
|
+
* that left since the batch was decoded dropped.
|
|
1410
|
+
*/
|
|
1411
|
+
applyStreamed(batch, lsn, committedAt) {
|
|
1412
|
+
return this.onWriter(async () => {
|
|
1413
|
+
this.refreshStreamed();
|
|
1414
|
+
const applied = new Map();
|
|
1415
|
+
for (const [name, changes] of batch) {
|
|
1416
|
+
const now = this.streamed.get(name);
|
|
1417
|
+
// decoded in another shape than the copy's now (before it was copied again, a stream left it): what they changed
|
|
1418
|
+
// committed before the new copy's snapshot, so it is in that copy — never written in a shape it does not have
|
|
1419
|
+
if (now === undefined || changes.columns === null || changes.columns.join("\0") !== now.columns.join("\0"))
|
|
1420
|
+
continue;
|
|
1421
|
+
const held = this.held.get(name);
|
|
1422
|
+
if (held !== undefined) {
|
|
1423
|
+
foldInto(held, changes);
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
applied.set(name, changes);
|
|
1427
|
+
}
|
|
1428
|
+
await this.applyChanges(applied, lsn, committedAt);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* A batch of the stream applied in ONE DuckDB transaction with the position it ends at: per table, a TRUNCATE's
|
|
1433
|
+
* rows removed, a row an UPDATE left a large value unchanged in (pgoutput sends none) updated in the columns it
|
|
1434
|
+
* carries (`updateCarried`) — read whole from the source by its key where DuckDB has no row to keep the value of, or
|
|
1435
|
+
* a column of it is not streamed — then the deleted keys and the whole rows written by key (`writeByKey`). With no
|
|
1436
|
+
* index each of those is a scan of the table's key column, so a batch costs a few scans per table, never one per row. A numeric wider than its copy's DECIMAL is the table's to copy
|
|
1437
|
+
* again (`WIDER`), never rounded. A null `lsn` is a piece of a large transaction (src/duck/stream.ts): committed, the
|
|
1438
|
+
* position left as it was, so a run that dies before the transaction's last piece applies it again whole. What it
|
|
1439
|
+
* applied is counted by table (`streamStatus`), and the batch's newest commit time kept (null: changes held during a
|
|
1440
|
+
* catch-up, whose time is the batches' that held them).
|
|
1441
|
+
*/
|
|
1442
|
+
async applyChanges(batch, lsn, committedAt = null) {
|
|
1443
|
+
const w = this.store.writer;
|
|
1444
|
+
let source = null;
|
|
1445
|
+
await w.run("BEGIN TRANSACTION");
|
|
1446
|
+
try {
|
|
1447
|
+
for (const [name, changes] of batch) {
|
|
1448
|
+
const added = this.tables.get(name);
|
|
1449
|
+
if (added === undefined)
|
|
1450
|
+
continue;
|
|
1451
|
+
// written as the COPY's columns, whatever the sources file says now: the stream decoded them so (`refreshStreamed`)
|
|
1452
|
+
const t = this.asCopied(added);
|
|
1453
|
+
const target = `${ident(t.schema)}.${ident(t.table)}`;
|
|
1454
|
+
const types = await this.columnTypes(t, target);
|
|
1455
|
+
const cols = t.columns.map((c) => c.name);
|
|
1456
|
+
const exact = await this.exactOf(t);
|
|
1457
|
+
const declared = (c) => NUMERIC_TYPE.exec(t.columns.find((x) => x.name === c)?.type ?? "")?.[1] !== undefined;
|
|
1458
|
+
const value = (c, v) => {
|
|
1459
|
+
const scale = exact.get(c);
|
|
1460
|
+
if (scale !== undefined && v !== null && widerThan(v, scale, declared(c)))
|
|
1461
|
+
throw new Error(`${WIDER} [${name}]: ${c} = ${v}`);
|
|
1462
|
+
return duckValue(v, types.get(c));
|
|
1463
|
+
};
|
|
1464
|
+
if (changes.truncated)
|
|
1465
|
+
await w.run(`DELETE FROM ${target}`);
|
|
1466
|
+
const whole = [];
|
|
1467
|
+
const fetch = [];
|
|
1468
|
+
const gone = [];
|
|
1469
|
+
// the rows an UPDATE left a large value unchanged in, by the columns they carry: one UPDATE for each set
|
|
1470
|
+
const partial = new Map();
|
|
1471
|
+
for (const { key, change } of changes.keys.values()) {
|
|
1472
|
+
if (change.kind === "delete") {
|
|
1473
|
+
gone.push(key);
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
if (change.refetch) {
|
|
1477
|
+
fetch.push(key);
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (!change.values.includes(undefined)) {
|
|
1481
|
+
whole.push(change.values);
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
const carried = cols.filter((_, i) => change.values[i] !== undefined).join("\0");
|
|
1485
|
+
const same = partial.get(carried) ?? [];
|
|
1486
|
+
partial.set(carried, same);
|
|
1487
|
+
same.push({ key, values: change.values });
|
|
1488
|
+
}
|
|
1489
|
+
for (const [carried, rows] of partial)
|
|
1490
|
+
fetch.push(...await this.updateCarried(t, target, carried.split("\0"), rows, value, types));
|
|
1491
|
+
if (fetch.length) {
|
|
1492
|
+
source ??= await PgConnection.connect(this.pgOptions());
|
|
1493
|
+
whole.push(...await this.sourceRows(source, t, fetch));
|
|
1494
|
+
}
|
|
1495
|
+
await this.writeByKey(t, target, gone, whole, value);
|
|
1496
|
+
}
|
|
1497
|
+
if (lsn !== null)
|
|
1498
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.stream_state VALUES ($1, $2, now())`, [this.replication.slot, formatLsn(lsn)]);
|
|
1499
|
+
await w.run("COMMIT");
|
|
1500
|
+
}
|
|
1501
|
+
catch (e) {
|
|
1502
|
+
await w.run("ROLLBACK");
|
|
1503
|
+
throw e;
|
|
1504
|
+
}
|
|
1505
|
+
finally {
|
|
1506
|
+
source?.close();
|
|
1507
|
+
}
|
|
1508
|
+
if (lsn !== null)
|
|
1509
|
+
this.appliedLsn = lsn;
|
|
1510
|
+
this.streamFailures = 0;
|
|
1511
|
+
let moved = false;
|
|
1512
|
+
for (const [name, changes] of batch) {
|
|
1513
|
+
if (!this.tables.has(name))
|
|
1514
|
+
continue;
|
|
1515
|
+
for (const into of [this.appliedCounts, this.sinceLine]) {
|
|
1516
|
+
const c = into.get(name) ?? noCounts();
|
|
1517
|
+
addCounts(c, changes.counts);
|
|
1518
|
+
into.set(name, c);
|
|
1519
|
+
}
|
|
1520
|
+
moved = true;
|
|
1521
|
+
}
|
|
1522
|
+
if (moved && committedAt !== null && committedAt.getTime() > 0)
|
|
1523
|
+
this.lastBatch = { appliedAt: Date.now(), committedAt: committedAt.getTime() };
|
|
1524
|
+
// the ledger's row counts, for mason status: each changed table counted at most every 5 s
|
|
1525
|
+
for (const name of batch.keys()) {
|
|
1526
|
+
const st = this.states.get(name);
|
|
1527
|
+
if (st === undefined || Date.now() - (this.countedAt.get(name) ?? 0) < 5_000)
|
|
1528
|
+
continue;
|
|
1529
|
+
const t = this.tables.get(name);
|
|
1530
|
+
const rows = Number((await w.runAndReadAll(`SELECT count(*) FROM ${ident(t.schema)}.${ident(t.table)}`)).getRowsJS()[0][0]);
|
|
1531
|
+
this.countedAt.set(name, Date.now());
|
|
1532
|
+
if (rows !== st.rows || st.error !== null)
|
|
1533
|
+
await this.record({ ...st, rows, error: null });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Rows an UPDATE left a large value unchanged in, every one carrying the same columns (`carried`, the key among them):
|
|
1538
|
+
* those columns set by ONE `UPDATE … FROM` the rows' keys, in the caller's transaction — with no index an UPDATE by key
|
|
1539
|
+
* is a scan of the key column, so never a statement per row (measured on DuckDB 2.0.0: 10,000 such rows
|
|
1540
|
+
* into 195 M uuid keys at 2.7 GiB took 1.5–1.8 s). Answers the keys DuckDB holds no row of, read whole from the source.
|
|
1541
|
+
*/
|
|
1542
|
+
async updateCarried(t, target, carried, rows, value, types) {
|
|
1543
|
+
const w = this.store.writer;
|
|
1544
|
+
const cols = t.columns.map((c) => c.name);
|
|
1545
|
+
const set = carried.filter((c) => !t.key.includes(c));
|
|
1546
|
+
await w.run(`CREATE OR REPLACE TEMP TABLE ${WRITE_PARTIAL} (${ident(WRITE_ROW)} INTEGER, ${carried.map((c) => `${ident(c)} ${types.get(c)}`).join(", ")})`);
|
|
1547
|
+
for (let i = 0; i < rows.length; i += 500) {
|
|
1548
|
+
await w.run(`INSERT INTO ${WRITE_PARTIAL} VALUES ${rows.slice(i, i + 500).map((r, j) => `(${i + j}, ${carried.map((c) => value(c, r.values[cols.indexOf(c)] ?? null)).join(", ")})`).join(", ")}`);
|
|
1549
|
+
}
|
|
1550
|
+
const on = t.key.map((k) => `t.${ident(k)} = p.${ident(k)}`).join(" AND ");
|
|
1551
|
+
// a stored JSON path of a JSON column it carries, computed from the new value (one it does not carry is unchanged)
|
|
1552
|
+
const assign = [...set.map((c) => `${ident(c)} = p.${ident(c)}`), ...this.kept.storedSet(t.source, set, (c) => `p.${ident(c)}`)];
|
|
1553
|
+
const matched = set.length === 0 ? 0 : Number((await w.run(`UPDATE ${target} AS t SET ${assign.join(", ")} FROM ${WRITE_PARTIAL} p WHERE ${on}`)).rowsChanged);
|
|
1554
|
+
// only when some row was not there: another scan, to say which
|
|
1555
|
+
const missing = matched === rows.length ? [] : (await w.runAndReadAll(`SELECT p.${ident(WRITE_ROW)} FROM ${WRITE_PARTIAL} p ANTI JOIN ${target} t ON ${on}`)).getRowsJS().map((r) => Number(r[0]));
|
|
1556
|
+
await w.run(`DROP TABLE ${WRITE_PARTIAL}`);
|
|
1557
|
+
return missing.map((i) => rows[i].key);
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* THE STREAM'S WRITE BY KEY, IN THE CALLER'S TRANSACTION: the deleted keys and the whole rows' keys deleted by ONE
|
|
1561
|
+
* join against the table's key column (`deleteKeys`), then the rows inserted. No copy has a primary key,
|
|
1562
|
+
* so there is no `INSERT OR REPLACE`; a key's row is the source's at the batch's end
|
|
1563
|
+
* whatever the table held, as replaying needs (src/duck/stream.ts). A table with no key (none can be added:
|
|
1564
|
+
* src/cli/preflight.ts) is only appended to.
|
|
1565
|
+
*/
|
|
1566
|
+
async writeByKey(t, target, gone, whole, value) {
|
|
1567
|
+
if (gone.length === 0 && whole.length === 0)
|
|
1568
|
+
return;
|
|
1569
|
+
const w = this.store.writer;
|
|
1570
|
+
const cols = t.columns.map((c) => c.name);
|
|
1571
|
+
const list = cols.map(ident).join(", ");
|
|
1572
|
+
if (whole.length) {
|
|
1573
|
+
await w.run(`CREATE OR REPLACE TEMP TABLE ${WRITE_ROWS} AS SELECT ${list} FROM ${target} LIMIT 0`);
|
|
1574
|
+
for (let i = 0; i < whole.length; i += 500) {
|
|
1575
|
+
await w.run(`INSERT INTO ${WRITE_ROWS} VALUES ${whole.slice(i, i + 500).map((v) => `(${v.map((x, k) => value(cols[k], x)).join(", ")})`).join(", ")}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (t.key.length > 0) {
|
|
1579
|
+
const keys = t.key.map(ident).join(", ");
|
|
1580
|
+
await w.run(`CREATE OR REPLACE TEMP TABLE ${WRITE_KEYS} AS SELECT ${keys} FROM ${target} LIMIT 0`);
|
|
1581
|
+
for (let i = 0; i < gone.length; i += 500) {
|
|
1582
|
+
await w.run(`INSERT INTO ${WRITE_KEYS} VALUES ${gone.slice(i, i + 500).map((k) => `(${t.key.map((c, j) => value(c, k[j] ?? null)).join(", ")})`).join(", ")}`);
|
|
1583
|
+
}
|
|
1584
|
+
if (whole.length)
|
|
1585
|
+
await w.run(`INSERT INTO ${WRITE_KEYS} SELECT ${keys} FROM ${WRITE_ROWS}`);
|
|
1586
|
+
await deleteKeys(w, t, target, WRITE_KEYS, t.key);
|
|
1587
|
+
await w.run(`DROP TABLE ${WRITE_KEYS}`);
|
|
1588
|
+
}
|
|
1589
|
+
if (whole.length) {
|
|
1590
|
+
// and its stored JSON paths, computed from the rows' JSON (src/duck/kept.ts)
|
|
1591
|
+
const stored = this.kept.stored(t.source, ident);
|
|
1592
|
+
await w.run(`INSERT INTO ${target} (${[list, ...stored.names.map(ident)].join(", ")}) SELECT ${[list, ...stored.values].join(", ")} FROM ${WRITE_ROWS}`);
|
|
1593
|
+
await w.run(`DROP TABLE ${WRITE_ROWS}`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/** a copied table's columns' DuckDB types, as DESCRIBE spells them (what `duckValue` casts a value's text to) */
|
|
1597
|
+
async columnTypes(t, target) {
|
|
1598
|
+
let m = this.duckTypes.get(t.source);
|
|
1599
|
+
if (m === undefined) {
|
|
1600
|
+
m = new Map((await this.store.writer.runAndReadAll(`DESCRIBE ${target}`)).getRowsJS().map((r) => [String(r[0]), String(r[1])]));
|
|
1601
|
+
this.duckTypes.set(t.source, m);
|
|
1602
|
+
}
|
|
1603
|
+
return m;
|
|
1604
|
+
}
|
|
1605
|
+
/** rows read from the source by key, each column as its text (as the stream sends it); a key it no longer has is
|
|
1606
|
+
* left out (its delete is in the stream) */
|
|
1607
|
+
async sourceRows(pg, t, keys) {
|
|
1608
|
+
const out = [];
|
|
1609
|
+
for (let i = 0; i < keys.length; i += 500) {
|
|
1610
|
+
const chunk = keys.slice(i, i + 500);
|
|
1611
|
+
// the key's values as untyped literals: Postgres reads each as its column's type, so the primary key's index is used
|
|
1612
|
+
const tuple = (k) => `(${k.map((v) => v === null ? "NULL" : pgLiteral(v)).join(", ")})`;
|
|
1613
|
+
const where = t.key.length === 1 ? `${pgIdent(t.key[0])} IN (${chunk.map((k) => k[0] === null ? "NULL" : pgLiteral(k[0])).join(", ")})`
|
|
1614
|
+
: `(${t.key.map(pgIdent).join(", ")}) IN (${chunk.map(tuple).join(", ")})`;
|
|
1615
|
+
const r = await pg.query(`SELECT ${t.columns.map((c) => `${pgIdent(c.name)}::text`).join(", ")} FROM ${pgIdent(t.schema)}.${pgIdent(t.table)} WHERE ${where}`);
|
|
1616
|
+
out.push(...r.rows);
|
|
1617
|
+
}
|
|
1618
|
+
return out;
|
|
1619
|
+
}
|
|
1620
|
+
/** for a test or a wait: resolves once every change the source had committed when it was called is in DuckDB, in every
|
|
1621
|
+
* table the stream keeps current — a table still copying is not waited for, one copied and not yet caught up is */
|
|
1622
|
+
async caughtUp(timeoutMs = 30_000) {
|
|
1623
|
+
while (this.running !== null)
|
|
1624
|
+
await this.running;
|
|
1625
|
+
const pg = await PgConnection.connect(this.pgOptions());
|
|
1626
|
+
let target;
|
|
1627
|
+
try {
|
|
1628
|
+
target = await currentLsn(pg);
|
|
1629
|
+
}
|
|
1630
|
+
finally {
|
|
1631
|
+
pg.close();
|
|
1632
|
+
}
|
|
1633
|
+
const until = Date.now() + timeoutMs;
|
|
1634
|
+
for (;;) {
|
|
1635
|
+
const s = this.stream;
|
|
1636
|
+
const behind = [...this.behind.keys()].filter((x) => this.tables.has(x));
|
|
1637
|
+
if (s !== null && s.position() >= target && behind.length === 0)
|
|
1638
|
+
return;
|
|
1639
|
+
if (Date.now() > until)
|
|
1640
|
+
throw new Error(`the stream did not reach ${formatLsn(target)} in ${timeoutMs} ms (${s === null ? "no stream is running" : `at ${formatLsn(s.position())}`}${behind.length ? `; not caught up yet: ${behind.join(", ")}` : ""})`);
|
|
1641
|
+
s?.poke();
|
|
1642
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* A long statement on a lane's connection (or several, none of them a COMMIT, ROLLBACK or DETACH), which `stop`
|
|
1647
|
+
* interrupts: never started once the sync is stopping, and its interrupt then thrown as `Stopping`. The flag is
|
|
1648
|
+
* cleared before the caller's next statement is issued (one thread), so an interrupt never lands on a rollback; one
|
|
1649
|
+
* sent between two statements is cleared by DuckDB when the next starts. An interrupt is the connection's own: the
|
|
1650
|
+
* rebuild's never reaches the writer's statement, nor the writer's the rebuild's.
|
|
1651
|
+
*/
|
|
1652
|
+
async interruptible(lane, f) {
|
|
1653
|
+
if (this.stopped)
|
|
1654
|
+
throw new Stopping();
|
|
1655
|
+
lane.inFlight = true;
|
|
1656
|
+
try {
|
|
1657
|
+
return await f();
|
|
1658
|
+
}
|
|
1659
|
+
catch (e) {
|
|
1660
|
+
throw this.stopped && e instanceof Error && /^INTERRUPT Error/.test(e.message) ? new Stopping() : e;
|
|
1661
|
+
}
|
|
1662
|
+
finally {
|
|
1663
|
+
lane.inFlight = false;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
/** the source attached for one more user (a pass, a rebuild), on the writer's turn: ATTACHed by the first. A user
|
|
1667
|
+
* whose attach throws is not one: it never detaches */
|
|
1668
|
+
async attach() {
|
|
1669
|
+
if (!this.attached) {
|
|
1670
|
+
// INSTALL fetches the extension from DuckDB's repository the first time on a machine (then ~/.duckdb/extensions):
|
|
1671
|
+
// offline, the first copy fails here saying so — bundling it is the plan's step 3
|
|
1672
|
+
if (!this.loaded) {
|
|
1673
|
+
try {
|
|
1674
|
+
await this.store.writer.run("INSTALL postgres; LOAD postgres;");
|
|
1675
|
+
}
|
|
1676
|
+
catch (e) {
|
|
1677
|
+
throw new Error(`DuckDB could not load its postgres extension: ${e.message}. The first copy on a machine downloads it from ` +
|
|
1678
|
+
"DuckDB's extension repository (then ~/.duckdb/extensions): connect to the internet once, and the copy is tried again at the next pass");
|
|
1679
|
+
}
|
|
1680
|
+
this.loaded = true;
|
|
1681
|
+
}
|
|
1682
|
+
await this.store.writer.run(`ATTACH ${literal(libpqString(this.source))} AS ${SRC} (TYPE postgres, READ_ONLY)`);
|
|
1683
|
+
this.attached = true;
|
|
1684
|
+
}
|
|
1685
|
+
this.sourceUsers++;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* THE SOURCE IS ATTACHED ONLY WHILE A PASS OR A REBUILD READS IT. An attached database is every connection's — a wire
|
|
1689
|
+
* session's too, and the rebuilds' — and its path is the connection string, password included (`duckdb_databases()`).
|
|
1690
|
+
* A session runs only planned SQL (src/duck/plan.ts PlannedSql), which cannot name it; detached when its last user
|
|
1691
|
+
* ends (on the writer's turn, so never under a transaction reading it), it is not there to name either.
|
|
1692
|
+
*/
|
|
1693
|
+
async detach() {
|
|
1694
|
+
this.sourceUsers = Math.max(0, this.sourceUsers - 1);
|
|
1695
|
+
if (this.sourceUsers > 0 || !this.attached)
|
|
1696
|
+
return;
|
|
1697
|
+
try {
|
|
1698
|
+
await this.store.writer.run(`DETACH ${SRC}`);
|
|
1699
|
+
this.attached = false;
|
|
1700
|
+
}
|
|
1701
|
+
// the pass's work stands; still attached, the next pass detaches it again (and attach() sees it is attached)
|
|
1702
|
+
catch (e) {
|
|
1703
|
+
this.log(`the source could not be detached after the pass (tried again after the next): ${e instanceof Error ? e.message : String(e)}`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
/** one pass: every removed table dropped, every added table needing it queued for a copy (step 0: whole, again) or
|
|
1707
|
+
* a catch-up (`enqueue`, run beside), and in poll mode every table kept current polled */
|
|
1708
|
+
async pass() {
|
|
1709
|
+
// a table no longer added (mason remove) is dropped, and its ledger rows with it — one whose rebuild is running at
|
|
1710
|
+
// the pass after that rebuild ends (its swap would make it again)
|
|
1711
|
+
for (const source of [...this.states.keys()].filter((s) => !this.tables.has(s) && this.rebuildingNow?.source !== s)) {
|
|
1712
|
+
const [schema, table] = [source.slice(0, source.indexOf(".")), source.slice(source.indexOf(".") + 1)];
|
|
1713
|
+
this.queued.delete(source);
|
|
1714
|
+
await this.onWriter(async () => {
|
|
1715
|
+
const w = this.store.writer;
|
|
1716
|
+
await w.run(`DROP TABLE IF EXISTS ${ident(schema)}.${ident(table)}`);
|
|
1717
|
+
// and what a copy of it that a crash cut short left beside it (`copy`)
|
|
1718
|
+
await w.run(`DROP TABLE IF EXISTS ${ident(schema)}.${ident(`${table}${SIDE_SUFFIX}`)}`);
|
|
1719
|
+
for (const ledger of ["copy_state", "poll_state", "catch_up", "copy_progress", "copy_chunks"])
|
|
1720
|
+
await w.run(`DELETE FROM ${MASON_SCHEMA}.${ledger} WHERE source = ${literal(source)}`);
|
|
1721
|
+
// its stored JSON paths and its view
|
|
1722
|
+
await this.kept.removed(source);
|
|
1723
|
+
this.behind.delete(source);
|
|
1724
|
+
this.held.delete(source);
|
|
1725
|
+
this.refreshStreamed();
|
|
1726
|
+
});
|
|
1727
|
+
this.snapshots.delete(source);
|
|
1728
|
+
this.retries.delete(source);
|
|
1729
|
+
this.retired.delete(source);
|
|
1730
|
+
this.copiedAs.delete(source);
|
|
1731
|
+
this.copiedColumns.delete(source);
|
|
1732
|
+
this.states.delete(source);
|
|
1733
|
+
this.log(`dropped ${source}'s copy: it is no longer added (mason add ${source} copies it again from the source, whole)`);
|
|
1734
|
+
}
|
|
1735
|
+
if (this.tables.size === 0) {
|
|
1736
|
+
await this.rebuildSchema();
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (this.mode === "replication") {
|
|
1740
|
+
try {
|
|
1741
|
+
await this.prepareReplication();
|
|
1742
|
+
}
|
|
1743
|
+
catch (e) {
|
|
1744
|
+
const m = `replication: ${e instanceof Error ? e.message : String(e)}`;
|
|
1745
|
+
// a table copied before keeps serving its copy; one never copied is failed, so mason status says why
|
|
1746
|
+
await this.onWriter(async () => {
|
|
1747
|
+
for (const t of this.tables.values()) {
|
|
1748
|
+
const st = this.states.get(t.source);
|
|
1749
|
+
await this.record({ ...st, phase: st.phase === "copied" ? "copied" : "failed", error: m });
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
await this.rebuildSchema();
|
|
1753
|
+
throw e;
|
|
1754
|
+
}
|
|
1755
|
+
// the stream starts once the slot is known, and runs from then on whatever the passes and rebuilds do
|
|
1756
|
+
this.startStream();
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
await this.onWriter(() => this.attach());
|
|
1760
|
+
}
|
|
1761
|
+
catch (e) {
|
|
1762
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
1763
|
+
// a table copied before keeps serving its copy (and its place in the catalog): the source being away is its error,
|
|
1764
|
+
// not a reason to copy it whole again when the source is back; one never copied is failed
|
|
1765
|
+
await this.onWriter(async () => {
|
|
1766
|
+
for (const t of this.tables.values()) {
|
|
1767
|
+
const st = this.states.get(t.source);
|
|
1768
|
+
await this.record({ ...st, phase: st.phase === "copied" ? "copied" : "failed", error: `could not attach the database: ${m}` });
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
// the copies serve while the source is away: the catalog lists them (on a fresh start, nothing else has)
|
|
1772
|
+
await this.rebuildSchema();
|
|
1773
|
+
throw new Error(`DuckDB could not attach ${this.source.database} on ${this.source.host}:${this.source.port}: ${m}`);
|
|
1774
|
+
}
|
|
1775
|
+
try {
|
|
1776
|
+
await this.work();
|
|
1777
|
+
}
|
|
1778
|
+
finally {
|
|
1779
|
+
await this.onWriter(() => this.detach());
|
|
1780
|
+
}
|
|
1781
|
+
await this.rebuildSchema();
|
|
1782
|
+
}
|
|
1783
|
+
/** a pass's queueing and polls, the source attached */
|
|
1784
|
+
async work() {
|
|
1785
|
+
const started = Date.now();
|
|
1786
|
+
// a table never copied (or whose copy failed, or whose copy is out of date) is copied whole, beside the rest: the
|
|
1787
|
+
// smallest first (`copyOrder`)
|
|
1788
|
+
for (const t of copyOrder([...this.tables.values()])) {
|
|
1789
|
+
if (this.stopped)
|
|
1790
|
+
return;
|
|
1791
|
+
// a failed copy waits its turn (`copyRetryDelayMs`); the error it recorded says when
|
|
1792
|
+
if ((this.retries.get(t.source)?.at ?? 0) > Date.now())
|
|
1793
|
+
continue;
|
|
1794
|
+
// a table the named publication does not carry: copied, nothing would keep it current (prepareReplication)
|
|
1795
|
+
if (this.unpublished.has(t.source))
|
|
1796
|
+
continue;
|
|
1797
|
+
if (this.mode !== "recopy" && !this.needsCopy(t))
|
|
1798
|
+
continue;
|
|
1799
|
+
if (this.queued.has(t.source) || this.rebuildingNow?.source === t.source)
|
|
1800
|
+
continue;
|
|
1801
|
+
if (this.states.get(t.source)?.phase === "copied" && this.copiedAs.get(t.source) !== columnsKey(t.columns))
|
|
1802
|
+
this.log(`${t.source}: its columns are not the ones it was copied with: it is copied again`);
|
|
1803
|
+
this.enqueue(t.source, { kind: "copy" });
|
|
1804
|
+
}
|
|
1805
|
+
// a table copied and not yet caught up (a slot new to the copy, or a run that stopped first): its one poll
|
|
1806
|
+
for (const source of this.behind.keys()) {
|
|
1807
|
+
const t = this.tables.get(source);
|
|
1808
|
+
// a failed catch-up waits its turn as a failed copy does (`backOff`); the error it recorded says when
|
|
1809
|
+
if (t !== undefined && !this.needsCopy(t) && (this.retries.get(source)?.at ?? 0) <= Date.now())
|
|
1810
|
+
this.enqueue(source, { kind: "catch-up" });
|
|
1811
|
+
}
|
|
1812
|
+
// a table whose new columns could not be copied (its copy failed above) is not polled with them
|
|
1813
|
+
const polled = [...this.tables.values()].filter((t) => !this.needsCopy(t));
|
|
1814
|
+
// POLL MODE: a table over the row limit is served as copied and polled only when asked (POLL_ROW_LIMIT, pollNow)
|
|
1815
|
+
const big = this.mode === "poll" ? polled.filter(overPollLimit) : [];
|
|
1816
|
+
for (const t of big.filter((x) => !this.leftOut.has(x.source))) {
|
|
1817
|
+
this.log(`${t.source} (about ${t.rows.toLocaleString("en-US")} rows) is ${OVER_POLL_LIMIT}: it is served as copied, NOT kept current by the poll ` +
|
|
1818
|
+
`(each poll would scan it whole on the database) — mason sync ${t.source} syncs it now; mason start --replication keeps every table current`);
|
|
1819
|
+
this.leftOut.add(t.source);
|
|
1820
|
+
}
|
|
1821
|
+
for (const s of [...this.leftOut].filter((x) => !big.some((t) => t.source === x)))
|
|
1822
|
+
this.leftOut.delete(s);
|
|
1823
|
+
const kept = this.mode === "poll" ? polled.filter((t) => !big.includes(t)) : [];
|
|
1824
|
+
for (const t of kept) {
|
|
1825
|
+
if (this.stopped)
|
|
1826
|
+
break;
|
|
1827
|
+
// A TRANSACTION PER TABLE, on the writer's turn: one table's slow scan holds no other, and a batch of the stream
|
|
1828
|
+
// or a swap may run between two. A table rebuilt or not yet caught up is not polled (decided on the turn itself);
|
|
1829
|
+
// its catch-up polls it
|
|
1830
|
+
try {
|
|
1831
|
+
await this.onWriter(async () => { if (this.rebuildingNow?.source !== t.source && !this.behind.has(t.source))
|
|
1832
|
+
await this.poll(this.writerLane, [t]); });
|
|
1833
|
+
}
|
|
1834
|
+
catch (e) {
|
|
1835
|
+
if (e instanceof Stopping) {
|
|
1836
|
+
this.log("poll stopped: Mason is stopping (rolled back; the copies serve as they were)");
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
// the table serves its copy as it was, and says why it is not current (mason status reads the ledger); the other
|
|
1840
|
+
// tables are polled as ever
|
|
1841
|
+
const m = `the poll failed, and is tried again at the next one (the copy serves as it was): ${e instanceof Error ? e.message : String(e)}`;
|
|
1842
|
+
this.log(`${t.source}: ${m}`);
|
|
1843
|
+
await this.onWriter(() => this.record({ ...this.states.get(t.source), error: m }));
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
if (this.mode === "poll")
|
|
1847
|
+
await this.onWriter(() => this.readPolledAt());
|
|
1848
|
+
this.lastPassMs = Date.now() - started;
|
|
1849
|
+
}
|
|
1850
|
+
/** a rebuild queued, unless that table's is queued or running already; the queue started if idle */
|
|
1851
|
+
enqueue(source, r) {
|
|
1852
|
+
if (this.stopped || this.queued.has(source) || this.rebuildingNow?.source === source)
|
|
1853
|
+
return;
|
|
1854
|
+
this.queued.set(source, r);
|
|
1855
|
+
this.drainRebuilds();
|
|
1856
|
+
}
|
|
1857
|
+
/** THE REBUILD QUEUE, one at a time on the rebuilds' connection: DuckDB's memory limit is one. Each rebuild says its
|
|
1858
|
+
* own failure; one that throws past that is a bug, said */
|
|
1859
|
+
drainRebuilds() {
|
|
1860
|
+
if (this.rebuilding !== null || this.stopped)
|
|
1861
|
+
return;
|
|
1862
|
+
this.rebuilding = (async () => {
|
|
1863
|
+
while (!this.stopped) {
|
|
1864
|
+
// a catch-up first, then the smallest copy (`copyOrder`): a table queued while a big one copied goes next
|
|
1865
|
+
const size = new Map(copyOrder([...this.queued.keys()].flatMap((s) => this.tables.get(s) ?? [])).map((t, i) => [t.source, i]));
|
|
1866
|
+
const next = [...this.queued].sort(([sa, a], [sb, b]) => REBUILD_ORDER.indexOf(a.kind) - REBUILD_ORDER.indexOf(b.kind) || (size.get(sa) ?? 0) - (size.get(sb) ?? 0))[0];
|
|
1867
|
+
if (next === undefined)
|
|
1868
|
+
return;
|
|
1869
|
+
this.queued.delete(next[0]);
|
|
1870
|
+
const t = this.tables.get(next[0]);
|
|
1871
|
+
// a catch-up that found a numeric wider than the copy: copied again, next
|
|
1872
|
+
if (t !== undefined && await this.rebuild(t, next[1]) === "copy again")
|
|
1873
|
+
this.enqueue(t.source, { kind: "copy" });
|
|
1874
|
+
// still needing a copy or a catch-up: a pass now, or when its back-off ends
|
|
1875
|
+
else
|
|
1876
|
+
this.followUp(next[0]);
|
|
1877
|
+
// removed while it was rebuilt: the pass that removal kicked left it for this one to drop
|
|
1878
|
+
if (!this.tables.has(next[0]) && this.states.has(next[0]))
|
|
1879
|
+
this.kick();
|
|
1880
|
+
}
|
|
1881
|
+
})().catch((e) => {
|
|
1882
|
+
this.log(`a rebuild failed outside its own handling (Mason bug: report it): ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
|
1883
|
+
}).finally(() => {
|
|
1884
|
+
this.rebuilding = null;
|
|
1885
|
+
// what was queued behind a rebuild that threw is still queued
|
|
1886
|
+
if (!this.stopped && this.queued.size > 0)
|
|
1887
|
+
this.drainRebuilds();
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* ONE REBUILD, beside the stream and the polls: the table marked as rebuilt on the writer's turn (from then on no poll
|
|
1892
|
+
* starts on it), then its copy on the rebuilds' connection, then — a table left behind by it — its catch-up.
|
|
1893
|
+
*/
|
|
1894
|
+
async rebuild(t, r) {
|
|
1895
|
+
await this.onWriter(async () => {
|
|
1896
|
+
this.rebuildingNow = { source: t.source, kind: r.kind };
|
|
1897
|
+
this.refreshStreamed();
|
|
1898
|
+
});
|
|
1899
|
+
let attached = false;
|
|
1900
|
+
try {
|
|
1901
|
+
try {
|
|
1902
|
+
await this.onWriter(() => this.attach());
|
|
1903
|
+
attached = true;
|
|
1904
|
+
}
|
|
1905
|
+
catch (e) {
|
|
1906
|
+
// the next pass says the same of every table, and queues it again
|
|
1907
|
+
const m = `could not attach the database: ${e instanceof Error ? e.message : String(e)}`;
|
|
1908
|
+
this.log(`${r.kind} of ${t.source} waits: ${m}`);
|
|
1909
|
+
const st = this.states.get(t.source);
|
|
1910
|
+
await this.onWriter(() => this.record({ ...st, phase: st.phase === "copied" ? "copied" : "failed", error: m }));
|
|
1911
|
+
return null;
|
|
1912
|
+
}
|
|
1913
|
+
if (r.kind === "copy")
|
|
1914
|
+
await this.copy(t);
|
|
1915
|
+
// a table its copy left behind (`swap`), or one marked so before: one poll, unless a copy of it comes first — the
|
|
1916
|
+
// table as it is added NOW, which may have changed while it was copied (then copied again at once: `followUp`)
|
|
1917
|
+
const now = this.tables.get(t.source);
|
|
1918
|
+
if (now !== undefined && this.behind.has(t.source) && !this.needsCopy(now) && !this.stopped)
|
|
1919
|
+
return await this.catchUp(now);
|
|
1920
|
+
// not caught up: nothing is held for it (its next copy's swap holds anew)
|
|
1921
|
+
if (this.held.has(t.source))
|
|
1922
|
+
await this.onWriter(async () => { this.held.delete(t.source); this.refreshStreamed(); });
|
|
1923
|
+
return null;
|
|
1924
|
+
}
|
|
1925
|
+
finally {
|
|
1926
|
+
await this.onWriter(async () => {
|
|
1927
|
+
if (attached)
|
|
1928
|
+
await this.detach();
|
|
1929
|
+
this.rebuildingNow = null;
|
|
1930
|
+
this.refreshStreamed();
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* A TABLE CAUGHT UP BY ONE POLL FROM ITS WATERMARK before the polls or the stream keep it current again — after its
|
|
1936
|
+
* copy (the copy's snapshot), or a slot new to the copy — on the rebuilds'
|
|
1937
|
+
* connection, so the stream and the other tables' polls go on meanwhile. In replication mode the stream holds the
|
|
1938
|
+
* table's changes from before the poll's snapshot and applies them after it (src/duck/stream.ts "why replaying is
|
|
1939
|
+
* safe"). A table over POLL_ROW_LIMIT is caught up too: the one scan its rebuild costs, not the periodic poll.
|
|
1940
|
+
*/
|
|
1941
|
+
async catchUp(t) {
|
|
1942
|
+
const mark = this.behind.get(t.source);
|
|
1943
|
+
const replication = this.mode === "replication";
|
|
1944
|
+
// J: from the writer's turn on, every batch's changes of this table are held (`applyStreamed`)
|
|
1945
|
+
if (replication)
|
|
1946
|
+
await this.onWriter(async () => { if (!this.held.has(t.source))
|
|
1947
|
+
this.held.set(t.source, noChanges()); this.refreshStreamed(); });
|
|
1948
|
+
const release = () => this.onWriter(async () => { this.held.delete(t.source); this.refreshStreamed(); });
|
|
1949
|
+
let found;
|
|
1950
|
+
const started = Date.now();
|
|
1951
|
+
this.catchingUp = { source: t.source, phase: "polling", startedAt: started };
|
|
1952
|
+
// A FAILED CATCH-UP waits as a failed copy does (`backOff`), said whole with its next attempt — in the log and in
|
|
1953
|
+
// mason status — and is caught up again then: what was held is read again by that poll (still behind, nothing lost)
|
|
1954
|
+
const failed = (what, e) => {
|
|
1955
|
+
const when = this.backOff(t.source, Date.now() - started);
|
|
1956
|
+
const m = `${what} (the copy serves as it was; it is caught up by one poll again after ${when}): ${e instanceof Error ? e.message : String(e)}`;
|
|
1957
|
+
this.log(`${t.source}: ${m}`);
|
|
1958
|
+
return this.onWriter(() => this.record({ ...this.states.get(t.source), error: m }));
|
|
1959
|
+
};
|
|
1960
|
+
// the catch-up's poll reads the columns the copy holds (`asCopied`)
|
|
1961
|
+
try {
|
|
1962
|
+
found = await this.poll(this.rebuilder, [this.asCopied(t)]);
|
|
1963
|
+
}
|
|
1964
|
+
catch (e) {
|
|
1965
|
+
this.catchingUp = null;
|
|
1966
|
+
await release();
|
|
1967
|
+
if (e instanceof Stopping) {
|
|
1968
|
+
this.log(`catch-up of ${t.source} stopped: Mason is stopping (rolled back; it is caught up by one poll at the next start)`);
|
|
1969
|
+
return null;
|
|
1970
|
+
}
|
|
1971
|
+
await failed("its catch-up poll failed", e);
|
|
1972
|
+
return null;
|
|
1973
|
+
}
|
|
1974
|
+
// a numeric wider than its copy: the poll said so, and the table is copied again (and caught up after that copy)
|
|
1975
|
+
if (found.length === 0) {
|
|
1976
|
+
this.catchingUp = null;
|
|
1977
|
+
await release();
|
|
1978
|
+
return "copy again";
|
|
1979
|
+
}
|
|
1980
|
+
let saidJoin = false;
|
|
1981
|
+
if (this.catchingUp?.source === t.source)
|
|
1982
|
+
this.catchingUp.phase = "applying";
|
|
1983
|
+
let heldFailure = null;
|
|
1984
|
+
const joined = await this.onWriter(async () => {
|
|
1985
|
+
this.catchingUp = null;
|
|
1986
|
+
const held = this.held.get(t.source);
|
|
1987
|
+
this.held.delete(t.source);
|
|
1988
|
+
if (held !== undefined && (held.truncated || held.keys.size > 0)) {
|
|
1989
|
+
try {
|
|
1990
|
+
await this.applyChanges(new Map([[t.source, held]]), this.appliedLsn);
|
|
1991
|
+
}
|
|
1992
|
+
catch (e) {
|
|
1993
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
1994
|
+
this.refreshStreamed();
|
|
1995
|
+
// a numeric wider than its copy: copied again (`needsCopy`), and caught up after that copy
|
|
1996
|
+
if (m.includes(`${WIDER} [${t.source}]`)) {
|
|
1997
|
+
this.snapshots.delete(t.source);
|
|
1998
|
+
this.log(`${t.source}: the changes the stream held during its catch-up could not be applied: it is copied again, wider (${m})`);
|
|
1999
|
+
return "copy again";
|
|
2000
|
+
}
|
|
2001
|
+
heldFailure = e;
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
// caught up: a failure's back-off and its words are the past
|
|
2006
|
+
this.retries.delete(t.source);
|
|
2007
|
+
const st = this.states.get(t.source);
|
|
2008
|
+
if (st !== undefined && st.error !== null)
|
|
2009
|
+
await this.record({ ...st, error: null });
|
|
2010
|
+
// a slot made again meanwhile marked it anew: caught up once more
|
|
2011
|
+
if (this.behind.get(t.source) === mark) {
|
|
2012
|
+
await this.store.writer.run(`DELETE FROM ${MASON_SCHEMA}.catch_up WHERE source = ${literal(t.source)}`);
|
|
2013
|
+
this.behind.delete(t.source);
|
|
2014
|
+
}
|
|
2015
|
+
// ITS JOIN, said with the position it joins at (`refreshStreamed`), on the line that says it was caught up
|
|
2016
|
+
this.joinWhy.set(t.source, `caught up ${t.source} by one poll ${mark.why}`);
|
|
2017
|
+
saidJoin = this.refreshStreamed().includes(t.source);
|
|
2018
|
+
await this.readPolledAt();
|
|
2019
|
+
return true;
|
|
2020
|
+
});
|
|
2021
|
+
if (heldFailure !== null)
|
|
2022
|
+
await failed("the changes the stream held during its catch-up could not be applied", heldFailure);
|
|
2023
|
+
if (joined !== true)
|
|
2024
|
+
return joined || null;
|
|
2025
|
+
if (saidJoin)
|
|
2026
|
+
return null;
|
|
2027
|
+
const tail = replication ? (this.behind.has(t.source) ? "it is caught up again first (the slot was made again meanwhile)" : "the stream carries what follows once it runs again")
|
|
2028
|
+
: this.mode === "poll" && overPollLimit(t) ? `it is ${OVER_POLL_LIMIT}, so that was one scan for its rebuild, not the periodic poll: served as of now, mason sync ${t.source} syncs it again`
|
|
2029
|
+
: "the poll keeps it current";
|
|
2030
|
+
this.log(`caught up ${t.source} by one poll ${mark.why}: ${tail}`);
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* A COPY'S SWAP, ON THE WRITER'S TURN (`onWriter`): the side table a copy built on its own connection replaces the
|
|
2035
|
+
* table in ONE transaction between two of the writer's — never beside a poll's or the stream's write of the table,
|
|
2036
|
+
* whose commit a DROP under it fails. With it: the copy's watermark and — what the polls and stream skipped while it
|
|
2037
|
+
* was copied — the table marked behind (`_mason.catch_up`), its changes held from now (replication), so one poll
|
|
2038
|
+
* catches it up (`catchUp`). A copy in a retired layout (`RETIRED_LAYOUT`) is replaced whole here, never converted.
|
|
2039
|
+
*/
|
|
2040
|
+
async swap(t, side, o) {
|
|
2041
|
+
const w = this.store.writer;
|
|
2042
|
+
const target = `${ident(t.schema)}.${ident(t.table)}`;
|
|
2043
|
+
await w.run("BEGIN TRANSACTION");
|
|
2044
|
+
try {
|
|
2045
|
+
await w.run(`DROP TABLE IF EXISTS ${target}`);
|
|
2046
|
+
await w.run(`ALTER TABLE ${side} RENAME TO ${ident(t.table)}`);
|
|
2047
|
+
if (o.snapshot !== null)
|
|
2048
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.poll_state VALUES (${literal(t.source)}, ${literal(o.snapshot)}, now())`);
|
|
2049
|
+
if (o.behind !== null)
|
|
2050
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.catch_up VALUES (${literal(t.source)}, ${literal(o.behind)}, now())`);
|
|
2051
|
+
// the copy is whole: nothing of it is left to continue
|
|
2052
|
+
await w.run(`DELETE FROM ${MASON_SCHEMA}.copy_progress WHERE source = ${literal(t.source)}`);
|
|
2053
|
+
await w.run(`DELETE FROM ${MASON_SCHEMA}.copy_chunks WHERE source = ${literal(t.source)}`);
|
|
2054
|
+
// the stored JSON paths of a column the new copy does not hold went with the old table
|
|
2055
|
+
await this.kept.swapping(w, t.source, t.columns.map((c) => c.name));
|
|
2056
|
+
await w.run("COMMIT");
|
|
2057
|
+
}
|
|
2058
|
+
catch (e) {
|
|
2059
|
+
await w.run("ROLLBACK");
|
|
2060
|
+
throw e;
|
|
2061
|
+
}
|
|
2062
|
+
// the copy's view of its visible columns, made again (its own transaction: DuckDB writes one database a transaction)
|
|
2063
|
+
await this.kept.swapped(t.source);
|
|
2064
|
+
if (o.snapshot !== null)
|
|
2065
|
+
this.snapshots.set(t.source, o.snapshot);
|
|
2066
|
+
// the columns this copy holds, from its swap on: what every write to it is shaped by (`asCopied`)
|
|
2067
|
+
this.copiedColumns.set(t.source, [...t.columns]);
|
|
2068
|
+
if (o.behind !== null) {
|
|
2069
|
+
this.behind.set(t.source, { why: o.behind });
|
|
2070
|
+
if (this.mode === "replication")
|
|
2071
|
+
this.held.set(t.source, noChanges());
|
|
2072
|
+
}
|
|
2073
|
+
this.retired.delete(t.source);
|
|
2074
|
+
this.duckTypes.delete(t.source);
|
|
2075
|
+
this.refreshStreamed();
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* One poll of the tables given (the passes and `pollNow` give one at a time), in ONE DuckDB transaction over ONE
|
|
2079
|
+
* source snapshot (the module's header), on the lane's connection — the writer's, or the rebuilds' for a catch-up —
|
|
2080
|
+
* except a table with more than `POLL_PIECE_ROWS` changed rows. Every table's changed rows are read into a STAGE (a
|
|
2081
|
+
* TEMP table: no index, spilled past the memory limit) in the snapshot's transaction, and written by key from it
|
|
2082
|
+
* (`writeStaged`: their keys deleted, then the rows inserted). A bigger
|
|
2083
|
+
* stage is reconciled in that transaction — the count DuckDB WILL hold is its count now and the stage's keys it lacks
|
|
2084
|
+
* (a local anti-join of the stage), and only when the source's differs are its keys read and the keys it lacks
|
|
2085
|
+
* deleted, as for a small one — and written after it commits, `POLL_PIECE_ROWS` to a committed transaction, the LAST
|
|
2086
|
+
* piece's moving the table's watermark (`_mason.poll_state`). Deleting first is also what fits: at 64 MB, 8,000 deletes
|
|
2087
|
+
* after 720,000 upserts into an 800,000-row table ran out of memory where before them they did not (measured).
|
|
2088
|
+
* Safe for the same reason a poll read twice is: a row a piece writes is the source's row at the snapshot,
|
|
2089
|
+
* and a poll that dies between pieces leaves the watermark where it was, so the next poll reads every changed row
|
|
2090
|
+
* again and writes it again. A reader may see a table's deletes and earlier pieces before its later ones, as it sees
|
|
2091
|
+
* one table's poll before another's. A statement that fails is named in the error (`doing`): a poll runs several
|
|
2092
|
+
* against the source and DuckDB, and the engine's message names none. Answers what it found per table (a pieced
|
|
2093
|
+
* table's once its last piece is in), or none when a numeric wider than its copy rolled it back.
|
|
2094
|
+
*/
|
|
2095
|
+
async poll(lane, tables) {
|
|
2096
|
+
const w = lane.conn;
|
|
2097
|
+
const timed = [];
|
|
2098
|
+
const found = [];
|
|
2099
|
+
// the tables whose changed rows are written in pieces once the snapshot's transaction commits
|
|
2100
|
+
const pieced = [];
|
|
2101
|
+
// its row counts, kept until its transaction commits (the poll's own: a catch-up's runs beside a pass's)
|
|
2102
|
+
const pending = new Map();
|
|
2103
|
+
const exacts = new Map();
|
|
2104
|
+
const declared = (t, column) => NUMERIC_TYPE.exec(t.columns.find((c) => c.name === column)?.type ?? "")?.[1] !== undefined;
|
|
2105
|
+
for (const t of tables)
|
|
2106
|
+
exacts.set(t.source, await this.exactOf(t, w));
|
|
2107
|
+
// the statement running, for the error of one that fails
|
|
2108
|
+
let doing = "reading the source's snapshot";
|
|
2109
|
+
const named = (e) => e instanceof Stopping || !(e instanceof Error) ? e : new Error(`${doing}: ${e.message}`, { cause: e });
|
|
2110
|
+
// the source's count against DuckDB's, and when they differ the keys DuckDB holds that the source does not deleted
|
|
2111
|
+
// (`adds`: the keys a stage still to be written adds to DuckDB's count)
|
|
2112
|
+
const reconcile = async (t, target, from, adds = 0n) => {
|
|
2113
|
+
const c0 = Date.now();
|
|
2114
|
+
doing = `${t.source}: counting its rows on the source and in DuckDB`;
|
|
2115
|
+
const counts = (await w.runAndReadAll(`SELECT (SELECT count FROM ${pq(`SELECT count(*) AS count FROM ${from}`)}), (SELECT count(*) FROM ${target})`)).getRowsJS()[0];
|
|
2116
|
+
const countMs = Date.now() - c0;
|
|
2117
|
+
let deleted = 0;
|
|
2118
|
+
if (counts[0] !== counts[1] + adds) {
|
|
2119
|
+
doing = `${t.source}: deleting the keys the source no longer holds (its keys anti-joined with DuckDB's)`;
|
|
2120
|
+
const on = t.key.map((k) => `t.${ident(k)} = k.${ident(k)}`).join(" AND ");
|
|
2121
|
+
const gone = await w.run(`DELETE FROM ${target} WHERE rowid IN (SELECT t.rowid FROM ${target} t ANTI JOIN ${pq(`SELECT ${t.key.map(pgIdent).join(", ")} FROM ${from}`)} k ON ${on})`);
|
|
2122
|
+
deleted = Number(gone.rowsChanged);
|
|
2123
|
+
}
|
|
2124
|
+
return { rows: Number(counts[0]), deleted, countMs };
|
|
2125
|
+
};
|
|
2126
|
+
let snapshot = "";
|
|
2127
|
+
await w.run("BEGIN TRANSACTION");
|
|
2128
|
+
try {
|
|
2129
|
+
await this.interruptible(lane, async () => {
|
|
2130
|
+
const t0 = Date.now();
|
|
2131
|
+
const at = (await w.runAndReadAll(`SELECT * FROM ${pq(SNAPSHOT_SQL)}`)).getRowsJS()[0];
|
|
2132
|
+
snapshot = at[0];
|
|
2133
|
+
const xmax = BigInt(at[1]);
|
|
2134
|
+
timed.push(`snapshot ${Date.now() - t0} ms`);
|
|
2135
|
+
for (const t of tables) {
|
|
2136
|
+
const target = `${ident(t.schema)}.${ident(t.table)}`;
|
|
2137
|
+
const from = `${pgIdent(t.schema)}.${pgIdent(t.table)}`;
|
|
2138
|
+
const exact = exacts.get(t.source);
|
|
2139
|
+
// an exact numeric as its text, checked to fit its copy's DECIMAL (WIDER: the table is copied again, wider)
|
|
2140
|
+
const cols = t.columns.map((c) => exact.has(c.name) ? fitsDecimal(c.name, exact.get(c.name), t.source, declared(t, c.name)) : storedValue(c)).join(", ");
|
|
2141
|
+
const enums = await enumLists(w, target);
|
|
2142
|
+
const pgCols = t.columns.map((c) => pgColumnSql(c, exact, enums)).join(", ");
|
|
2143
|
+
const watermark = snapshotXmin(this.snapshots.get(t.source));
|
|
2144
|
+
const stage = `temp.main.${ident(`${POLL_STAGE}${pieced.length}`)}`;
|
|
2145
|
+
const c0 = Date.now();
|
|
2146
|
+
doing = `${t.source}: reading its changed rows from the source into the poll's stage`;
|
|
2147
|
+
// its columns named by position: a source column named rowid would hide the stage's own, which a piece reads
|
|
2148
|
+
const staged = Number((await w.run(`CREATE OR REPLACE TEMP TABLE ${ident(`${POLL_STAGE}${pieced.length}`)} AS SELECT * FROM (SELECT ${cols} FROM ` +
|
|
2149
|
+
`${pq(`SELECT ${pgCols} FROM ${from} WHERE ${xmin8Sql(xmax)} >= ${watermark}`)}) AS polled(${t.columns.map((_, i) => `c${i}`).join(", ")})`)).rowsChanged);
|
|
2150
|
+
if (staged > POLL_PIECE_ROWS) {
|
|
2151
|
+
const c1 = Date.now();
|
|
2152
|
+
doing = `${t.source}: counting the keys its ${staged} changed row(s) add to DuckDB (the stage anti-joined with DuckDB's keys)`;
|
|
2153
|
+
const on = t.key.map((k) => `s.${ident(`c${t.columns.findIndex((c) => c.name === k)}`)} = t.${ident(k)}`).join(" AND ");
|
|
2154
|
+
const adds = (await w.runAndReadAll(`SELECT count(*) FROM ${stage} s ANTI JOIN ${target} t ON ${on}`)).getRowsJS()[0][0];
|
|
2155
|
+
const { rows, deleted, countMs } = await reconcile(t, target, from, adds);
|
|
2156
|
+
pieced.push({ t, target, stage, staged, deleted, started: c0 });
|
|
2157
|
+
timed.push(`${t.source} ${staged} changed, staged in ${c1 - c0} ms, ${adds} new, counted in ${countMs} ms${deleted ? `, ${deleted} deleted` : ""}`);
|
|
2158
|
+
pending.set(t.source, rows);
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
doing = `${t.source}: writing its ${staged} changed row(s) by key`;
|
|
2162
|
+
await writeStaged(w, t, target, stage, "", this.kept);
|
|
2163
|
+
const c1 = Date.now();
|
|
2164
|
+
const { rows, deleted, countMs } = await reconcile(t, target, from);
|
|
2165
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.poll_state VALUES (${literal(t.source)}, ${literal(snapshot)}, now())`);
|
|
2166
|
+
await w.run(`DROP TABLE ${stage}`);
|
|
2167
|
+
timed.push(`${t.source} ${staged} changed in ${c1 - c0} ms, counted in ${countMs} ms${deleted ? `, ${deleted} deleted in ${Date.now() - c1 - countMs} ms` : ""}`);
|
|
2168
|
+
found.push({ table: t.source, changed: staged, deleted, rows, ms: Date.now() - c0 });
|
|
2169
|
+
pending.set(t.source, rows);
|
|
2170
|
+
}
|
|
2171
|
+
});
|
|
2172
|
+
doing = "committing the poll";
|
|
2173
|
+
await w.run("COMMIT");
|
|
2174
|
+
}
|
|
2175
|
+
catch (e) {
|
|
2176
|
+
try {
|
|
2177
|
+
await w.run("ROLLBACK");
|
|
2178
|
+
}
|
|
2179
|
+
catch (r) {
|
|
2180
|
+
this.log(`the poll's ROLLBACK failed too: ${r instanceof Error ? r.message : String(r)}`);
|
|
2181
|
+
}
|
|
2182
|
+
// a stage made in the rolled-back transaction went with it
|
|
2183
|
+
pieced.length = 0;
|
|
2184
|
+
// a numeric wider than its copy: that table is copied again (wider) at the next pass, the others polled
|
|
2185
|
+
// matched against the tables' own names (a name may hold a space)
|
|
2186
|
+
const wider = e instanceof Error ? tables.find((t) => e.message.includes(`${WIDER} [${t.source}]`))?.source : undefined;
|
|
2187
|
+
if (wider !== undefined && this.snapshots.delete(wider)) {
|
|
2188
|
+
this.log(`poll: ${wider} has a numeric value wider than its copy's DECIMAL: it is copied again at the next pass`);
|
|
2189
|
+
return [];
|
|
2190
|
+
}
|
|
2191
|
+
throw named(e);
|
|
2192
|
+
}
|
|
2193
|
+
// committed: the watermarks and the row counts move together (a pieced table's with its last piece)
|
|
2194
|
+
const committed = async (source, rows) => {
|
|
2195
|
+
const st = this.states.get(source);
|
|
2196
|
+
if (st.rows !== rows || st.error !== null)
|
|
2197
|
+
await this.record({ ...st, rows, error: null }, undefined, w);
|
|
2198
|
+
this.snapshots.set(source, snapshot);
|
|
2199
|
+
};
|
|
2200
|
+
try {
|
|
2201
|
+
for (const [source, rows] of pending)
|
|
2202
|
+
if (!pieced.some((p) => p.t.source === source))
|
|
2203
|
+
await committed(source, rows);
|
|
2204
|
+
this.log(`poll: ${timed.join("; ")}`);
|
|
2205
|
+
for (const { t, target, stage, staged, deleted, started } of pieced) {
|
|
2206
|
+
const p0 = Date.now();
|
|
2207
|
+
const n = Math.ceil(staged / POLL_PIECE_ROWS);
|
|
2208
|
+
for (let i = 0; i < n; i++) {
|
|
2209
|
+
await w.run("BEGIN TRANSACTION");
|
|
2210
|
+
try {
|
|
2211
|
+
doing = `${t.source}: writing piece ${i + 1} of ${n} of its ${staged} changed row(s) by key`;
|
|
2212
|
+
await this.interruptible(lane, () => writeStaged(w, t, target, stage, ` WHERE rowid >= ${i * POLL_PIECE_ROWS} AND rowid < ${(i + 1) * POLL_PIECE_ROWS}`, this.kept));
|
|
2213
|
+
if (i === n - 1) {
|
|
2214
|
+
await w.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.poll_state VALUES (${literal(t.source)}, ${literal(snapshot)}, now())`);
|
|
2215
|
+
}
|
|
2216
|
+
doing = `${t.source}: committing piece ${i + 1} of ${n}`;
|
|
2217
|
+
await w.run("COMMIT");
|
|
2218
|
+
}
|
|
2219
|
+
catch (e) {
|
|
2220
|
+
await w.run("ROLLBACK");
|
|
2221
|
+
throw named(e);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const rows = pending.get(t.source);
|
|
2225
|
+
await committed(t.source, rows);
|
|
2226
|
+
found.push({ table: t.source, changed: staged, deleted, rows, ms: Date.now() - started });
|
|
2227
|
+
this.log(`poll: ${t.source}: ${staged.toLocaleString("en-US")} changed row(s), written in ${n} pieces in ${Date.now() - p0} ms`);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
finally {
|
|
2231
|
+
for (const { stage } of pieced)
|
|
2232
|
+
await w.run(`DROP TABLE IF EXISTS ${stage}`);
|
|
2233
|
+
}
|
|
2234
|
+
return found;
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* A WHOLE COPY OF ONE TABLE, IN THE SOURCE'S HEAP ORDER, WITH NO INDEX (docs/DESIGN.md "Heap order, no index"), IN
|
|
2238
|
+
* CHUNKS THAT RESUME (on the test server: a copy of one 196 M-row table was 23 minutes that could not step aside, and one
|
|
2239
|
+
* that failed at 90 % started from zero, reading the customer's replica again — ~738 GB over a day's attempts). On
|
|
2240
|
+
* the rebuilds' connection, a side table `<table>__mason_copy` is filled a range of the source's heap pages at a
|
|
2241
|
+
* time, each sized to take `MASON_COPY_CHUNK_SECONDS` at the rate measured here (`nextChunkPages`; `chunkPages` the
|
|
2242
|
+
* ceiling): each chunk a passthrough read (`pq`) of `ctid >= '(p,0)' AND ctid < '(q,0)'` — a TID range scan, the
|
|
2243
|
+
* extension's own way of splitting a scan, never an ORDER BY there — appended in the order Postgres's scan delivers
|
|
2244
|
+
* it, in a DuckDB transaction of its own that also records the copy's progress (`_mason.copy_progress`,
|
|
2245
|
+
* `CopyProgress`), committed. The last chunk is open-ended, so pages past the count read at the start (a table that
|
|
2246
|
+
* grew) are read too. Then the writer swaps it in (`swap`; readers see the old copy until then) and it is caught up
|
|
2247
|
+
* from the copy's FIRST snapshot (`catchUp`).
|
|
2248
|
+
*
|
|
2249
|
+
* HELD TO ITS FIRST SNAPSHOT: each DuckDB transaction is a Postgres transaction of its own, so every chunk after the
|
|
2250
|
+
* first — and every chunk of a copy resumed by a later run — reads a newer snapshot. Each chunk therefore keeps only
|
|
2251
|
+
* the rows written before the first snapshot's xmin, the copy's watermark (src/cdc/poll.ts `xminBeforeSql`, the
|
|
2252
|
+
* complement of the poll's "changed since"): a transaction that commits after that snapshot has an id at or past its
|
|
2253
|
+
* xmin (it was running then, or began later), and a tuple's xmin never changes, so each chunk's rows are rows of the
|
|
2254
|
+
* first snapshot that no later transaction changed. A row updated or deleted since is absent from the copy (its old
|
|
2255
|
+
* version is invisible later, its new one is past the watermark) or stale in it (read before the change), exactly as
|
|
2256
|
+
* in a one-transaction copy, and a key is never read twice. The catch-up poll from that watermark reads every row the
|
|
2257
|
+
* chunks left out and writes it by key, and its count check deletes what the source no longer holds. Two things
|
|
2258
|
+
* would break it, and each starts the copy over, said: a rewrite of the source table (VACUUM FULL, CLUSTER, TRUNCATE,
|
|
2259
|
+
* a column type change: its rows MOVE between pages, read twice or never) — its file (`pg_relation_filenode`) is read
|
|
2260
|
+
* after each chunk, under the lock that chunk's read still holds, and compared with the first chunk's — and a billion
|
|
2261
|
+
* transactions since the first snapshot (`xminBeforeHolds`: past 2^31 `age()` wraps). The swap asserts what this
|
|
2262
|
+
* gives: as many distinct keys as rows.
|
|
2263
|
+
*
|
|
2264
|
+
* WHY NO STAGE AND NO KEY (measured on DuckDB 2.0.0, sampling `duckdb_memory()` every 50 ms): an insert into
|
|
2265
|
+
* a table with no index writes its row groups to the file as they fill (DuckDB's optimistic writes), so a chunk's
|
|
2266
|
+
* memory does not grow with the table — 5,000,000 rows of the test server's table's shape at 256 MB peaked at 244 MiB as one
|
|
2267
|
+
* statement; the chunked copy's numbers are in the docs. In heap order the time column's zonemaps prune.
|
|
2268
|
+
*
|
|
2269
|
+
* READ A FEW AT A TIME (`readChunks`, `COPY_PARALLEL`): each chunk on one of up to `MASON_COPY_PARALLEL` DuckDB
|
|
2270
|
+
* connections, each one backend on the source — never the attached scan's ten.
|
|
2271
|
+
*
|
|
2272
|
+
* RESUMED, NEVER READ TWICE: a copy that died — a crash, `mason stop` (it interrupts the chunks in flight, which alone
|
|
2273
|
+
* roll back), a failure — continues with the ranges no chunk committed (`chunksToRead`) when its columns are the same,
|
|
2274
|
+
* said ("resuming the copy of …"); changed columns, or a table no longer added, drop the side table and its progress,
|
|
2275
|
+
* and so does a copy that ends before any chunk committed (it has nothing to keep but its beginning). A failed copy
|
|
2276
|
+
* waits before it is tried again (`copyRetryDelayMs`). Step 0 (`recopy`) has no catch-up to bring what a later
|
|
2277
|
+
* snapshot changed: its chunks are one transaction on one connection, and it does not resume. Read-only on the
|
|
2278
|
+
* source: SELECTs.
|
|
2279
|
+
*/
|
|
2280
|
+
async copy(t) {
|
|
2281
|
+
const was = this.states.get(t.source);
|
|
2282
|
+
const first = was.phase !== "copied";
|
|
2283
|
+
const w = this.rebuilder.conn;
|
|
2284
|
+
if (first)
|
|
2285
|
+
await this.record({ ...was, phase: "copying", error: null }, undefined, w);
|
|
2286
|
+
const started = Date.now();
|
|
2287
|
+
const side = `${ident(t.schema)}.${ident(`${t.table}${SIDE_SUFFIX}`)}`;
|
|
2288
|
+
const recopy = this.mode === "recopy";
|
|
2289
|
+
// what this copy has committed of itself: kept for the next attempt (a failure, a stop), dropped when it cannot be
|
|
2290
|
+
let kept = null;
|
|
2291
|
+
try {
|
|
2292
|
+
// a copy begun before (a run that died or stopped, a copy that failed): continued where its columns are the same
|
|
2293
|
+
kept = recopy ? await this.forgetCopy(t, side) : await this.resumable(t, side);
|
|
2294
|
+
const resumed = kept;
|
|
2295
|
+
if (resumed === null) {
|
|
2296
|
+
// the stream (or the other tables' polls) goes on: the slot holds no WAL for this copy, and the table joins once
|
|
2297
|
+
// caught up from the copy's snapshot
|
|
2298
|
+
const beside = this.mode === "replication" ? "beside the stream, which goes on keeping the other tables current: it is streamed once caught up by one poll from the copy's snapshot"
|
|
2299
|
+
: this.mode === "poll" ? "beside the other tables' polls: it is caught up by one poll from the copy's snapshot once copied" : "beside the other tables";
|
|
2300
|
+
const again = this.retired.has(t.source) ? `, again (its copy is ${RETIRED_LAYOUT})` : "";
|
|
2301
|
+
this.log(`copying ${t.source}${again}: ${t.rows === null ? "rows unknown" : `about ${t.rows} row(s)`}, ${t.bytes} source bytes, ${beside}`);
|
|
2302
|
+
}
|
|
2303
|
+
let read;
|
|
2304
|
+
try {
|
|
2305
|
+
read = await this.readChunks(t, side, kept, (p) => { kept = p; });
|
|
2306
|
+
}
|
|
2307
|
+
catch (e) {
|
|
2308
|
+
if (!(e instanceof StartOver))
|
|
2309
|
+
throw e;
|
|
2310
|
+
// once: a second reason to start over is the copy's failure
|
|
2311
|
+
this.log(`starting the copy of ${t.source} over, from its first page: ${e.message}`);
|
|
2312
|
+
kept = await this.forgetCopy(t, side);
|
|
2313
|
+
read = await this.readChunks(t, side, null, (p) => { kept = p; });
|
|
2314
|
+
}
|
|
2315
|
+
const p = read.progress;
|
|
2316
|
+
// AS MANY DISTINCT KEYS AS ROWS (`copy`'s header): every chunk read only the first snapshot's rows, and no page
|
|
2317
|
+
// twice. Never expected to fail; when it does (a source rewritten in place in a way its file does not show — none
|
|
2318
|
+
// known — or a Mason bug) the copy is not swapped in and starts over at its retry
|
|
2319
|
+
if (t.key.length > 0) {
|
|
2320
|
+
const [rows, keys] = (await w.runAndReadAll(`SELECT count(*), count(DISTINCT (${t.key.map(ident).join(", ")})) FROM ${side}`)).getRowsJS()[0];
|
|
2321
|
+
if (rows !== keys) {
|
|
2322
|
+
kept = await this.forgetCopy(t, side);
|
|
2323
|
+
throw new Error(`its copy holds ${rows} rows but ${keys} distinct keys (Mason bug: report it, with mason logs): it was not swapped in, and is copied again from its first page`);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
// its stored JSON paths filled, so the table swapped in has every one whole (src/duck/kept.ts fillSide)
|
|
2327
|
+
await this.kept.fillSide(w, t.source, side, t.columns.map((c) => c.name), (sql) => this.interruptible(this.rebuilder, async () => Number((await w.run(sql)).rowsChanged)));
|
|
2328
|
+
// THE SWAP, on the writer's turn: one transaction, between two of the stream's or the polls'. It carries the copy's
|
|
2329
|
+
// watermark (its first snapshot), removes its progress, and leaves the table behind until its catch-up (none in
|
|
2330
|
+
// recopy mode: no poll)
|
|
2331
|
+
await this.onWriter(() => this.swap(t, side, { snapshot: p.snapshot, behind: p.snapshot === null ? null : "after its copy" }));
|
|
2332
|
+
this.copyLive.delete(t.source);
|
|
2333
|
+
kept = null;
|
|
2334
|
+
this.retries.delete(t.source);
|
|
2335
|
+
const seconds = (Date.now() - started) / 1000;
|
|
2336
|
+
this.log(`copied ${t.source}: ${p.rows} row(s), ${t.bytes} source bytes, in ${seconds.toFixed(2)} s (read in ${read.seconds.toFixed(2)} s` +
|
|
2337
|
+
`${resumed === null ? "" : `, resumed at page ${readThrough(resumed.done) ?? p.pages} of ${resumed.pages}${pastPages(resumed.done)} with ${resumed.rows} row(s) in`}), stored as the source's scan delivered them, with no index`);
|
|
2338
|
+
await this.record({ source: t.source, phase: "copied", rows: p.rows, bytes: t.bytes, seconds, copiedAt: new Date().toISOString(), error: null }, t.columns, w);
|
|
2339
|
+
this.copiedAs.set(t.source, columnsKey(t.columns));
|
|
2340
|
+
// in the catalog at once: a pass's other copies may take hours
|
|
2341
|
+
await this.rebuildSchema();
|
|
2342
|
+
}
|
|
2343
|
+
catch (e) {
|
|
2344
|
+
// its chunks are no longer read (a failure after every chunk was read, at its check, fill or swap, included)
|
|
2345
|
+
this.copyLive.delete(t.source);
|
|
2346
|
+
let k = kept;
|
|
2347
|
+
// begun, and no chunk committed: nothing of the source's rows to keep, so its next attempt begins again (a new
|
|
2348
|
+
// snapshot), as when the beginning and the first chunk were one transaction
|
|
2349
|
+
if (k !== null && k.done.length === 0) {
|
|
2350
|
+
try {
|
|
2351
|
+
k = await this.forgetCopy(t, side);
|
|
2352
|
+
}
|
|
2353
|
+
catch (f) {
|
|
2354
|
+
this.log(`the copy of ${t.source} could not drop what it began (its next attempt continues it): ${f instanceof Error ? f.message : String(f)}`);
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
const through = k === null ? null : readThrough(k.done);
|
|
2358
|
+
const where = k === null ? null : through === null ? "with every page read (its swap is next)" : `at page ${through} of ${k.pages}${pastPages(k.done)}, ${k.rows} row(s) in`;
|
|
2359
|
+
if (e instanceof Stopping) {
|
|
2360
|
+
// the chunks in flight rolled back: a table never copied continues at the next start, one copied before serves its
|
|
2361
|
+
// copy as it was meanwhile
|
|
2362
|
+
this.log(`copy of ${t.source} stopped: Mason is stopping (${where === null ? "rolled back; it is copied at the next start" : `the chunks in flight rolled back; it continues ${where} at the next start`})`);
|
|
2363
|
+
if (first)
|
|
2364
|
+
await this.record({ ...was, phase: "waiting", error: null }, undefined, w);
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
2368
|
+
// tried again after a wait that grows with each failure in a row: a copy failing for good must not read the
|
|
2369
|
+
// source at every pass (a 20-minute read of a production replica every 95 minutes) — and then
|
|
2370
|
+
// continued where it was, never read again from its first page
|
|
2371
|
+
const failures = (this.retries.get(t.source)?.failures ?? 0) + 1;
|
|
2372
|
+
const at = Date.now() + copyRetryDelayMs(failures, Date.now() - started);
|
|
2373
|
+
this.retries.set(t.source, { failures, at });
|
|
2374
|
+
const when = `tried again after ${new Date(at).toISOString().slice(11, 16)} UTC (failure ${failures} in a row), or at once when Mason starts again or its columns or key change` +
|
|
2375
|
+
(where === null ? "" : `; it continues ${where}`);
|
|
2376
|
+
this.log(`copy of ${t.source} failed: ${m}; ${when}`);
|
|
2377
|
+
// a table that was copied keeps serving its last copy; one never copied is failed
|
|
2378
|
+
await this.record({ ...this.states.get(t.source), phase: first ? "failed" : "copied", error: `${m} (${when})` }, undefined, w);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
/** The DuckDB file's bytes with its write-ahead log's (what a copy adds lands in the log until a checkpoint). */
|
|
2382
|
+
fileBytes() {
|
|
2383
|
+
let n = 0;
|
|
2384
|
+
for (const f of [this.store.file, `${this.store.file}.wal`]) {
|
|
2385
|
+
try {
|
|
2386
|
+
n += fs.statSync(f).size;
|
|
2387
|
+
}
|
|
2388
|
+
catch (e) {
|
|
2389
|
+
if (e.code !== "ENOENT")
|
|
2390
|
+
throw e;
|
|
2391
|
+
} // no log between checkpoints
|
|
2392
|
+
}
|
|
2393
|
+
return n;
|
|
2394
|
+
}
|
|
2395
|
+
/** Why the copy of `t` must stop now — what its pages left would add is more than the disk has free — or null. */
|
|
2396
|
+
diskShort(t, p, disk) {
|
|
2397
|
+
const bytesNow = this.fileBytes();
|
|
2398
|
+
const projected = copyDiskProjection({ pages: p.pages, done: p.done, doneAtStart: disk.doneAtStart, bytesAtStart: disk.bytesAtStart, bytesNow });
|
|
2399
|
+
if (projected === null)
|
|
2400
|
+
return null;
|
|
2401
|
+
const free = this.diskFree(path.dirname(this.store.file));
|
|
2402
|
+
if (projected.needed <= free)
|
|
2403
|
+
return null;
|
|
2404
|
+
return `the copy of ${t.source} would need about ${humanBytes(projected.needed)} more on disk than is free (${humanBytes(free)} free where ${this.store.file} is; ` +
|
|
2405
|
+
`${pagesRead(p.done, p.pages)} of ${p.pages} pages read, ${humanBytes(bytesNow - disk.bytesAtStart)} added): free some disk, mason remove ${t.source}, or mason add ${t.source} --columns … with fewer columns`;
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* THE CHUNKS OF A COPY (`copy`), from `from` (a copy begun before) or begun here (`beginCopy`, a transaction of its
|
|
2409
|
+
* own), READ `copyParallel` AT A TIME (`COPY_PARALLEL`, `MASON_COPY_PARALLEL`): each reader a DuckDB connection of
|
|
2410
|
+
* its own — the rebuilds' and up to seven opened for this copy — so each is one transaction, hence one backend, on the
|
|
2411
|
+
* source (the attachment is the instance's; `postgres_query` on a connection reads through that connection's own
|
|
2412
|
+
* transaction, measured: three connections, three backends). The readers take the chunks left in page order, each
|
|
2413
|
+
* cut at the size the measured rate says when it is taken (`takeChunk`, `nextChunkPages`), each a DuckDB transaction of its own that appends its rows to the ONE side table and its own row to
|
|
2414
|
+
* `_mason.copy_chunks`, committed: DuckDB 2.0 lets transactions append to one table side by side (tests/integration/
|
|
2415
|
+
* duck-poll.itest.ts holds it), so no reader waits for another's commit. Chunks commit OUT OF ORDER; each committed
|
|
2416
|
+
* one is recorded by its range, so a copy that dies continues with exactly the ranges no chunk committed — the ones
|
|
2417
|
+
* in flight, never one read twice (the key of `copy_chunks` refuses that at its commit) nor one skipped.
|
|
2418
|
+
*
|
|
2419
|
+
* EVERY CHUNK IS HELD TO THE FIRST SNAPSHOT (`xminBeforeSql` from `beginCopy`'s one read; `copy`'s header), whichever
|
|
2420
|
+
* reader reads it and whenever, and checks the source table's file after its read: the first file read is the
|
|
2421
|
+
* copy's, and a chunk that reads another starts the whole copy over (`StartOver`). The first reader to fail —
|
|
2422
|
+
* a rewrite, a snapshot held too long, an error, `mason stop` — interrupts the others (sent again every 50 ms: DuckDB
|
|
2423
|
+
* loses an interrupt that arrives before its statement starts) and none commits after it: their chunks roll back and
|
|
2424
|
+
* are read again by the next attempt. A recopy is one transaction on one reader, no progress. `kept` hears each
|
|
2425
|
+
* committed progress; every minute a line says where it is, the chunks of that minute and the readers.
|
|
2426
|
+
*/
|
|
2427
|
+
async readChunks(t, side, from, kept) {
|
|
2428
|
+
const w = this.rebuilder.conn;
|
|
2429
|
+
const recopy = this.mode === "recopy";
|
|
2430
|
+
const pgFrom = `${pgIdent(t.schema)}.${pgIdent(t.table)}`;
|
|
2431
|
+
const stored = t.columns.map(storedValue).join(", ");
|
|
2432
|
+
// named: a copy resumed after its stored JSON paths were filled holds them too (src/duck/kept.ts fillSide)
|
|
2433
|
+
const named = t.columns.map((c) => ident(c.name)).join(", ");
|
|
2434
|
+
const started = Date.now();
|
|
2435
|
+
const lanes = [this.rebuilder];
|
|
2436
|
+
if (recopy)
|
|
2437
|
+
await w.run("BEGIN TRANSACTION");
|
|
2438
|
+
try {
|
|
2439
|
+
let begun = from;
|
|
2440
|
+
if (begun === null) {
|
|
2441
|
+
if (!recopy)
|
|
2442
|
+
await w.run("BEGIN TRANSACTION");
|
|
2443
|
+
try {
|
|
2444
|
+
begun = await this.interruptible(this.rebuilder, () => this.beginCopy(t, side, pgFrom));
|
|
2445
|
+
if (!recopy) {
|
|
2446
|
+
await this.writeBegun(t, begun);
|
|
2447
|
+
await w.run("COMMIT");
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
catch (e) {
|
|
2451
|
+
if (!recopy)
|
|
2452
|
+
await w.run("ROLLBACK");
|
|
2453
|
+
throw e;
|
|
2454
|
+
}
|
|
2455
|
+
if (!recopy)
|
|
2456
|
+
kept(begun);
|
|
2457
|
+
}
|
|
2458
|
+
let p = begun;
|
|
2459
|
+
// what this attempt adds to the file, against the pages it reads (copyDiskProjection)
|
|
2460
|
+
const disk = { bytesAtStart: this.fileBytes(), doneAtStart: p.done };
|
|
2461
|
+
// CHUNKS SIZED BY THE TIME THEY TAKE HERE (`nextChunkPages`): the row estimate's chunk is only the ceiling; the
|
|
2462
|
+
// first chunks are a tenth of it, to measure, and every later one is what a reader reads in copyChunkSeconds at
|
|
2463
|
+
// the rate measured since — cut from what no committed chunk covers (`copyGaps`, `takeChunk`), so a copy resumed
|
|
2464
|
+
// reads exactly the ranges it did not commit, whatever size its chunks were
|
|
2465
|
+
const ceiling = chunkPages(p.pages, t.rows, this.copyChunkRows);
|
|
2466
|
+
const gaps = copyGaps(p.done);
|
|
2467
|
+
let rate = null;
|
|
2468
|
+
// the source's page size, for the bytes a second its readers read (pg_relation_size is in bytes; pages are its unit)
|
|
2469
|
+
const blockSize = Number((await this.interruptible(this.rebuilder, () => w.runAndReadAll(`SELECT * FROM ${pq("SELECT current_setting('block_size')::int")}`))).getRowsJS()[0][0]);
|
|
2470
|
+
// the side table's enum arrays (`pgColumnSql`; over every type the oracle and the stream suite hold, the enum array
|
|
2471
|
+
// is the one a passthrough read types otherwise than the attached scan)
|
|
2472
|
+
const lists = await enumLists(w, side);
|
|
2473
|
+
const read = t.columns.map((c) => pgColumnSql(c, p.exact, lists)).join(", ");
|
|
2474
|
+
const watermark = p.snapshot === null ? null : snapshotXmin(p.snapshot);
|
|
2475
|
+
// THE COPY'S FILE: the first a chunk read (or the one its committed chunks were read from); set before any commit
|
|
2476
|
+
// after it, so of two chunks that read different files the second to look starts the copy over, whichever commits
|
|
2477
|
+
let filenode = p.filenode;
|
|
2478
|
+
// as many readers as the first chunks fill (a small table opens only the readers it has chunks for)
|
|
2479
|
+
const readers = recopy ? 1 : Math.max(1, Math.min(this.copyParallel, chunksToRead(p.pages, nextChunkPages(ceiling, null, this.copyChunkSeconds), p.done).length));
|
|
2480
|
+
const pagesDone = pagesRead(p.done, p.pages);
|
|
2481
|
+
const live = { pages: p.pages, chunksDone: p.done.length, pagesDone, rowsDone: p.rows, untaken: gapPages(gaps, p.pages),
|
|
2482
|
+
chunkPages: nextChunkPages(ceiling, null, this.copyChunkSeconds), readers, reading: [], rate: null,
|
|
2483
|
+
rowsPerPage: pagesDone > 0 ? p.rows / pagesDone : t.rows !== null && t.rows > 0 && p.pages > 0 ? t.rows / p.pages : null };
|
|
2484
|
+
this.copyLive.set(t.source, live);
|
|
2485
|
+
for (let i = 1; i < readers; i++) {
|
|
2486
|
+
const lane = { conn: await this.store.instance.connect(), inFlight: false };
|
|
2487
|
+
lanes.push(lane);
|
|
2488
|
+
this.copyLanes.add(lane);
|
|
2489
|
+
}
|
|
2490
|
+
// the first failure, which every reader stops at (the others' are their interruption, or the same cause)
|
|
2491
|
+
let failure = null;
|
|
2492
|
+
const fail = (e) => {
|
|
2493
|
+
failure ??= { error: e };
|
|
2494
|
+
for (const l of lanes)
|
|
2495
|
+
if (l.inFlight)
|
|
2496
|
+
l.conn.interrupt();
|
|
2497
|
+
};
|
|
2498
|
+
// the chunks since the last minute's line, and how long they took
|
|
2499
|
+
let lineAt = Date.now(), chunks = 0, chunkMs = 0;
|
|
2500
|
+
const reader = async (lane, k) => {
|
|
2501
|
+
const c = lane.conn;
|
|
2502
|
+
for (;;) {
|
|
2503
|
+
if (failure !== null)
|
|
2504
|
+
return;
|
|
2505
|
+
const sizedBy = rate;
|
|
2506
|
+
const range = takeChunk(gaps, p.pages, nextChunkPages(ceiling, sizedBy, this.copyChunkSeconds));
|
|
2507
|
+
if (range === undefined)
|
|
2508
|
+
return;
|
|
2509
|
+
const { first, end } = range;
|
|
2510
|
+
// its pages as counted when the copy began (an open-ended last chunk reads what the table grew by too)
|
|
2511
|
+
const chunkSize = Math.max(1, (end ?? p.pages) - first);
|
|
2512
|
+
const slot = { since: Date.now(), pages: chunkSize };
|
|
2513
|
+
live.reading.push(slot);
|
|
2514
|
+
live.untaken = gapPages(gaps, p.pages);
|
|
2515
|
+
live.chunkPages = nextChunkPages(ceiling, rate, this.copyChunkSeconds);
|
|
2516
|
+
const told = { source: t.source, first, end, reader: k, pages: chunkSize, ceiling, sizedBy };
|
|
2517
|
+
const c0 = slot.since;
|
|
2518
|
+
let inTx = false;
|
|
2519
|
+
try {
|
|
2520
|
+
if (!recopy) {
|
|
2521
|
+
await c.run("BEGIN TRANSACTION");
|
|
2522
|
+
inTx = true;
|
|
2523
|
+
}
|
|
2524
|
+
const rows = await this.interruptible(lane, async () => {
|
|
2525
|
+
// each chunk is a transaction, so a snapshot, of its own: held to the first one while that can tell
|
|
2526
|
+
if (watermark !== null) {
|
|
2527
|
+
const xmax = BigInt((await c.runAndReadAll(`SELECT * FROM ${pq(SNAPSHOT_SQL)}`)).getRowsJS()[0][1]);
|
|
2528
|
+
if (!xminBeforeHolds(xmax, watermark)) {
|
|
2529
|
+
throw new StartOver(`${(xmax - watermark).toLocaleString("en-US")} transactions ran on the source since its first snapshot, past what a row's age can tell apart`);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
const where = `ctid >= '(${first},0)'::tid${end === null ? "" : ` AND ctid < '(${end},0)'::tid`}`;
|
|
2533
|
+
const r = await c.run(`INSERT INTO ${side} (${named}) SELECT ${stored} FROM ${pq(`SELECT ${read} FROM ${pgFrom} WHERE ${watermark === null ? where : `${where} AND ${xminBeforeSql(watermark)}`}`)}`);
|
|
2534
|
+
// the table's file, under the lock this transaction's read holds until it ends: a rewrite moved its rows
|
|
2535
|
+
const node = Number((await c.runAndReadAll(`SELECT * FROM ${pq(`SELECT pg_relation_filenode(${pgLiteral(pgFrom)}::regclass)::bigint`)}`)).getRowsJS()[0][0]);
|
|
2536
|
+
filenode ??= node;
|
|
2537
|
+
if (node !== filenode) {
|
|
2538
|
+
throw new StartOver(`the source rewrote ${t.source} since its copy began (its file changed: a VACUUM FULL, CLUSTER, TRUNCATE or column type change), so its rows moved between pages`);
|
|
2539
|
+
}
|
|
2540
|
+
const n = Number(r.rowsChanged);
|
|
2541
|
+
await this.onChunk?.({ ...told, rows: n, phase: "read" });
|
|
2542
|
+
if (!recopy)
|
|
2543
|
+
await this.recordChunk(c, t, range, n, node);
|
|
2544
|
+
return n;
|
|
2545
|
+
});
|
|
2546
|
+
// another reader failed while this one read (its interrupt arrived between two statements): never committed
|
|
2547
|
+
if (failure !== null)
|
|
2548
|
+
throw failure.error;
|
|
2549
|
+
if (!recopy)
|
|
2550
|
+
await c.run("COMMIT");
|
|
2551
|
+
const seconds = (Date.now() - c0) / 1000;
|
|
2552
|
+
p = { ...p, filenode, done: [...p.done, range], rows: p.rows + rows };
|
|
2553
|
+
if (!recopy)
|
|
2554
|
+
kept(p);
|
|
2555
|
+
// the measured rate, and the copy's progress, before anything else hears of the commit
|
|
2556
|
+
rate = nextRate(rate, { pages: chunkSize, rows, seconds, blockSize });
|
|
2557
|
+
live.reading.splice(live.reading.indexOf(slot), 1);
|
|
2558
|
+
live.chunksDone++;
|
|
2559
|
+
live.pagesDone += chunkSize;
|
|
2560
|
+
live.rowsDone = p.rows;
|
|
2561
|
+
live.rate = rate;
|
|
2562
|
+
live.rowsPerPage = live.pagesDone > 0 ? live.rowsDone / live.pagesDone : live.rowsPerPage;
|
|
2563
|
+
live.chunkPages = nextChunkPages(ceiling, rate, this.copyChunkSeconds);
|
|
2564
|
+
const now = this.states.get(t.source);
|
|
2565
|
+
if (now !== undefined && now.phase === "copying")
|
|
2566
|
+
this.states.set(t.source, { ...now, rowsSoFar: p.rows, rowsEstimate: t.rows });
|
|
2567
|
+
await this.onChunk?.({ ...told, rows, phase: "committed", seconds });
|
|
2568
|
+
chunks++;
|
|
2569
|
+
chunkMs += Date.now() - c0;
|
|
2570
|
+
}
|
|
2571
|
+
catch (e) {
|
|
2572
|
+
const at = live.reading.indexOf(slot);
|
|
2573
|
+
if (at >= 0)
|
|
2574
|
+
live.reading.splice(at, 1);
|
|
2575
|
+
if (inTx) {
|
|
2576
|
+
try {
|
|
2577
|
+
await c.run("ROLLBACK");
|
|
2578
|
+
}
|
|
2579
|
+
catch (r) {
|
|
2580
|
+
this.log(`a chunk of ${t.source}'s copy could not be rolled back after it failed (its connection is closed with the copy): ${r instanceof Error ? r.message : String(r)}`);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
fail(e);
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
// STOPPED BEFORE THE DISK FILLS: the chunks committed are kept, the copy fails with the way out and is tried
|
|
2587
|
+
// again as a failed copy is (freed disk is used at the retry, or at once at the next start)
|
|
2588
|
+
const short = recopy ? null : this.diskShort(t, p, disk);
|
|
2589
|
+
if (short !== null) {
|
|
2590
|
+
fail(new Error(short));
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
const at = readThrough(p.done);
|
|
2594
|
+
if (Date.now() - lineAt >= 60_000 && at !== null) {
|
|
2595
|
+
this.log(`copying ${t.source}: page ${at} of ${p.pages}${pastPages(p.done)}, ${p.rows.toLocaleString("en-US")} row(s) in; ${chunks} chunk(s) in the last minute on ${readers} reader(s), ` +
|
|
2596
|
+
`${(chunkMs / chunks / 1000).toFixed(1)} s each` + (rate === null ? "" : `; a reader reads ~${Math.round(rate.rowsPerSecond).toLocaleString("en-US")} rows/s ` +
|
|
2597
|
+
`(${humanBytes(rate.bytesPerSecond)}/s of the table's pages), so the next chunk is ${live.chunkPages} page(s), ~${this.copyChunkSeconds} s (MASON_COPY_CHUNK_SECONDS; at most ${ceiling})`));
|
|
2598
|
+
lineAt = Date.now();
|
|
2599
|
+
chunks = 0;
|
|
2600
|
+
chunkMs = 0;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
};
|
|
2604
|
+
// an interrupt sent before a reader's statement began is lost: sent again until every reader has stopped
|
|
2605
|
+
const again = setInterval(() => { if (failure !== null)
|
|
2606
|
+
for (const l of lanes)
|
|
2607
|
+
if (l.inFlight)
|
|
2608
|
+
l.conn.interrupt(); }, 50);
|
|
2609
|
+
try {
|
|
2610
|
+
await Promise.all(lanes.map((lane, k) => reader(lane, k)));
|
|
2611
|
+
}
|
|
2612
|
+
finally {
|
|
2613
|
+
clearInterval(again);
|
|
2614
|
+
}
|
|
2615
|
+
if (failure !== null)
|
|
2616
|
+
throw failure.error;
|
|
2617
|
+
if (recopy)
|
|
2618
|
+
await w.run("COMMIT");
|
|
2619
|
+
return { rows: p.rows, seconds: (Date.now() - started) / 1000, progress: p };
|
|
2620
|
+
}
|
|
2621
|
+
catch (e) {
|
|
2622
|
+
// a failure or a stop: nothing in flight any more (a copy that read every chunk keeps its progress to its swap, `copy`)
|
|
2623
|
+
this.copyLive.delete(t.source);
|
|
2624
|
+
if (recopy)
|
|
2625
|
+
await w.run("ROLLBACK");
|
|
2626
|
+
throw e;
|
|
2627
|
+
}
|
|
2628
|
+
finally {
|
|
2629
|
+
for (const lane of lanes.slice(1)) {
|
|
2630
|
+
this.copyLanes.delete(lane);
|
|
2631
|
+
lane.conn.closeSync();
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
/** A COPY BEGUN, in a transaction of its own: the source's snapshot read FIRST (the transaction's first read fixes the
|
|
2636
|
+
* extension's REPEATABLE READ snapshot; its xmin is the copy's watermark, as the change reader records one before its
|
|
2637
|
+
* copy), the exact numerics' widths, the side table (NO KEY: no PRIMARY KEY or UNIQUE, so no index), and the heap's
|
|
2638
|
+
* pages now (`pg_relation_size`, exact where `relpages` is an estimate) */
|
|
2639
|
+
async beginCopy(t, side, pgFrom) {
|
|
2640
|
+
const w = this.rebuilder.conn;
|
|
2641
|
+
const snapshot = this.mode !== "recopy" ? (await w.runAndReadAll(`SELECT * FROM ${pq(SNAPSHOT_SQL)}`)).getRowsJS()[0][0] : null;
|
|
2642
|
+
const described = await w.runAndReadAll(`DESCRIBE SELECT ${t.columns.map((c) => ident(c.name)).join(", ")} FROM ${SRC}.${ident(t.schema)}.${ident(t.table)}`);
|
|
2643
|
+
const scanned = new Map(described.getRowsJS().map((r) => [String(r[0]), String(r[1])]));
|
|
2644
|
+
// the numerics the extension would read as doubles: their widths, in the copy's snapshot (NUMERIC_TYPE)
|
|
2645
|
+
const exact = await this.exactNumerics(t, scanned, pgFrom);
|
|
2646
|
+
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")}`);
|
|
2647
|
+
await w.run(`CREATE SCHEMA IF NOT EXISTS ${ident(t.schema)}`);
|
|
2648
|
+
await w.run(`CREATE OR REPLACE TABLE ${side} (${defs.join(", ")})`);
|
|
2649
|
+
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]);
|
|
2650
|
+
const ceiling = chunkPages(pages, t.rows, this.copyChunkRows);
|
|
2651
|
+
const firstSize = nextChunkPages(ceiling, null, this.copyChunkSeconds);
|
|
2652
|
+
const readers = this.mode === "recopy" ? 1 : Math.min(this.copyParallel, Math.max(1, Math.ceil(pages / firstSize)));
|
|
2653
|
+
this.log(`copying ${t.source}${snapshot === null ? "" : ` as of the source's snapshot ${snapshot}: what commits after it is caught up by one poll once copied`}; ` +
|
|
2654
|
+
`${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)), ` +
|
|
2655
|
+
`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`}`);
|
|
2656
|
+
return { snapshot, columns: columnsKey(t.columns), exact, filenode: null, pages, done: [], rows: 0 };
|
|
2657
|
+
}
|
|
2658
|
+
/** what a copy was begun with, in its beginning's transaction (`CopyProgress`) */
|
|
2659
|
+
async writeBegun(t, p) {
|
|
2660
|
+
await this.rebuilder.conn.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.copy_progress VALUES (${literal(t.source)}, ${p.snapshot === null ? "NULL" : literal(p.snapshot)}, ` +
|
|
2661
|
+
`${literal(p.columns)}, ${literal(JSON.stringify([...p.exact]))}, ${p.pages}, now())`);
|
|
2662
|
+
}
|
|
2663
|
+
/** a chunk committed, in its own transaction on its reader's connection (`CopyProgress`): appended, never an update */
|
|
2664
|
+
async recordChunk(c, t, r, rows, filenode) {
|
|
2665
|
+
await c.run(`INSERT INTO ${MASON_SCHEMA}.copy_chunks VALUES (${literal(t.source)}, ${r.first}, ${r.end === null ? "NULL" : r.end}, ${rows}, ${filenode}, now())`);
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* A COPY BEGUN BEFORE, TO CONTINUE (`copy`): what it was begun with, its committed chunks and its side table, when its
|
|
2669
|
+
* columns are the ones it was begun with — said, with where it continues and the first snapshot it is held to.
|
|
2670
|
+
* Otherwise null, and what it left is dropped (`forgetCopy`), said when there was something.
|
|
2671
|
+
*/
|
|
2672
|
+
async resumable(t, side) {
|
|
2673
|
+
const w = this.rebuilder.conn;
|
|
2674
|
+
const r = (await w.runAndReadAll(`SELECT snapshot, columns, exact, pages FROM ${MASON_SCHEMA}.copy_progress WHERE source = ${literal(t.source)}`)).getRowsJS();
|
|
2675
|
+
const there = (await w.runAndReadAll(`SELECT count(*) FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = ${literal(t.schema)} AND table_name = ${literal(`${t.table}${SIDE_SUFFIX}`)}`)).getRowsJS()[0][0] === 1n;
|
|
2676
|
+
const row = r[0];
|
|
2677
|
+
if (row === undefined || row[0] === null || row[1] !== columnsKey(t.columns) || !there) {
|
|
2678
|
+
if (row !== undefined)
|
|
2679
|
+
this.log(`starting the copy of ${t.source} over, from its first page: ${!there ? "the table it was filling is gone" : row[0] === null ? "it was begun as a recopy" : "its columns are not the ones its copy was begun with"}`);
|
|
2680
|
+
return this.forgetCopy(t, side);
|
|
2681
|
+
}
|
|
2682
|
+
const chunks = (await w.runAndReadAll(`SELECT first_page, end_page, "rows", filenode FROM ${MASON_SCHEMA}.copy_chunks WHERE source = ${literal(t.source)} ORDER BY first_page`)).getRowsJS();
|
|
2683
|
+
const done = chunks.map(([first, end]) => ({ first: Number(first), end: end === null ? null : Number(end) }));
|
|
2684
|
+
const rows = chunks.reduce((n, c) => n + Number(c[2]), 0);
|
|
2685
|
+
const p = { snapshot: row[0], columns: row[1], exact: new Map(JSON.parse(row[2])), filenode: chunks.length ? Number(chunks[0][3]) : null, pages: Number(row[3]), done, rows };
|
|
2686
|
+
const at = readThrough(done);
|
|
2687
|
+
this.log(at === null ? `resuming the copy of ${t.source} with every page read (${rows} row(s) in): its swap is next`
|
|
2688
|
+
: `resuming the copy of ${t.source} at page ${at} of ${p.pages}${pastPages(done)}, ${rows} row(s) in, held to its first snapshot ${p.snapshot}: the pages read before are not read again`);
|
|
2689
|
+
const now = this.states.get(t.source);
|
|
2690
|
+
if (now !== undefined && now.phase === "copying")
|
|
2691
|
+
this.states.set(t.source, { ...now, rowsSoFar: rows, rowsEstimate: t.rows });
|
|
2692
|
+
return p;
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* A COPY IN PROGRESS RECORDED BY A MASON OF ONE READER (before copies read in parallel chunks): one progress row whose
|
|
2696
|
+
* `next_page` was the page before which every page was read (null: every page). Turned into the chunk it amounts to,
|
|
2697
|
+
* so that copy continues where it was rather than reading the source again, and the row's last three columns dropped.
|
|
2698
|
+
*/
|
|
2699
|
+
async oneReaderProgress() {
|
|
2700
|
+
const w = this.store.writer;
|
|
2701
|
+
const old = (await w.runAndReadAll(`SELECT count(*) FROM duckdb_columns() WHERE database_name = current_database() AND schema_name = ${literal(MASON_SCHEMA)} ` +
|
|
2702
|
+
`AND table_name = 'copy_progress' AND column_name = 'next_page'`)).getRowsJS()[0][0];
|
|
2703
|
+
if (old === 0n)
|
|
2704
|
+
return;
|
|
2705
|
+
await w.run("BEGIN TRANSACTION");
|
|
2706
|
+
try {
|
|
2707
|
+
const moved = Number((await w.run(`INSERT INTO ${MASON_SCHEMA}.copy_chunks SELECT source, 0, next_page, "rows", filenode, updated_at FROM ${MASON_SCHEMA}.copy_progress ` +
|
|
2708
|
+
`WHERE snapshot IS NOT NULL AND filenode IS NOT NULL AND (next_page IS NULL OR next_page > 0)`)).rowsChanged);
|
|
2709
|
+
for (const gone of ["filenode", "next_page", "rows"])
|
|
2710
|
+
await w.run(`ALTER TABLE ${MASON_SCHEMA}.copy_progress DROP COLUMN IF EXISTS ${ident(gone)}`);
|
|
2711
|
+
await w.run("COMMIT");
|
|
2712
|
+
if (moved > 0)
|
|
2713
|
+
this.log(`${moved} copy(ies) in progress were begun by a Mason that read one chunk at a time: each continues where it was`);
|
|
2714
|
+
}
|
|
2715
|
+
catch (e) {
|
|
2716
|
+
await w.run("ROLLBACK");
|
|
2717
|
+
throw e;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
/** a copy's side table and progress dropped (on the rebuilds' connection, outside a transaction): its next attempt
|
|
2721
|
+
* starts from the first page. Answers null, the progress there is now */
|
|
2722
|
+
async forgetCopy(t, side) {
|
|
2723
|
+
const w = this.rebuilder.conn;
|
|
2724
|
+
await w.run(`DROP TABLE IF EXISTS ${side}`);
|
|
2725
|
+
await w.run(`DELETE FROM ${MASON_SCHEMA}.copy_progress WHERE source = ${literal(t.source)}`);
|
|
2726
|
+
await w.run(`DELETE FROM ${MASON_SCHEMA}.copy_chunks WHERE source = ${literal(t.source)}`);
|
|
2727
|
+
return null;
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* THE COPIES IN A RETIRED LAYOUT (`RETIRED_LAYOUT`), read at `open`: a copied table that carries a primary-key or
|
|
2731
|
+
* unique constraint was stored in key order with that key's index (the earlier layout). Each is said by name and copied
|
|
2732
|
+
* again through the normal path (`needsCopy`), serving as it was until its swap; never converted in place.
|
|
2733
|
+
*/
|
|
2734
|
+
async findRetired() {
|
|
2735
|
+
const r = await this.store.writer.runAndReadAll(`SELECT DISTINCT schema_name || '.' || table_name FROM duckdb_constraints() ` +
|
|
2736
|
+
`WHERE database_name = current_database() AND schema_name <> ${literal(MASON_SCHEMA)} AND constraint_type IN ('PRIMARY KEY', 'UNIQUE')`);
|
|
2737
|
+
for (const [source] of r.getRowsJS()) {
|
|
2738
|
+
if (this.states.get(source)?.phase !== "copied")
|
|
2739
|
+
continue;
|
|
2740
|
+
this.retired.add(source);
|
|
2741
|
+
this.log(`${source}: its copy 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; the copy serves as it was until then`);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
/** what a copy of the retired layout left beside the file (in that layout a copy was staged in a file of its own,
|
|
2745
|
+
* `<file>.stage-<table>.db`, and partitioned into parquet parts beside it, `….parts`): removed, said */
|
|
2746
|
+
removeStageLeftovers() {
|
|
2747
|
+
const dir = path.dirname(this.store.file), base = `${path.basename(this.store.file)}.stage-`;
|
|
2748
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.startsWith(base))) {
|
|
2749
|
+
fs.rmSync(path.join(dir, f), { recursive: true, force: true });
|
|
2750
|
+
this.log(`removed ${f}: a copy's stage, from an older Mason (a copy now reads the source once, straight into its table)`);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
/** The table's numerics the extension would read as doubles, each with the DECIMAL(38, s) scale that holds every value
|
|
2754
|
+
* (NUMERIC_TYPE); one whose values do not fit is left out (a double, said). */
|
|
2755
|
+
async exactNumerics(t, scanned, pgFrom) {
|
|
2756
|
+
const wide = t.columns.filter((c) => readsAsDouble(c.type) && scanned.get(c.name) === "DOUBLE");
|
|
2757
|
+
const out = new Map();
|
|
2758
|
+
if (wide.length === 0)
|
|
2759
|
+
return out;
|
|
2760
|
+
const parts = wide.map((c) => {
|
|
2761
|
+
const v = pgIdent(c.name);
|
|
2762
|
+
const finite = `${v}::text NOT IN ('NaN', 'Infinity', '-Infinity')`;
|
|
2763
|
+
return `max(scale(${v})) FILTER (WHERE ${finite}), min(scale(${v})) FILTER (WHERE ${finite}), max(scale(trim_scale(${v}))) FILTER (WHERE ${finite}), ` +
|
|
2764
|
+
`max(length(trunc(abs(${v}))::text)) FILTER (WHERE ${finite}), count(*) FILTER (WHERE NOT ${finite})`;
|
|
2765
|
+
});
|
|
2766
|
+
// in the copy's transaction, on the rebuilds' connection: its snapshot
|
|
2767
|
+
const row = (await this.rebuilder.conn.runAndReadAll(`SELECT * FROM ${pq(`SELECT ${parts.join(", ")} FROM ${pgFrom}`)}`)).getRowsJS()[0];
|
|
2768
|
+
wide.forEach((c, k) => {
|
|
2769
|
+
const [maxScale, minScale, used, digits, special] = row.slice(5 * k, 5 * k + 5).map((x) => x === null ? null : Number(x));
|
|
2770
|
+
const declared = NUMERIC_TYPE.exec(c.type);
|
|
2771
|
+
const scale = declared[1] !== undefined ? used ?? 0 : maxScale ?? 0;
|
|
2772
|
+
const width = (digits ?? 1) + scale;
|
|
2773
|
+
if ((special ?? 0) > 0) {
|
|
2774
|
+
this.log(`${t.source}.${c.name}: ${special} value(s) NaN or Infinity, which DuckDB's DECIMAL cannot hold: copied as a double`);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (scale > 38 || width > 38) {
|
|
2778
|
+
this.log(`${t.source}.${c.name}: values of ${digits ?? 1} digit(s) and ${scale} decimal(s) do not fit DuckDB's DECIMAL(38): copied as a double (15 significant digits)`);
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
if (declared[1] === undefined && minScale !== null && minScale !== maxScale)
|
|
2782
|
+
this.log(`${t.source}.${c.name}: values of ${minScale} to ${maxScale} decimals: Mason answers each with ${scale}, Postgres with its own`);
|
|
2783
|
+
out.set(c.name, scale);
|
|
2784
|
+
});
|
|
2785
|
+
return out;
|
|
2786
|
+
}
|
|
2787
|
+
/** the exact numerics (NUMERIC_TYPE) of a copied table, from the copy's own DECIMAL columns (on the connection of the
|
|
2788
|
+
* caller's transaction) */
|
|
2789
|
+
async exactOf(t, c = this.store.writer) {
|
|
2790
|
+
const rows = (await c.runAndReadAll(`SELECT column_name, numeric_scale FROM information_schema.columns WHERE table_catalog = current_database() AND table_schema = ${literal(t.schema)} AND table_name = ${literal(t.table)} AND data_type LIKE 'DECIMAL(38,%'`)).getRowsJS();
|
|
2791
|
+
const numerics = new Set(t.columns.filter((c) => readsAsDouble(c.type)).map((c) => c.name));
|
|
2792
|
+
return new Map(rows.filter(([name]) => numerics.has(name)).map(([name, scale]) => [name, Number(scale)]));
|
|
2793
|
+
}
|
|
2794
|
+
/** the state, kept and written to the ledger, on the connection of the flow that knows it (awaited, and on the
|
|
2795
|
+
* writer only on its turn: a statement on a connection lands inside the transaction open there). The table's row is
|
|
2796
|
+
* that flow's alone while it writes it (a rebuild's table is not polled or streamed until caught up) */
|
|
2797
|
+
async record(s, columns, c = this.store.writer) {
|
|
2798
|
+
this.states.set(s.source, s);
|
|
2799
|
+
const v = (x) => x === null ? "NULL" : typeof x === "number" ? String(x) : literal(x);
|
|
2800
|
+
const sql = `INSERT OR REPLACE INTO ${MASON_SCHEMA}.copy_state (source, phase, "rows", bytes, seconds, copied_at, error, columns) VALUES (${literal(s.source)}, ${literal(s.phase)}, ${v(s.rows)}, ${v(s.bytes)}, ${v(s.seconds)}, ` +
|
|
2801
|
+
`${s.copiedAt === null ? "NULL" : `${literal(s.copiedAt)}::TIMESTAMPTZ`}, ${v(s.error)}, ${columns ? `${literal(JSON.stringify(columns))}::JSON` : `(SELECT columns FROM ${MASON_SCHEMA}.copy_state WHERE source = ${literal(s.source)})`})`;
|
|
2802
|
+
// the ledger is the record the commands read: a failed write is said, never silent, and the copy itself stands
|
|
2803
|
+
try {
|
|
2804
|
+
await c.run(sql);
|
|
2805
|
+
}
|
|
2806
|
+
catch (e) {
|
|
2807
|
+
this.log(`recording ${s.source}'s copy state failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
/** the catalog rebuilt (`schemaBuilt`), after any rebuild already under way */
|
|
2811
|
+
rebuildSchema() {
|
|
2812
|
+
// after the one before, whether it succeeded or failed (its failure is its own caller's to say)
|
|
2813
|
+
const next = this.schemaBuild.then(() => this.schemaBuilt(), () => this.schemaBuilt());
|
|
2814
|
+
this.schemaBuild = next;
|
|
2815
|
+
return next;
|
|
2816
|
+
}
|
|
2817
|
+
/** the catalog's tables: every copied table as DuckDB holds it, typed as the source spells it where the wire agrees */
|
|
2818
|
+
async schemaBuilt() {
|
|
2819
|
+
// its own connection, which sees what is committed: never the writer, which a copy's transaction may hold for hours
|
|
2820
|
+
const c = this.catalog ?? this.store.writer;
|
|
2821
|
+
const r = await c.runAndReadAll(`SELECT source, columns FROM ${MASON_SCHEMA}.copy_state WHERE phase = 'copied'`);
|
|
2822
|
+
const copied = new Map(r.getRowsJS().map(([s, c]) => [s, c === null ? [] : JSON.parse(c)]));
|
|
2823
|
+
const tables = [];
|
|
2824
|
+
for (const t of this.tables.values()) {
|
|
2825
|
+
const cols = copied.get(t.source);
|
|
2826
|
+
if (cols === undefined)
|
|
2827
|
+
continue;
|
|
2828
|
+
const types = await c.prepare(`SELECT * FROM ${ident(t.schema)}.${ident(t.table)} LIMIT 0`);
|
|
2829
|
+
const duckTypes = new Map(Array.from({ length: types.columnCount }, (_, i) => [types.columnName(i), types.columnType(i)]));
|
|
2830
|
+
types.destroySync();
|
|
2831
|
+
// the column's constraints and default — and below the table's keys and unique indexes — as the source had them
|
|
2832
|
+
// when the tables were last set (`setTables`: at mason start, and at mason add / remove; the run's 10-second
|
|
2833
|
+
// catalog read reaches the commands' facts, not this): what the copy recorded describes its types, which a
|
|
2834
|
+
// change of recopies
|
|
2835
|
+
const now = new Map(t.columns.map((c) => [c.name, c]));
|
|
2836
|
+
const columns = cols.filter((c) => duckTypes.has(c.name)).map((c) => pgColumn(c, duckTypes.get(c.name), now.get(c.name)));
|
|
2837
|
+
const joins = t.foreignKeys.filter((f) => this.tables.has(f.target) && columns.some((c) => c.name === f.column))
|
|
2838
|
+
.map((f) => ({ name: f.target.split(".")[1], table: f.target, on: [f.column, f.targetColumn], relationship: "many_to_one" }));
|
|
2839
|
+
// a unique index over columns the copy keeps: the catalog's too, so a foreign key to it is one (and pg_indexes lists it)
|
|
2840
|
+
const uniqueKeys = (t.uniqueKeys ?? []).filter((u) => u.columns.every((c) => columns.some((x) => x.name === c)));
|
|
2841
|
+
tables.push({ name: t.table, schema: t.schema, primaryKey: t.primaryKey ?? t.key, columns, joins,
|
|
2842
|
+
...(uniqueKeys.length ? { uniqueKeys } : {}),
|
|
2843
|
+
...(t.rowTypeOid !== undefined && columns.length === cols.length ? { rowTypeOid: t.rowTypeOid } : {}) });
|
|
2844
|
+
}
|
|
2845
|
+
const views = this.views;
|
|
2846
|
+
const key = JSON.stringify([tables, views]);
|
|
2847
|
+
if (key !== this.schemaKey) {
|
|
2848
|
+
this.schemaKey = key;
|
|
2849
|
+
this.currentSchema = { database: this.database, tables, ...(views.length > 0 ? { views } : {}) };
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
const KEPT_ARRAYS = new Set(["json[]", "jsonb[]", "inet[]", "citext[]"]);
|
|
2854
|
+
/** the columns of a DuckDB table stored as a LIST of ENUM (`pgColumnSql`), on the connection of the caller's
|
|
2855
|
+
* transaction: an enum array is named by its stored type, never its source spelling (`mood[]`, `other.level[]`) */
|
|
2856
|
+
async function enumLists(c, table) {
|
|
2857
|
+
return new Set((await c.runAndReadAll(`DESCRIBE ${table}`)).getRowsJS().filter((r) => /^ENUM\(.*\)\[\]$/s.test(String(r[1]))).map((r) => String(r[0])));
|
|
2858
|
+
}
|
|
2859
|
+
/** a column as `postgres_query` reads it: an exact numeric as its text (DECIMAL takes it digit for digit); a kept array
|
|
2860
|
+
* as text[] — the extension reads a query's citext[] or inet[] as one VARCHAR, a table's as a VARCHAR[] (the corpus
|
|
2861
|
+
* run of fbbf8f08: a table with both an unbounded numeric and a citext[] could not be copied) — and so is an enum
|
|
2862
|
+
* array (`enumLists`: one VARCHAR, '{sad,ok}', which DuckDB cannot cast to its ENUM(...)[]; text[] it casts element by
|
|
2863
|
+
* element). Found once every chunk of a copy read through `postgres_query`: the oracle's enum array failed to
|
|
2864
|
+
* copy; the poll's read of a changed one had failed the same way */
|
|
2865
|
+
function pgColumnSql(c, exact, lists) {
|
|
2866
|
+
if (exact.has(c.name))
|
|
2867
|
+
return `${pgIdent(c.name)}::text AS ${pgIdent(c.name)}`;
|
|
2868
|
+
if (KEPT_ARRAYS.has(c.type) || lists.has(c.name))
|
|
2869
|
+
return `${pgIdent(c.name)}::text[] AS ${pgIdent(c.name)}`;
|
|
2870
|
+
return pgIdent(c.name);
|
|
2871
|
+
}
|
|
2872
|
+
/** the OID family a source type is sent in (text for every character type: DuckDB has one VARCHAR) */
|
|
2873
|
+
function sourceOid(spelled) {
|
|
2874
|
+
const array = spelled.endsWith("[]");
|
|
2875
|
+
const base = spelled.replace(/\[\]$/, "").replace(/\(.*\)/, "").trim();
|
|
2876
|
+
const oid = {
|
|
2877
|
+
smallint: 21, integer: 23, bigint: 20, real: 700, "double precision": 701, numeric: 1700, text: 25, "character varying": 25, character: 25,
|
|
2878
|
+
boolean: 16, uuid: 2950, date: 1082, "time without time zone": 1083, "timestamp without time zone": 1114, "timestamp with time zone": 1184,
|
|
2879
|
+
interval: 1186, json: 3802, jsonb: 3802, bytea: 17,
|
|
2880
|
+
// held as DuckDB text, declared as themselves: the catalog Postgres resolves their operators (citext's
|
|
2881
|
+
// case-insensitive ones) and the wire sends their OID (src/duck/types.ts)
|
|
2882
|
+
inet: 25, citext: 25,
|
|
2883
|
+
};
|
|
2884
|
+
const o = oid[base];
|
|
2885
|
+
if (o === undefined)
|
|
2886
|
+
return null;
|
|
2887
|
+
return array ? -o : o;
|
|
2888
|
+
}
|
|
2889
|
+
/** the Postgres spelling of the wire's OID: what the catalog says when the source's spelling would disagree with the wire */
|
|
2890
|
+
const SPELLING_OF_OID = {
|
|
2891
|
+
21: "smallint", 23: "integer", 20: "bigint", 700: "real", 701: "double precision", 1700: "numeric", 25: "text", 16: "boolean", 2950: "uuid",
|
|
2892
|
+
1082: "date", 1083: "time without time zone", 1114: "timestamp without time zone", 1184: "timestamp with time zone", 1186: "interval", 3802: "jsonb", 17: "bytea",
|
|
2893
|
+
};
|
|
2894
|
+
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 };
|
|
2895
|
+
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" };
|
|
2896
|
+
/** an ENUM's labels, of the column or of its list's element; null for any other type */
|
|
2897
|
+
function enumLabelsOf(t) {
|
|
2898
|
+
const own = t instanceof DuckDBListType ? t.valueType : t;
|
|
2899
|
+
return own instanceof DuckDBEnumType ? own.values : null;
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* A copied column in the catalog: the SOURCE's spelling (`numeric(12,2)`, `character varying(255)`) wherever the wire
|
|
2903
|
+
* sends that family of type, so a tool sees the source's own types; the wire's type where they differ (an unconstrained
|
|
2904
|
+
* `numeric` the extension reads as DOUBLE is `double precision`, an enum is `text`).
|
|
2905
|
+
*/
|
|
2906
|
+
function pgColumn(c, duck, now) {
|
|
2907
|
+
const wire = wireColumn(c.name, duck).oid;
|
|
2908
|
+
const element = ELEMENT_OF_ARRAY[wire];
|
|
2909
|
+
const wireFamily = element !== undefined ? -element : wire;
|
|
2910
|
+
// an array DuckDB holds as VARCHAR[] whose elements the wire writes as the source's type (src/duck/types.ts: a json's
|
|
2911
|
+
// text, a jsonb's canonical text, an inet's output, a citext's bytes): declared as the source spells it
|
|
2912
|
+
// an enum (or an array of one) DuckDB holds as its ENUM, in the declared order: declared as the source's type, with
|
|
2913
|
+
// its labels, so Postgres orders and compares it by them
|
|
2914
|
+
const labels = enumLabelsOf(duck);
|
|
2915
|
+
const spelled = labels !== null || sourceOid(c.type) === wireFamily || (wire === 1009 && KEPT_ARRAYS.has(c.type)) ? c.type
|
|
2916
|
+
: element !== undefined ? `${SPELLING_OF_OID[element] ?? "text"}[]` : SPELLING_OF_OID[wire] ?? "text";
|
|
2917
|
+
// `type` is the ClickHouse path's; the catalog reads `spelled`
|
|
2918
|
+
const type = API_TYPE[wire] ?? "text";
|
|
2919
|
+
const facts = now ?? c;
|
|
2920
|
+
return { name: c.name, type, spelled, ...(c.comment ? { description: c.comment } : {}), ...(labels !== null ? { enumLabels: labels } : {}),
|
|
2921
|
+
...(facts.notNull ? { notNull: true } : {}), ...(facts.default !== undefined ? { default: facts.default } : {}),
|
|
2922
|
+
...(facts.identity !== undefined ? { identity: facts.identity } : {}), ...(facts.sequence !== undefined ? { sequence: facts.sequence } : {}),
|
|
2923
|
+
...(c.typeOid !== undefined && spelled === c.type ? { typeOid: c.typeOid, ...(c.typeArrayOid !== undefined ? { typeArrayOid: c.typeArrayOid } : {}) } : {}) };
|
|
2924
|
+
}
|
|
2925
|
+
/** a span as the stream's line says it: `2.1 s`, `3 min`, `2 h` */
|
|
2926
|
+
function secondsText(ms) {
|
|
2927
|
+
const sec = Math.max(0, ms) / 1000;
|
|
2928
|
+
return sec < 90 ? `${sec.toFixed(1)} s` : sec < 5400 ? `${Math.round(sec / 60)} min` : `${Math.round(sec / 3600)} h`;
|
|
2929
|
+
}
|