@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,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DUCKDB COPY KEPT CURRENT BY LOGICAL REPLICATION (replication for the DuckDB case, only to sync the copied tables).
|
|
3
|
+
* `mason start --replication` on Mason's own publication and
|
|
4
|
+
* slot on the developer's database (src/cli/replication-mode.ts), the slot's `pgoutput` stream (src/cdc/pgoutput.ts,
|
|
5
|
+
* over src/cdc/pg-wire.ts) applied to the copied tables (src/duck/sync.ts `applyChanges`) instead of polling them.
|
|
6
|
+
*
|
|
7
|
+
* WHY REPLAYING IS SAFE. Every change of a table with a primary key is a whole row set at its key, or a key removed,
|
|
8
|
+
* so the changes of a key applied in commit order end at the key's state whatever state they started from. The copy
|
|
9
|
+
* and the stream may therefore overlap: a change the copy already holds is applied again, and the row passes back
|
|
10
|
+
* through its older states until the stream reaches the copy's snapshot (a reader may see one of them for that long).
|
|
11
|
+
* What may NOT happen is a gap — and the stream runs WHILE tables are copied (a copy runs beside the
|
|
12
|
+
* stream, never instead of it), acknowledging positions as it goes, so the changes of a table it does not stream yet
|
|
13
|
+
* are skipped and never come again. So a table JOINS the stream only once caught up (Sync `swap`, `catchUp`):
|
|
14
|
+
* 1. at a moment J, between two applied batches (the writer's turn), the stream starts decoding the table's changes
|
|
15
|
+
* and HOLDING them, applying none;
|
|
16
|
+
* 2. ONE poll from the table's watermark reads, in a source snapshot P taken after J, every row changed since it. The
|
|
17
|
+
* watermark is at or before the first change the stream could have skipped: a copy's snapshot is pinned before
|
|
18
|
+
* its rows are read, and the stream skipped the table only from the copy's start (a re-copy: from when it left);
|
|
19
|
+
* a slot new to the copy carries nothing from before it, and the watermark is the table's last poll. Every skipped
|
|
20
|
+
* change committed before J, so before P: it is in what the poll writes;
|
|
21
|
+
* 3. the held changes — every change decoded after J — are applied after the poll, in commit order, and from then on
|
|
22
|
+
* the stream applies the table's changes as they come. Those between J and P are in both, which is the overlap
|
|
23
|
+
* above: safe.
|
|
24
|
+
* A table being copied again leaves the stream first (its changes are skipped from then on, and its new copy's
|
|
25
|
+
* snapshot is after that), then joins the same way. Every table's lookup is made at each change
|
|
26
|
+
* (`StreamTarget.tables`), so a table joins or leaves without a restart.
|
|
27
|
+
* Held by tests/integration/duck-stream.itest.ts: changes committed while a table copies are in DuckDB once it is
|
|
28
|
+
* caught up, the stream never stopped.
|
|
29
|
+
*
|
|
30
|
+
* EXCEPT AN UNCHANGED TOAST VALUE: an UPDATE that leaves a large value as it was carries no value for it (pgoutput's
|
|
31
|
+
* `u`). The row DuckDB holds keeps its own; a row DuckDB does not hold (the copy had it deleted, or a TRUNCATE in the
|
|
32
|
+
* batch) is read from the source by its key, its value as it is now — later changes in the stream bring it on.
|
|
33
|
+
*
|
|
34
|
+
* WHAT IS ACKNOWLEDGED: a batch of whole transactions is applied in ONE DuckDB transaction with the position it ends
|
|
35
|
+
* at (`_mason.stream_state`), and only then is the position sent to Postgres (`sendStatus`). A run that dies between
|
|
36
|
+
* reads the unacknowledged tail again, and skips the commits its own record says it applied.
|
|
37
|
+
*
|
|
38
|
+
* A LARGE TRANSACTION IS APPLIED IN PIECES (a migration's UPDATE of tens of millions of rows is one
|
|
39
|
+
* transaction). Folded whole, it sat in Node's heap until its commit, then went into DuckDB as one transaction, whose
|
|
40
|
+
* own state grows until the commit (`PIECE_CHANGES` says what was measured). So once `flushRows` changes are folded
|
|
41
|
+
* inside a transaction they are applied and COMMITTED without a position (`apply(batch, null)`), and the fold starts
|
|
42
|
+
* again; the position is recorded, and acknowledged, only with the transaction's last piece. A run that dies between
|
|
43
|
+
* pieces has recorded no commit at or past this one, so it reads the transaction again from its BEGIN and applies it
|
|
44
|
+
* whole: what the earlier pieces wrote is written again, which replaying makes safe (above). A piece's partial row (an
|
|
45
|
+
* unchanged-TOAST update) whose earlier row went in an earlier piece is applied as an UPDATE of the columns it carries,
|
|
46
|
+
* as one whose row the copy holds always is.
|
|
47
|
+
*/
|
|
48
|
+
import { decode } from "../cdc/pgoutput.js";
|
|
49
|
+
import { PgConnection, formatLsn, pgIdent, pgLiteral } from "../cdc/pg-wire.js";
|
|
50
|
+
const lit = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
51
|
+
/**
|
|
52
|
+
* A DECODED TUPLE AS THE COPY'S COLUMNS (`columns`, the copy's order), from the relation's: a column the publication sends
|
|
53
|
+
* and the copy lacks is dropped; a column of the copy the tuple leaves unchanged (TOAST) is `undefined`, kept by the row
|
|
54
|
+
* DuckDB holds. pgoutput's relation is what ARRIVES, never what is written: the copy's own record decides a write's
|
|
55
|
+
* shape (the test server: a held change written in the shape of the moment named a column its copy did not have).
|
|
56
|
+
*/
|
|
57
|
+
export function projectTuple(relation, tuple, columns) {
|
|
58
|
+
return columns.map((name) => {
|
|
59
|
+
const at = relation.findIndex((c) => c.name === name);
|
|
60
|
+
if (at < 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
const v = tuple[at];
|
|
63
|
+
return v.kind === "text" ? v.text : v.kind === "null" ? null : undefined;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** the copy's columns a relation does not carry, but for those Postgres never publishes (`generated`, read from the
|
|
67
|
+
* source instead): what refuses the table (`StreamTarget.refuse`) */
|
|
68
|
+
export function missingFrom(relation, table) {
|
|
69
|
+
return table.columns.filter((name) => !relation.some((c) => c.name === name) && !(table.generated ?? []).includes(name));
|
|
70
|
+
}
|
|
71
|
+
export function pgArrayTree(text) {
|
|
72
|
+
let s = text;
|
|
73
|
+
const eq = s.indexOf("=");
|
|
74
|
+
if (s.startsWith("[") && eq > 0)
|
|
75
|
+
s = s.slice(eq + 1);
|
|
76
|
+
let i = 0;
|
|
77
|
+
const fail = () => { throw new Error(`malformed array literal: ${text}`); };
|
|
78
|
+
const space = () => { while (i < s.length && /\s/.test(s[i]))
|
|
79
|
+
i++; };
|
|
80
|
+
const list = () => {
|
|
81
|
+
if (s[i] !== "{")
|
|
82
|
+
fail();
|
|
83
|
+
i++;
|
|
84
|
+
const out = [];
|
|
85
|
+
space();
|
|
86
|
+
if (s[i] === "}") {
|
|
87
|
+
i++;
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
for (;;) {
|
|
91
|
+
space();
|
|
92
|
+
if (s[i] === "{")
|
|
93
|
+
out.push(list());
|
|
94
|
+
else if (s[i] === '"') {
|
|
95
|
+
i++;
|
|
96
|
+
let v = "";
|
|
97
|
+
while (i < s.length && s[i] !== '"') {
|
|
98
|
+
if (s[i] === "\\")
|
|
99
|
+
i++;
|
|
100
|
+
v += s[i++];
|
|
101
|
+
}
|
|
102
|
+
if (s[i] !== '"')
|
|
103
|
+
fail();
|
|
104
|
+
i++;
|
|
105
|
+
out.push(v);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
let v = "";
|
|
109
|
+
while (i < s.length && s[i] !== "," && s[i] !== "}")
|
|
110
|
+
v += s[i++];
|
|
111
|
+
v = v.trim();
|
|
112
|
+
out.push(v.toUpperCase() === "NULL" ? null : v);
|
|
113
|
+
}
|
|
114
|
+
space();
|
|
115
|
+
if (s[i] === ",") {
|
|
116
|
+
i++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (s[i] === "}") {
|
|
120
|
+
i++;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
fail();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
space();
|
|
127
|
+
const tree = list();
|
|
128
|
+
space();
|
|
129
|
+
if (i !== s.length)
|
|
130
|
+
fail();
|
|
131
|
+
return tree;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A Postgres value's TEXT (as pgoutput sends it) as a DuckDB expression of the copy's column type (`DESCRIBE`'s
|
|
135
|
+
* spelling): what the copy stores for the same value, held by tests/integration/duck-stream.itest.ts against a copy
|
|
136
|
+
* of every type the oracle's type matrix has. A bytea is Postgres's hex output (`\x…`), an array its `{…}` literal.
|
|
137
|
+
*/
|
|
138
|
+
export function duckValue(text, type) {
|
|
139
|
+
if (text === null)
|
|
140
|
+
return "NULL";
|
|
141
|
+
if (type.endsWith("[]"))
|
|
142
|
+
return duckList(pgArrayTree(text), type);
|
|
143
|
+
if (type === "VARCHAR")
|
|
144
|
+
return lit(text);
|
|
145
|
+
if (type === "BLOB") {
|
|
146
|
+
if (!text.startsWith("\\x"))
|
|
147
|
+
throw new Error(`a bytea arrived as ${JSON.stringify(text.slice(0, 20))}, not in hex (bytea_output must be hex)`);
|
|
148
|
+
return `unhex(${lit(text.slice(2))})`;
|
|
149
|
+
}
|
|
150
|
+
if (type === "INTERVAL" && text.startsWith("P"))
|
|
151
|
+
return `CAST(${lit(intervalWords(text))} AS INTERVAL)`;
|
|
152
|
+
return `CAST(${lit(text)} AS ${type})`;
|
|
153
|
+
}
|
|
154
|
+
const ISO_INTERVAL = /^P(?:(-?[\d.]+)Y)?(?:(-?[\d.]+)M)?(?:(-?[\d.]+)W)?(?:(-?[\d.]+)D)?(?:T(?:(-?[\d.]+)H)?(?:(-?[\d.]+)M)?(?:(-?[\d.]+)S)?)?$/;
|
|
155
|
+
/**
|
|
156
|
+
* An interval as Mason's connections spell it (IntervalStyle iso_8601, src/cdc/pg-wire.ts: `P-10M-3DT-4H-5M-6.789S`) in
|
|
157
|
+
* the words DuckDB's INTERVAL reads (`-10 months -3 days -4 hours -5 minutes -6.789 seconds`), each part with its sign
|
|
158
|
+
*/
|
|
159
|
+
export function intervalWords(text) {
|
|
160
|
+
const m = ISO_INTERVAL.exec(text);
|
|
161
|
+
if (m === null || text === "P" || text.endsWith("T"))
|
|
162
|
+
throw new Error(`an interval arrived as ${JSON.stringify(text)}, which is not ISO 8601`);
|
|
163
|
+
const [, y, mo, w, d, h, mi, sec] = m;
|
|
164
|
+
const days = w === undefined ? d : String(Number(w) * 7 + Number(d ?? 0));
|
|
165
|
+
const parts = [[y, "years"], [mo, "months"], [days, "days"], [h, "hours"], [mi, "minutes"], [sec, "seconds"]]
|
|
166
|
+
.filter(([v]) => v !== undefined).map(([v, unit]) => `${v} ${unit}`);
|
|
167
|
+
return parts.length ? parts.join(" ") : "0 seconds";
|
|
168
|
+
}
|
|
169
|
+
function duckList(tree, type) {
|
|
170
|
+
const element = type.slice(0, -2);
|
|
171
|
+
const items = tree.map((x) => Array.isArray(x) ? duckList(x, element) : duckValue(x, element));
|
|
172
|
+
return `CAST([${items.join(", ")}] AS ${type})`;
|
|
173
|
+
}
|
|
174
|
+
export const noCounts = () => ({ inserts: 0, updates: 0, deletes: 0, truncates: 0 });
|
|
175
|
+
export function addCounts(to, from) {
|
|
176
|
+
to.inserts += from.inserts;
|
|
177
|
+
to.updates += from.updates;
|
|
178
|
+
to.deletes += from.deletes;
|
|
179
|
+
to.truncates += from.truncates;
|
|
180
|
+
}
|
|
181
|
+
/** `1,204 changes (1,000 inserts, 200 updates, 4 deletes)`: what the log and `mason status` say of a table's counts */
|
|
182
|
+
export function countsText(c) {
|
|
183
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
184
|
+
const total = c.inserts + c.updates + c.deletes + c.truncates;
|
|
185
|
+
const parts = [[c.inserts, "insert"], [c.updates, "update"], [c.deletes, "delete"], [c.truncates, "truncate"]]
|
|
186
|
+
.filter(([k]) => k > 0).map(([k, w]) => `${n(k)} ${w}${k === 1 ? "" : "s"}`);
|
|
187
|
+
return `${n(total)} change${total === 1 ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
|
|
188
|
+
}
|
|
189
|
+
/** a table's changes, none yet */
|
|
190
|
+
export const noChanges = (columns = null) => ({ columns, truncated: false, keys: new Map(), counts: noCounts() });
|
|
191
|
+
/** The change folded into what the batch holds for its key: a later whole row or delete replaces it, a later partial
|
|
192
|
+
* row keeps the earlier row's values where it carries none. */
|
|
193
|
+
export function fold(t, key, change) {
|
|
194
|
+
const k = JSON.stringify(key);
|
|
195
|
+
const was = t.keys.get(k)?.change;
|
|
196
|
+
if (change.kind === "row" && !change.refetch && change.values.includes(undefined) && was?.kind === "row" && !was.refetch) {
|
|
197
|
+
change = { kind: "row", values: change.values.map((v, i) => v === undefined ? was.values[i] : v) };
|
|
198
|
+
}
|
|
199
|
+
t.keys.set(k, { key, change });
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* THE MOST CHANGES ONE DUCKDB TRANSACTION OF THE STREAM APPLIES: a batch is applied once it holds this many, and a
|
|
203
|
+
* transaction of more is applied in pieces of this many (the module's header). In Node's heap, 607,000 changes of one
|
|
204
|
+
* transaction folded whole ran a sync out of 96 MB; in pieces of this many it ran in 48 MB
|
|
205
|
+
* (tests/integration/duck-stream.itest.ts).
|
|
206
|
+
*
|
|
207
|
+
* WHAT A PIECE COSTS DUCKDB (measured on DuckDB 2.0.0, a laptop's NVMe, warm). No copy has an index
|
|
208
|
+
* (src/duck/sync.ts `copy`), so a piece is written by key: its keys deleted by ONE join against the table's
|
|
209
|
+
* key column, then its rows inserted, and its unchanged-TOAST rows set by ONE `UPDATE … FROM` (src/duck/sync.ts
|
|
210
|
+
* `writeByKey`, `updateCarried`) — each a scan of the key column, the price of no index. 10,000 changes into 1 M uuid
|
|
211
|
+
* rows at 64 MB: 4 ms; into 10 M at 2 GB: 20 ms; into 195 M at 2.7 GiB (the test server's table and limit): 0.35–0.86 s to
|
|
212
|
+
* delete, 1.5–1.8 s to update — and on the test server, whose 3 GB key column does not fit DuckDB's 2.7 GiB and whose EBS
|
|
213
|
+
* volume reads 125 MB/s, a scan can be tens of seconds. So a table over the poll's row limit is applied by TIME as well
|
|
214
|
+
* (`applySize`). When the copy had a primary key, the key set this number: a deleted key pinned its part of the index
|
|
215
|
+
* until the commit (at 64 MB on 1 M uuid keys, 50,000 deletes in one transaction ran out after 15,500), and a write of
|
|
216
|
+
* random keys needed the whole index resident.
|
|
217
|
+
*/
|
|
218
|
+
export const PIECE_CHANGES = 10_000;
|
|
219
|
+
/**
|
|
220
|
+
* A BATCH THAT CHANGES A BIG TABLE (one over src/duck/sync.ts POLL_ROW_LIMIT, `big` in `StreamTarget.tables`) is
|
|
221
|
+
* applied at most every `BIG_APPLY_MS`, and a transaction's piece holds up to `BIG_PIECE_CHANGES`: each application
|
|
222
|
+
* scans that table's key column (`PIECE_CHANGES` says what that costs), so fewer, larger ones — a 12 M-row migration is
|
|
223
|
+
* 240 scans where it would be 1,200. The other tables in such a batch wait with it (seconds of staleness, said in
|
|
224
|
+
* docs/DESIGN.md); the piece's rows in Node's heap are five times a small table's, which the 48 MB a piece of
|
|
225
|
+
* 10,000 ran in leaves room for.
|
|
226
|
+
*/
|
|
227
|
+
export const BIG_APPLY_MS = 5_000;
|
|
228
|
+
export const BIG_PIECE_CHANGES = 50_000;
|
|
229
|
+
/** how long a batch may wait and how many changes it may hold before it is applied (`flushMs` / `flushRows` for one
|
|
230
|
+
* of small tables; at least `BIG_APPLY_MS` / `BIG_PIECE_CHANGES` once it holds a big table's change) */
|
|
231
|
+
export function applySize(big, flushMs, flushRows) {
|
|
232
|
+
return big ? { ms: Math.max(flushMs, BIG_APPLY_MS), rows: Math.max(flushRows, BIG_PIECE_CHANGES) } : { ms: flushMs, rows: flushRows };
|
|
233
|
+
}
|
|
234
|
+
/** A later batch's changes of a table folded into what is held of it (Sync `catchUp`): what they mean once applied in
|
|
235
|
+
* commit order after it, as `fold` does within a batch */
|
|
236
|
+
export function foldInto(held, later) {
|
|
237
|
+
held.columns ??= later.columns;
|
|
238
|
+
if (later.truncated) {
|
|
239
|
+
held.truncated = true;
|
|
240
|
+
held.keys.clear();
|
|
241
|
+
}
|
|
242
|
+
for (const { key, change } of later.keys.values())
|
|
243
|
+
fold(held, key, change);
|
|
244
|
+
addCounts(held.counts, later.counts);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The stream: START_REPLICATION from the slot's confirmed position, each transaction's changes folded per table and
|
|
248
|
+
* key, a batch applied once it has waited `flushMs` (or holds `flushRows` changes) and ends at a commit, its position
|
|
249
|
+
* then acknowledged; a transaction of more than `flushRows` changes is applied in pieces of that many (the module's
|
|
250
|
+
* header). A batch holding a big table's change waits and holds more (`applySize`). The source going away is waited out (a reconnect resumes from the slot); anything else ends the
|
|
251
|
+
* stream with its error (`done`), for the sync to say and act on.
|
|
252
|
+
*/
|
|
253
|
+
export class ReplicationStream {
|
|
254
|
+
repl = null;
|
|
255
|
+
stopping = false;
|
|
256
|
+
acked = 0n;
|
|
257
|
+
/** the furthest the server has said its WAL is, with nothing of ours pending: what `caughtUp` compares with */
|
|
258
|
+
seen = 0n;
|
|
259
|
+
done;
|
|
260
|
+
connect;
|
|
261
|
+
slot;
|
|
262
|
+
publication;
|
|
263
|
+
target;
|
|
264
|
+
log;
|
|
265
|
+
flushMs;
|
|
266
|
+
flushRows;
|
|
267
|
+
constructor(o) {
|
|
268
|
+
this.connect = o.connect;
|
|
269
|
+
this.slot = o.slot;
|
|
270
|
+
this.publication = o.publication;
|
|
271
|
+
this.target = o.target;
|
|
272
|
+
this.log = o.log;
|
|
273
|
+
this.flushMs = o.flushMs ?? 500;
|
|
274
|
+
this.flushRows = o.flushRows ?? PIECE_CHANGES;
|
|
275
|
+
this.acked = this.target.applied();
|
|
276
|
+
this.done = this.run();
|
|
277
|
+
}
|
|
278
|
+
/** the stream ended, its changes not yet applied dropped (the slot sends them again at the next start) */
|
|
279
|
+
async stop() {
|
|
280
|
+
this.stopping = true;
|
|
281
|
+
this.repl?.close();
|
|
282
|
+
await this.done;
|
|
283
|
+
}
|
|
284
|
+
/** the position applied or passed over: everything the source had written at `lsn` is in DuckDB once it is past it */
|
|
285
|
+
position() { return this.seen > this.acked ? this.seen : this.acked; }
|
|
286
|
+
/** ask the server where it is now: it answers with a keepalive at once (`caughtUp` waits on it) */
|
|
287
|
+
poke() {
|
|
288
|
+
try {
|
|
289
|
+
this.repl?.sendStatus(this.acked, true);
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
this.log(`replication: could not ask the source for its position (${e.message})`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async run() {
|
|
296
|
+
let attempts = 0;
|
|
297
|
+
while (!this.stopping) {
|
|
298
|
+
try {
|
|
299
|
+
this.repl = await this.connect();
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
if (this.stopping)
|
|
303
|
+
break;
|
|
304
|
+
const wait = Math.min(30_000, 1_000 * 2 ** Math.min(attempts++, 5));
|
|
305
|
+
this.log(`replication: the source is away (${e.message}); trying again in ${wait / 1000} s`);
|
|
306
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// a stop that came while connecting closed nothing (there was no connection yet): this one is closed instead
|
|
310
|
+
if (this.stopping) {
|
|
311
|
+
this.repl.close();
|
|
312
|
+
this.repl = null;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
if (attempts)
|
|
316
|
+
this.log(`replication: the source is back; resuming from ${formatLsn(this.acked)}`);
|
|
317
|
+
attempts = 0;
|
|
318
|
+
try {
|
|
319
|
+
await this.streamOnce(this.repl);
|
|
320
|
+
if (this.stopping)
|
|
321
|
+
break;
|
|
322
|
+
this.log("replication: the source ended the stream (a shutdown); reconnecting");
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
if (this.stopping)
|
|
326
|
+
break;
|
|
327
|
+
if (!away(e)) {
|
|
328
|
+
this.repl.close();
|
|
329
|
+
this.repl = null;
|
|
330
|
+
return e;
|
|
331
|
+
}
|
|
332
|
+
this.log(`replication: the source went away (${e.message}); reconnecting`);
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
this.repl?.close();
|
|
336
|
+
this.repl = null;
|
|
337
|
+
}
|
|
338
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
async streamOnce(repl) {
|
|
343
|
+
const relations = new Map();
|
|
344
|
+
// the streamed tables refused by a relation lacking a copied column, by their `tables()` entry: once each
|
|
345
|
+
const refused = new WeakSet();
|
|
346
|
+
let batch = new Map();
|
|
347
|
+
let rows = 0, firstAt = 0;
|
|
348
|
+
// the batch holds a change of a big table: applied by `applySize`'s larger bounds
|
|
349
|
+
let big = false;
|
|
350
|
+
const size = () => applySize(big, this.flushMs, this.flushRows);
|
|
351
|
+
let inTxn = false, skipping = false;
|
|
352
|
+
let lastEnd = this.acked;
|
|
353
|
+
// when the open transaction committed (its begin carries it), and the newest commit folded into the batch
|
|
354
|
+
let txnCommit = new Date(0), lastCommit = new Date(0);
|
|
355
|
+
// the open transaction's pieces applied so far, and its changes by table (for the log line once it commits)
|
|
356
|
+
let pieces = 0;
|
|
357
|
+
const txnChanges = new Map();
|
|
358
|
+
const flush = async () => {
|
|
359
|
+
if (lastEnd <= this.acked)
|
|
360
|
+
return;
|
|
361
|
+
if (batch.size || pieces)
|
|
362
|
+
await this.target.apply(batch, lastEnd, lastCommit);
|
|
363
|
+
this.acked = lastEnd;
|
|
364
|
+
repl.sendStatus(this.acked);
|
|
365
|
+
batch = new Map();
|
|
366
|
+
rows = 0;
|
|
367
|
+
firstAt = 0;
|
|
368
|
+
pieces = 0;
|
|
369
|
+
big = false;
|
|
370
|
+
};
|
|
371
|
+
// a piece of the open transaction: applied and committed, the position left where it is
|
|
372
|
+
const piece = async () => {
|
|
373
|
+
await this.target.apply(batch, null, txnCommit);
|
|
374
|
+
pieces++;
|
|
375
|
+
batch = new Map();
|
|
376
|
+
rows = 0;
|
|
377
|
+
firstAt = 0;
|
|
378
|
+
big = false;
|
|
379
|
+
};
|
|
380
|
+
// feedback while idle: Postgres drops a walsender it has not heard from in wal_sender_timeout (60 s). A send on a
|
|
381
|
+
// socket that has closed is said here; the stream's own read meets the close and reconnects. It asks for a reply: a
|
|
382
|
+
// walsender that hears from us sends no keepalive of its own, so an idle stream's position would never pass the WAL
|
|
383
|
+
// the database writes for other tables (and the slot would hold it), and `mason status` would read it as behind
|
|
384
|
+
const status = setInterval(() => {
|
|
385
|
+
try {
|
|
386
|
+
repl.sendStatus(this.acked, true);
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
this.log(`replication: the status update could not be sent (${e.message})`);
|
|
390
|
+
}
|
|
391
|
+
}, 10_000);
|
|
392
|
+
const command = `START_REPLICATION SLOT ${pgIdent(this.slot)} LOGICAL 0/0 (proto_version '1', publication_names ${pgLiteral(`"${this.publication.replace(/"/g, '""')}"`)})`;
|
|
393
|
+
try {
|
|
394
|
+
for await (const m of repl.replicate(command)) {
|
|
395
|
+
if (m.kind === "keepalive") {
|
|
396
|
+
// nothing of ours pending: the WAL up to the server's position holds no change we lack, and an idle slot
|
|
397
|
+
// must not hold the database's WAL for tables nobody reads
|
|
398
|
+
if (!inTxn && rows === 0 && lastEnd <= this.acked) {
|
|
399
|
+
if (m.walEnd > this.seen)
|
|
400
|
+
this.seen = m.walEnd;
|
|
401
|
+
if (m.walEnd > this.acked) {
|
|
402
|
+
this.acked = m.walEnd;
|
|
403
|
+
repl.sendStatus(this.acked);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else if (m.replyRequested)
|
|
407
|
+
repl.sendStatus(this.acked);
|
|
408
|
+
if (!inTxn && rows > 0 && Date.now() - firstAt >= size().ms)
|
|
409
|
+
await flush();
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const msg = decode(m.data);
|
|
413
|
+
switch (msg.type) {
|
|
414
|
+
case "begin":
|
|
415
|
+
inTxn = true;
|
|
416
|
+
pieces = 0;
|
|
417
|
+
txnChanges.clear();
|
|
418
|
+
txnCommit = pgTime(msg.commitTime);
|
|
419
|
+
// a commit the copy's own record says it applied (a run that died before its acknowledgement)
|
|
420
|
+
skipping = msg.finalLsn < this.target.applied();
|
|
421
|
+
break;
|
|
422
|
+
case "commit":
|
|
423
|
+
inTxn = false;
|
|
424
|
+
lastEnd = msg.endLsn;
|
|
425
|
+
lastCommit = pgTime(msg.commitTime);
|
|
426
|
+
if (pieces > 0) {
|
|
427
|
+
const n = [...txnChanges.values()].reduce((a, b) => a + b, 0);
|
|
428
|
+
this.log(`replication: ${[...txnChanges.keys()].join(", ")}: ${n.toLocaleString("en-US")} change(s) in one transaction, applied in ${pieces + (rows > 0 ? 1 : 0)} pieces`);
|
|
429
|
+
// its last piece, with its position: at once (the earlier pieces are in, unacknowledged)
|
|
430
|
+
await flush();
|
|
431
|
+
}
|
|
432
|
+
else if (rows > 0 && (Date.now() - firstAt >= size().ms || rows >= size().rows))
|
|
433
|
+
await flush();
|
|
434
|
+
else if (rows === 0) {
|
|
435
|
+
this.acked = lastEnd > this.acked ? lastEnd : this.acked;
|
|
436
|
+
this.seen = this.acked;
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
case "relation":
|
|
440
|
+
relations.set(msg.relid, { source: `${msg.namespace}.${msg.name}`, columns: msg.columns });
|
|
441
|
+
break;
|
|
442
|
+
case "insert":
|
|
443
|
+
case "update":
|
|
444
|
+
case "delete":
|
|
445
|
+
case "truncate": {
|
|
446
|
+
if (skipping)
|
|
447
|
+
break;
|
|
448
|
+
const changed = msg.type === "truncate" ? msg.relids : [msg.relid];
|
|
449
|
+
for (const relid of changed) {
|
|
450
|
+
const rel = relations.get(relid);
|
|
451
|
+
// looked up at each change: a table caught up since the stream started is streamed from here on
|
|
452
|
+
const table = rel === undefined ? undefined : this.target.tables().get(rel.source);
|
|
453
|
+
if (rel === undefined || table === undefined)
|
|
454
|
+
continue; // a table the copy does not hold, or not yet caught up
|
|
455
|
+
// a column the copy holds that the relation does not carry (a DDL on the database, a narrower publication):
|
|
456
|
+
// its changes cannot be written, so the table is refused and leaves the stream; the others go on
|
|
457
|
+
const missing = missingFrom(rel.columns, table);
|
|
458
|
+
if (missing.length) {
|
|
459
|
+
if (!refused.has(table)) {
|
|
460
|
+
refused.add(table);
|
|
461
|
+
await this.target.refuse(rel.source, missing);
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (table.big === true)
|
|
466
|
+
big = true;
|
|
467
|
+
const t = batch.get(rel.source) ?? noChanges(table.columns);
|
|
468
|
+
batch.set(rel.source, t);
|
|
469
|
+
txnChanges.set(rel.source, (txnChanges.get(rel.source) ?? 0) + 1);
|
|
470
|
+
t.counts[msg.type === "insert" ? "inserts" : msg.type === "update" ? "updates" : msg.type === "delete" ? "deletes" : "truncates"]++;
|
|
471
|
+
if (msg.type === "truncate") {
|
|
472
|
+
t.truncated = true;
|
|
473
|
+
t.keys.clear();
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
// a generated column (Postgres publishes none before 18) is read from the source
|
|
477
|
+
const absent = table.columns.some((name) => !rel.columns.some((c) => c.name === name));
|
|
478
|
+
const values = (tuple) => projectTuple(rel.columns, tuple, table.columns);
|
|
479
|
+
const keyOf = (vals) => table.key.map((k) => {
|
|
480
|
+
const v = vals[table.columns.indexOf(k)];
|
|
481
|
+
if (v === undefined)
|
|
482
|
+
throw new Error(`${rel.source}.${k}: a key value arrived unchanged-TOAST, which the stream cannot place`);
|
|
483
|
+
return v;
|
|
484
|
+
});
|
|
485
|
+
if (msg.type === "delete")
|
|
486
|
+
fold(t, keyOf(values(msg.key)), { kind: "delete" });
|
|
487
|
+
else {
|
|
488
|
+
const vals = values(msg.tuple);
|
|
489
|
+
if (msg.type === "update" && msg.oldKey)
|
|
490
|
+
fold(t, keyOf(values(msg.oldKey)), { kind: "delete" });
|
|
491
|
+
fold(t, keyOf(vals), { kind: "row", values: vals, ...(absent ? { refetch: true } : {}) });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (rows === 0)
|
|
495
|
+
firstAt = Date.now();
|
|
496
|
+
rows++;
|
|
497
|
+
if (inTxn && rows >= size().rows)
|
|
498
|
+
await piece();
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
default: break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
clearInterval(status);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/** pgoutput's commit time (microseconds since 2000-01-01 UTC) as a date */
|
|
511
|
+
export function pgTime(us) {
|
|
512
|
+
return new Date(946_684_800_000 + Number(us / 1000n));
|
|
513
|
+
}
|
|
514
|
+
/** the source being away — closed, refused, reset, a shutdown — which a reconnect outlasts; anything else is the stream's */
|
|
515
|
+
function away(e) {
|
|
516
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
517
|
+
return /connection closed|ECONNREFUSED|ECONNRESET|EPIPE|ETIMEDOUT|timed out|terminating connection|the database system is (shutting down|starting up)|server closed|is active for PID/i.test(m);
|
|
518
|
+
}
|