@mason-data/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED TABLES: TABLES DECLARED IN SQL, KEPT CURRENT BY MASON (issue 124, docs/DESIGN.md "Generated tables"). A file in
|
|
3
|
+
* `mason/generated_tables/` (src/cli/generated-files.ts) names a table and the SELECT that gives it, and a dashboard reads
|
|
4
|
+
* that table by name instead of repeating the work. This is `kind: scheduled`, by the whole re-run: the SELECT run over
|
|
5
|
+
* one snapshot into a table beside the old one, swapped in, up to `target_lag:` behind its bases. `kind: live` is refused
|
|
6
|
+
* until its write path exists; nothing here ever moves a table from one kind to the other.
|
|
7
|
+
*
|
|
8
|
+
* - WHAT IS CHECKED, AND WHEN: the file's shape when it is read; its SQL (src/duck/generated-plan.ts) once the listener's
|
|
9
|
+
* catalog holds its bases, again whenever that catalog moves, and before anything is built. A refusal stops nothing
|
|
10
|
+
* else: the other tables run on, and a table already deployed keeps serving AND being re-run under the definition it
|
|
11
|
+
* was built from, which the ledger holds (`Deployed`), so a bad edit costs no freshness.
|
|
12
|
+
* - A RUN (`run`), ONE AT A TIME IN THE ONE REBUILD QUEUE, on a connection of its own set up as a session in UTC (a plan
|
|
13
|
+
* read on a connection with DuckDB's own null order sorts otherwise): its snapshot taken on a writer's turn, the table
|
|
14
|
+
* beside the old one written there, then swapped in on another writer's turn, as a copy's swap is. The source is never
|
|
15
|
+
* touched. A failed run leaves the old table serving, says why (never quoting a row: `withoutValues`), and is tried
|
|
16
|
+
* again after the same back-off as a failed copy.
|
|
17
|
+
* - LAG IS THE AGE OF THE OLDEST BASE CHANGE NOT REFLECTED, never the age of the snapshot: over an idle base nothing is
|
|
18
|
+
* behind. Each commit that changed a base marks it (`changed`), in memory and after that commit, so nothing is added to
|
|
19
|
+
* a batch's transaction. A run takes the marks on the same writer's turn it takes its snapshot, so a commit is either
|
|
20
|
+
* in the snapshot or marked for the next run: DuckDB's snapshot is the transaction's first read of a table, never its
|
|
21
|
+
* BEGIN (measured). Marks die with the process: at a start, a base last synced after the table was built counts as
|
|
22
|
+
* changed since then, which at worst costs one re-run. A run starts early enough to end inside its target, by the last
|
|
23
|
+
* run's length; one that outlasts its target is followed by the next at once, and `mason status` says the target is
|
|
24
|
+
* missed.
|
|
25
|
+
* - A CATALOG TABLE, A TABLE TO ROLES: in the catalog as a copy of its output's types (`served`), so statements, EXPLAIN and
|
|
26
|
+
* a role's `tables:` treat it as any other, with no key. Before it is built the catalog has no such relation, and a
|
|
27
|
+
* statement is told so by name (gap `generated-not-built`, src/duck/plan.ts).
|
|
28
|
+
* - A CHAIN (issue 124 stage 2): a table may read another. Parents are built first, and a run of a parent builds every
|
|
29
|
+
* child after it in the same transaction, each child planned over its parents' SIDE tables (src/duck/plan.ts
|
|
30
|
+
* swapRelations), never the served ones, so parent and child are one snapshot; they swap in on ONE writer's turn. A
|
|
31
|
+
* child that comes due while a parent has changes not in it hands its run to that parent (`rootOf`). A child of a
|
|
32
|
+
* parent failed, or refused with nothing deployed, is not run, and says why (`blocker`); a deployed parent refused in its
|
|
33
|
+
* file is re-run from what it was built from, and its children with it. A cycle is refused by name: from the SQL's names when
|
|
34
|
+
* the folder is read (`findCycles`), else from the plan's reads at the check.
|
|
35
|
+
*/
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { cycleRefusal, isNumeric, planDefinition, readsByName, refusalOf, refusalText, storedColumns, uncheckedRefusal } from "./generated-plan.js";
|
|
38
|
+
import { MASON_SCHEMA, pgErrorOf } from "./store.js";
|
|
39
|
+
import { StatementError } from "./statement.js";
|
|
40
|
+
export const GENERATED_LEDGER = `${MASON_SCHEMA}.generated_tables`;
|
|
41
|
+
const LEDGER_DDL = `CREATE TABLE IF NOT EXISTS ${GENERATED_LEDGER} (name VARCHAR PRIMARY KEY, file VARCHAR, sql VARCHAR, order_by VARCHAR, target_every VARCHAR, target_seconds BIGINT, comment VARCHAR, ` +
|
|
42
|
+
`fingerprint VARCHAR, columns VARCHAR, bases VARCHAR, snapshot_at TIMESTAMPTZ, built_at TIMESTAMPTZ, "rows" BIGINT, seconds DOUBLE, bases_synced VARCHAR)`;
|
|
43
|
+
/** the table a run writes, beside the one served until the swap */
|
|
44
|
+
const SIDE_SUFFIX = "__mason_generated";
|
|
45
|
+
/** how often the folder is read and each table's due time looked at */
|
|
46
|
+
export const REVIEW_MS = 5_000;
|
|
47
|
+
const ident = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
48
|
+
const literal = (s) => `'${s.replaceAll("'", "''")}'`;
|
|
49
|
+
/** A row's values taken out of an error: what a table's failure says is kept and shown, and a value in it would be a row
|
|
50
|
+
* quoted (`invalid input syntax for type integer: "abc"`): quoted text and every number go. */
|
|
51
|
+
export function withoutValues(message) {
|
|
52
|
+
return message.split("\n")[0].replace(/'(?:[^']|'')*'/g, "…").replace(/"(?:[^"]|"")*"/g, "…").replace(/\b\d[\d.:,-]*\b/g, "…");
|
|
53
|
+
}
|
|
54
|
+
/** a definition the write's engine types refuse (`storedColumns`): the table's refusal, with its ways out */
|
|
55
|
+
class RefusedAtWrite extends Error {
|
|
56
|
+
entry;
|
|
57
|
+
refused;
|
|
58
|
+
constructor(entry, refused) { super(refused.map((r) => r.message).join("; ")); this.entry = entry; this.refused = refused; }
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A RUN'S FAILURE IN POSTGRES'S WORDS, never the engine's (CLAUDE.md "Nothing a user sees is named for DuckDB"): the ONE
|
|
62
|
+
* mapping a statement on the wire gets (src/duck/store.ts `pgErrorOf`) gives its SQLSTATE, message and Postgres's own
|
|
63
|
+
* DETAIL, and nothing here reads an engine spelling. Said without the run's values (`withoutValues`: Postgres's message
|
|
64
|
+
* may quote one), with no name Mason keeps for itself (`__mason_cell_0`), and the way out its SQLSTATE has.
|
|
65
|
+
*/
|
|
66
|
+
export function failureWords(err) {
|
|
67
|
+
const { code, message, detail } = pgErrorOf(err, "").fields;
|
|
68
|
+
// the mapping's own DETAIL is Postgres's; one that carries the engine's text is the engine's, and stays out
|
|
69
|
+
const theirs = detail === undefined || detail.startsWith("the engine") ? "" : ` ${detail}`;
|
|
70
|
+
const way = WAY_OUT[code];
|
|
71
|
+
return `${withoutValues(message)} (SQLSTATE ${code}).${theirs}${way === undefined ? "" : ` ${way}`}`.replace(/__mason_\w+/g, "a value it computes");
|
|
72
|
+
}
|
|
73
|
+
/** what to do about a run's failure, by its SQLSTATE: the data's, so the run is tried again on the back-off meanwhile */
|
|
74
|
+
const WAY_OUT = {
|
|
75
|
+
"22003": "Give the value more digits (a wider type or typmod), cast it to double precision if it need not be exact, or compute it in the statement that reads the synced table.",
|
|
76
|
+
"22012": "Guard the divisor (x / nullif(y, 0)), or compute it in the statement that reads the synced table.",
|
|
77
|
+
"22P02": "Cast only the values that are of that type (a CASE over the text), or compute it in the statement that reads the synced table.",
|
|
78
|
+
};
|
|
79
|
+
/** the engine type of each column a plan's SQL computes, as DuckDB says when it prepares it (`EngineTypes`) */
|
|
80
|
+
async function engineTypes(c, sql) {
|
|
81
|
+
const p = await c.prepare(sql);
|
|
82
|
+
try {
|
|
83
|
+
return new Map(Array.from({ length: p.columnCount }, (_, i) => { const type = p.columnType(i).toString(); return [[p.columnName(i), type], [`#${i}`, type]]; }).flat());
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
p.destroySync();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class GeneratedTables {
|
|
90
|
+
store;
|
|
91
|
+
host;
|
|
92
|
+
reviewMs;
|
|
93
|
+
onStep;
|
|
94
|
+
entries = new Map();
|
|
95
|
+
reader = null;
|
|
96
|
+
planner = null;
|
|
97
|
+
timer = null;
|
|
98
|
+
reviewing = null;
|
|
99
|
+
/** a run's planning in flight: it plans on the listener's catalog, so a stop waits for it before the listener closes */
|
|
100
|
+
planning = null;
|
|
101
|
+
stopped = false;
|
|
102
|
+
/** the run in flight, ended or not (`run`): the next starts after it */
|
|
103
|
+
runs = Promise.resolve();
|
|
104
|
+
constructor(store, host, options = {}) {
|
|
105
|
+
this.store = store;
|
|
106
|
+
this.host = host;
|
|
107
|
+
this.reviewMs = options.reviewMs ?? REVIEW_MS;
|
|
108
|
+
this.onStep = options.onStep ?? (() => undefined);
|
|
109
|
+
}
|
|
110
|
+
/** the ledger read and what a crash left beside it dropped, before the catalog serves anything */
|
|
111
|
+
async open() {
|
|
112
|
+
const w = this.store.writer;
|
|
113
|
+
await w.run(LEDGER_DDL);
|
|
114
|
+
const rows = (await w.runAndReadAll(`SELECT name, file, sql, order_by, target_every, target_seconds, comment, fingerprint, columns, bases, epoch_ms(snapshot_at), epoch_ms(built_at), "rows", seconds, bases_synced FROM ${GENERATED_LEDGER}`)).getRowsJS();
|
|
115
|
+
for (const [name, file, sql, orderBy, every, seconds, comment, fingerprint, columns, bases, snapshotAt, builtAt, count, took, basesSynced] of rows) {
|
|
116
|
+
const at = name.indexOf(".");
|
|
117
|
+
this.entry(name).deployed = { definition: { name, schema: name.slice(0, at), table: name.slice(at + 1), file, sql, orderBy, targetLag: { every, seconds: Number(seconds) }, comment },
|
|
118
|
+
fingerprint, columns: JSON.parse(columns), bases: JSON.parse(bases), basesSynced: basesSynced === null ? {} : JSON.parse(basesSynced), snapshotAt: Number(snapshotAt), builtAt: Number(builtAt), rows: Number(count), seconds: took };
|
|
119
|
+
}
|
|
120
|
+
// marks die with a run: a base whose last sync is not the one the snapshot saw may have changed since. Never "synced
|
|
121
|
+
// after the snapshot": a poll's time is its start, so one that began before the snapshot and committed after it would
|
|
122
|
+
// be missed. A generated base's "last sync" is its own snapshot (`baseSynced`): a parent swapped in alone since the
|
|
123
|
+
// child's run, a new definition's included, leaves the child stale; one built with the child leaves it current
|
|
124
|
+
for (const entry of this.entries.values()) {
|
|
125
|
+
const d = entry.deployed;
|
|
126
|
+
if (d === null)
|
|
127
|
+
continue;
|
|
128
|
+
if (d.bases.some((b) => { const s = this.baseSynced(b); return s === null || d.basesSynced[b] === undefined || d.basesSynced[b] !== s; }))
|
|
129
|
+
entry.dirtySince = d.snapshotAt;
|
|
130
|
+
}
|
|
131
|
+
const left = (await w.runAndReadAll(`SELECT schema_name, table_name FROM duckdb_tables() WHERE database_name = current_database() AND ends_with(table_name, '${SIDE_SUFFIX}')`)).getRowsJS();
|
|
132
|
+
for (const [schema, table] of left)
|
|
133
|
+
await w.run(`DROP TABLE ${ident(schema)}.${ident(table)}`);
|
|
134
|
+
// the run's connection was set up as a session is, by the one set-up (src/duck/store.ts `SessionSetup`)
|
|
135
|
+
await this.host.settle();
|
|
136
|
+
}
|
|
137
|
+
/** a base's last sync, ms; null: not known */
|
|
138
|
+
syncedMs(base) { const s = this.host.syncedAt(base); return s === null ? null : Date.parse(s); }
|
|
139
|
+
/** what a child's ledger row records of a base: a synced table's last sync, a generated table's snapshot (ms, as served
|
|
140
|
+
* now); null: not known */
|
|
141
|
+
baseSynced(base) {
|
|
142
|
+
const p = this.host.added(base) === null ? this.entries.get(base) : undefined;
|
|
143
|
+
return p === undefined ? this.syncedMs(base) : p.deployed?.snapshotAt ?? null;
|
|
144
|
+
}
|
|
145
|
+
/** From here on the folder is read (`read`) and each definition held to the listener's catalog (`planner`). Resolves once
|
|
146
|
+
* they have been looked at, so a start can say what is refused. */
|
|
147
|
+
async start(read, planner) {
|
|
148
|
+
this.reader = read;
|
|
149
|
+
this.planner = planner;
|
|
150
|
+
await this.review();
|
|
151
|
+
this.arm();
|
|
152
|
+
}
|
|
153
|
+
arm() {
|
|
154
|
+
if (this.stopped || this.reviewMs === 0)
|
|
155
|
+
return;
|
|
156
|
+
this.timer = setTimeout(() => { void this.review().finally(() => this.arm()); }, this.reviewMs);
|
|
157
|
+
this.timer.unref();
|
|
158
|
+
}
|
|
159
|
+
/** the timer stopped, and what it was doing ended: the review, and a run's planning, the two that read the listener's
|
|
160
|
+
* catalog (a run past its plan sees `stopped` and ends; its build is the sync's stop to interrupt) */
|
|
161
|
+
async stop() {
|
|
162
|
+
this.stopped = true;
|
|
163
|
+
if (this.timer !== null)
|
|
164
|
+
clearTimeout(this.timer);
|
|
165
|
+
await this.reviewing;
|
|
166
|
+
// settled, not awaited: its failure is the run's to say, or not, as stopped
|
|
167
|
+
if (this.planning !== null)
|
|
168
|
+
await Promise.allSettled([this.planning]);
|
|
169
|
+
}
|
|
170
|
+
entry(name) {
|
|
171
|
+
let e = this.entries.get(name);
|
|
172
|
+
if (e === undefined) {
|
|
173
|
+
e = { name, wanted: null, fileRefused: null, checked: null, reads: [], cycle: null, blocked: null, seen: null, refusals: [], said: null, warnings: [], warned: null, waitingFor: null, deployed: null, dirtySince: null, taken: null, queued: false, running: false, failures: 0, retryAt: 0, error: null };
|
|
174
|
+
this.entries.set(name, e);
|
|
175
|
+
}
|
|
176
|
+
return e;
|
|
177
|
+
}
|
|
178
|
+
// ------------------------------------------------------------------------------------------- what the sync reads
|
|
179
|
+
/** the tables served, as the catalog declares them (src/duck/sync.ts `schemaBuilt`), but one a copy holds the name of */
|
|
180
|
+
served() {
|
|
181
|
+
return [...this.entries.values()].flatMap((e) => e.deployed === null || this.host.added(e.name) !== null ? [] :
|
|
182
|
+
[{ schema: e.deployed.definition.schema, table: e.deployed.definition.table, columns: e.deployed.columns, comment: e.deployed.definition.comment }]);
|
|
183
|
+
}
|
|
184
|
+
/** the tables a role may name, built or not: `columns` none until they are known (src/cli/roles.ts) */
|
|
185
|
+
named() {
|
|
186
|
+
return [...this.entries.values()].filter((e) => this.defines(e.name)).map((e) => e.deployed !== null ? { source: e.name, columns: e.deployed.columns } : { source: e.name, columns: [], pending: true });
|
|
187
|
+
}
|
|
188
|
+
/** `name` is a generated table this folder defines: a file that names it, or a table built from one; never a name
|
|
189
|
+
* whose file is gone and was never built, nor one a file names that is refused before its SQL is read */
|
|
190
|
+
defines(name) {
|
|
191
|
+
const e = this.entries.get(name);
|
|
192
|
+
return e !== undefined && (e.wanted !== null || e.deployed !== null);
|
|
193
|
+
}
|
|
194
|
+
/** a base's commit changed its rows: called on the writer's turn, after that commit */
|
|
195
|
+
changed(base) {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
for (const e of this.entries.values())
|
|
198
|
+
if (e.dirtySince === null && e.deployed?.bases.includes(base))
|
|
199
|
+
e.dirtySince = now;
|
|
200
|
+
}
|
|
201
|
+
/** how far behind its bases a table is, seconds, across its chain: 0 when nothing is unreflected */
|
|
202
|
+
behind(e, now) {
|
|
203
|
+
const oldest = this.chainOldest(e);
|
|
204
|
+
return oldest === null ? 0 : Math.max(0, (now - oldest) / 1000);
|
|
205
|
+
}
|
|
206
|
+
/** LAG ACROSS A CHAIN: the oldest base change not in `e`'s rows, its own or one a table it is built from has not
|
|
207
|
+
* reflected yet (that one is not in `e` either), anywhere below it; null: none. Measured against the base, never
|
|
208
|
+
* summed: a `1m` table over a `1m` table aims to be 1 minute behind the base, not 2 */
|
|
209
|
+
chainOldest(e, seen = new Set()) {
|
|
210
|
+
seen.add(e.name);
|
|
211
|
+
let oldest = this.oldest(e);
|
|
212
|
+
for (const n of e.deployed?.bases ?? []) {
|
|
213
|
+
const p = this.entries.get(n);
|
|
214
|
+
if (p === undefined || seen.has(n) || this.host.added(n) !== null)
|
|
215
|
+
continue;
|
|
216
|
+
const o = this.chainOldest(p, seen);
|
|
217
|
+
if (o !== null && (oldest === null || o < oldest))
|
|
218
|
+
oldest = o;
|
|
219
|
+
}
|
|
220
|
+
return oldest;
|
|
221
|
+
}
|
|
222
|
+
/** the oldest change not in the table: a run's, until its swap, or one since */
|
|
223
|
+
oldest(e) { return e.taken === null ? e.dirtySince : e.dirtySince === null ? e.taken : Math.min(e.taken, e.dirtySince); }
|
|
224
|
+
stateOf(e) {
|
|
225
|
+
if (e.fileRefused !== null || e.refusals.length > 0)
|
|
226
|
+
return "refused";
|
|
227
|
+
if (e.deployed === null)
|
|
228
|
+
return e.running || e.queued ? "building" : e.error !== null ? "failed" : e.waitingFor !== null || e.blocked !== null ? "waiting" : "building";
|
|
229
|
+
if (e.error !== null)
|
|
230
|
+
return "failed";
|
|
231
|
+
return this.chainOldest(e) !== null ? "behind" : "current";
|
|
232
|
+
}
|
|
233
|
+
status(now = Date.now()) {
|
|
234
|
+
return [...this.entries.values()].map((e) => {
|
|
235
|
+
const d = e.wanted ?? e.deployed?.definition ?? null;
|
|
236
|
+
const refusals = e.fileRefused?.refusals ?? e.refusals;
|
|
237
|
+
const file = e.fileRefused?.file ?? d?.file ?? e.name;
|
|
238
|
+
const target = e.deployed?.definition.targetLag ?? d?.targetLag ?? null;
|
|
239
|
+
const behindSeconds = this.behind(e, now), since = this.chainOldest(e);
|
|
240
|
+
return { table: e.name, file, kind: e.fileRefused !== null ? e.fileRefused.kind : "scheduled", method: "whole", state: this.stateOf(e), targetLag: target?.every ?? null, behindSeconds,
|
|
241
|
+
behindSince: since === null ? null : new Date(since).toISOString(),
|
|
242
|
+
missingTarget: target !== null && behindSeconds > target.seconds, serving: e.deployed !== null,
|
|
243
|
+
lastRun: e.deployed === null ? null : { at: new Date(e.deployed.builtAt).toISOString(), seconds: e.deployed.seconds, rows: e.deployed.rows },
|
|
244
|
+
error: e.error, retryAt: e.error === null ? null : new Date(e.retryAt).toISOString(), refusals: refusals.map((r) => ({ ...r, text: refusalText(file, r) })), waitingFor: this.waitingWords(e), warnings: e.warnings.map((w) => `${file}: ${w}`) };
|
|
245
|
+
}).sort((a, b) => a.table.localeCompare(b.table));
|
|
246
|
+
}
|
|
247
|
+
/** what `e` waits for, as `GeneratedStatus.waitingFor` says it: the table's name, with why it blocks as that table is
|
|
248
|
+
* NOW (refused with nothing deployed, or its last run failed), read here and never stored, so `mason wait` decides
|
|
249
|
+
* from what is so when it reads (src/cli/readiness.ts `generatedReadiness`) */
|
|
250
|
+
waitingWords(e) {
|
|
251
|
+
if (e.waitingFor !== null) {
|
|
252
|
+
const why = this.refusedOrFailed(e.waitingFor);
|
|
253
|
+
return why === null ? e.waitingFor : `${e.waitingFor}, which ${why}`;
|
|
254
|
+
}
|
|
255
|
+
if (e.blocked !== null) {
|
|
256
|
+
const why = this.refusedOrFailed(e.blocked);
|
|
257
|
+
return why === null ? e.blocked : `${e.blocked} ${why}`;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
/** the tables as the listener takes the sync's states (`Sync.state`): how EXPLAIN says each is kept current, and what a
|
|
262
|
+
* statement is told of one not built yet (src/duck/plan.ts `missingRelationHint`) */
|
|
263
|
+
copies(now = Date.now()) {
|
|
264
|
+
return this.status(now).map((s) => {
|
|
265
|
+
const lag = s.behindSeconds === 0 ? "nothing behind" : `${Math.round(s.behindSeconds)} s behind`;
|
|
266
|
+
const last = s.lastRun === null ? "never built" : `last re-run ${s.lastRun.at}`;
|
|
267
|
+
const words = s.serving ? `generated from ${s.file}: scheduled, re-run whole, target ${s.targetLag}, ${lag}; ${last}${s.error === null ? "" : `; its last run failed: ${s.error}`}` :
|
|
268
|
+
s.state === "refused" ? `refused: ${s.refusals[0]?.message ?? ""}` : s.state === "failed" ? `its first build failed: ${s.error ?? ""}` : s.state === "waiting" ? `waiting for ${s.waitingFor ?? "a table"}` : "being built";
|
|
269
|
+
return { source: s.table, phase: s.serving ? "copied" : "copying", rows: s.lastRun?.rows ?? null, bytes: null, seconds: s.lastRun?.seconds ?? null, copiedAt: s.lastRun?.at ?? null, error: s.error, kept: { by: "generated", text: words } };
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------------------------------- the review
|
|
273
|
+
/** The folder read, each definition held to the catalog, and what is due queued: every `reviewMs`, one at a time. */
|
|
274
|
+
review() {
|
|
275
|
+
this.reviewing ??= this.reviewed().catch((e) => {
|
|
276
|
+
this.host.log(`generated tables: the review failed, and runs again (Mason bug: report it): ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
|
277
|
+
}).finally(() => { this.reviewing = null; });
|
|
278
|
+
return this.reviewing;
|
|
279
|
+
}
|
|
280
|
+
async reviewed() {
|
|
281
|
+
if (this.reader === null || this.stopped)
|
|
282
|
+
return;
|
|
283
|
+
const read = this.reader();
|
|
284
|
+
const wanted = new Map(read.definitions.map((d) => [d.name, d])), refused = new Map(read.refused.map((r) => [r.name, r]));
|
|
285
|
+
for (const name of new Set([...wanted.keys(), ...refused.keys()]))
|
|
286
|
+
this.entry(name);
|
|
287
|
+
for (const e of this.entries.values()) {
|
|
288
|
+
e.wanted = wanted.get(e.name) ?? null;
|
|
289
|
+
e.fileRefused = refused.get(e.name) ?? null;
|
|
290
|
+
e.reads = e.wanted === null ? [] : await readsByName(e.wanted.sql);
|
|
291
|
+
}
|
|
292
|
+
this.findCycles();
|
|
293
|
+
for (const e of [...this.entries.values()]) {
|
|
294
|
+
// a stop mid-review: the listener's catalog may be closing, and nothing is planned on it
|
|
295
|
+
if (this.stopped)
|
|
296
|
+
return;
|
|
297
|
+
this.say(e);
|
|
298
|
+
if (e.wanted === null && e.fileRefused === null) {
|
|
299
|
+
await this.remove(e);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
await this.check(e);
|
|
303
|
+
if (!e.running)
|
|
304
|
+
this.schedule(e);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** a refusal said once, when it starts or changes */
|
|
308
|
+
say(e) {
|
|
309
|
+
const file = e.fileRefused?.file ?? e.wanted?.file ?? e.name, list = e.fileRefused?.refusals ?? e.refusals;
|
|
310
|
+
const words = list.length === 0 ? null : list.map((r) => refusalText(file, r)).join("\n");
|
|
311
|
+
if (words !== e.said) {
|
|
312
|
+
if (words !== null)
|
|
313
|
+
this.host.log(`${words}${e.deployed !== null ? `\n ${e.name} keeps serving what it was last built from.` : ""}`);
|
|
314
|
+
else
|
|
315
|
+
this.host.log(`${file}: no longer refused`);
|
|
316
|
+
e.said = words;
|
|
317
|
+
}
|
|
318
|
+
const warned = e.warnings.length === 0 ? null : e.warnings.map((w) => `${file}: ${w}`).join("\n");
|
|
319
|
+
if (warned !== null && warned !== e.warned)
|
|
320
|
+
this.host.log(warned);
|
|
321
|
+
e.warned = warned;
|
|
322
|
+
}
|
|
323
|
+
/** its file gone: the table goes, unless a run has it (the run's end looks again) or a copy now holds its name */
|
|
324
|
+
async remove(e) {
|
|
325
|
+
if (e.running || e.queued)
|
|
326
|
+
return;
|
|
327
|
+
this.entries.delete(e.name);
|
|
328
|
+
if (e.deployed === null)
|
|
329
|
+
return;
|
|
330
|
+
const d = e.deployed.definition;
|
|
331
|
+
const copy = this.host.added(e.name) !== null;
|
|
332
|
+
await this.host.onWriter(async () => {
|
|
333
|
+
const w = this.store.writer;
|
|
334
|
+
await w.run("BEGIN TRANSACTION");
|
|
335
|
+
try {
|
|
336
|
+
if (!copy)
|
|
337
|
+
await w.run(`DROP TABLE IF EXISTS ${ident(d.schema)}.${ident(d.table)}`);
|
|
338
|
+
await w.run(`DELETE FROM ${GENERATED_LEDGER} WHERE name = ${literal(e.name)}`);
|
|
339
|
+
await w.run("COMMIT");
|
|
340
|
+
}
|
|
341
|
+
catch (x) {
|
|
342
|
+
await w.run("ROLLBACK");
|
|
343
|
+
throw x;
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
this.host.log(`${e.name}: its file is gone, so the generated table is dropped`);
|
|
347
|
+
await this.host.rebuildCatalog();
|
|
348
|
+
}
|
|
349
|
+
/** the generated tables `n` reads: by its SQL's names, and by what the plan said where it was checked; what it was
|
|
350
|
+
* built from where its file is gone or refused */
|
|
351
|
+
readsOf(n, names) {
|
|
352
|
+
const e = this.entries.get(n);
|
|
353
|
+
if (e === undefined)
|
|
354
|
+
return [];
|
|
355
|
+
// what the plan read, only while `wanted` is the definition it planned: an edit's reads are its names until checked
|
|
356
|
+
const planned = e.checked !== null && e.wanted !== null && e.seen?.json === JSON.stringify(e.wanted) ? e.checked.bases : [];
|
|
357
|
+
return [...new Set([...e.reads, ...planned, ...(e.wanted === null ? e.deployed?.bases ?? [] : [])])].filter((x) => names.has(x));
|
|
358
|
+
}
|
|
359
|
+
/** the shortest way from `self` through what each reads back to `self`, as the tables in order (self first and last) */
|
|
360
|
+
cycleOf(self, reads) {
|
|
361
|
+
const from = new Map(), queue = [self];
|
|
362
|
+
while (queue.length > 0) {
|
|
363
|
+
const n = queue.shift();
|
|
364
|
+
for (const d of reads(n)) {
|
|
365
|
+
if (d === self) {
|
|
366
|
+
const back = [];
|
|
367
|
+
for (let at = n; at !== self; at = from.get(at))
|
|
368
|
+
back.push(at);
|
|
369
|
+
return [self, ...back.reverse(), self];
|
|
370
|
+
}
|
|
371
|
+
if (!from.has(d)) {
|
|
372
|
+
from.set(d, n);
|
|
373
|
+
queue.push(d);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** each definition whose SQL's names read itself, through others or not: refused before its check */
|
|
380
|
+
findCycles() {
|
|
381
|
+
const names = this.names("");
|
|
382
|
+
for (const e of this.entries.values()) {
|
|
383
|
+
const cycle = e.wanted === null || !names.has(e.name) ? null : this.cycleOf(e.name, (n) => this.readsOf(n, names));
|
|
384
|
+
e.cycle = cycle === null ? null : cycleRefusal(cycle);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** `wanted` held to the catalog as it is now: once per definition and per catalog */
|
|
388
|
+
async check(e) {
|
|
389
|
+
const planner = this.planner, schema = this.host.schema();
|
|
390
|
+
if (planner === null)
|
|
391
|
+
return;
|
|
392
|
+
const def = e.wanted;
|
|
393
|
+
if (def === null) {
|
|
394
|
+
e.checked = null;
|
|
395
|
+
e.seen = null;
|
|
396
|
+
e.refusals = [];
|
|
397
|
+
e.waitingFor = null;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const held = this.host.added(e.name);
|
|
401
|
+
if (held !== null) {
|
|
402
|
+
e.checked = null;
|
|
403
|
+
e.seen = null;
|
|
404
|
+
e.waitingFor = null;
|
|
405
|
+
await this.release(e);
|
|
406
|
+
e.refusals = [{ key: null, message: `${e.name} is a ${held === "view" ? "view of the database" : "table the sources file syncs"}`, why: "A name holds one table.", options: ["rename this file", "or take the table out of the sources file"] }];
|
|
407
|
+
this.say(e);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (e.cycle !== null) {
|
|
411
|
+
e.checked = null;
|
|
412
|
+
e.seen = null;
|
|
413
|
+
e.waitingFor = null;
|
|
414
|
+
e.refusals = [e.cycle];
|
|
415
|
+
this.say(e);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
// a parent's target_lag is held against this one's: looked at again when any table's target moves
|
|
419
|
+
const json = JSON.stringify([def, [...this.entries.values()].map((p) => [p.name, this.targetOf(p)?.seconds ?? null])]);
|
|
420
|
+
if (e.seen?.json === json && e.seen.schema === schema)
|
|
421
|
+
return;
|
|
422
|
+
e.seen = { json, schema };
|
|
423
|
+
try {
|
|
424
|
+
// on the check's own connection: a run may hold the run's in a transaction now (`checkConnection`)
|
|
425
|
+
const c = this.host.checkConnection();
|
|
426
|
+
await this.host.settleCheck();
|
|
427
|
+
const r = await planDefinition(planner, def.sql, def.orderBy, undefined, (sql) => engineTypes(c, sql));
|
|
428
|
+
e.waitingFor = null;
|
|
429
|
+
e.warnings = "refused" in r ? [] : r.definition.warnings;
|
|
430
|
+
// what its names did not show (a name the plan resolved otherwise): a cycle by what the plan reads
|
|
431
|
+
const names = this.names(e.name);
|
|
432
|
+
const cycle = "refused" in r ? null : this.cycleOf(e.name, (n) => n === e.name ? r.definition.bases.filter((x) => names.has(x)) : this.readsOf(n, names));
|
|
433
|
+
if ("refused" in r) {
|
|
434
|
+
e.checked = null;
|
|
435
|
+
e.refusals = r.refused;
|
|
436
|
+
}
|
|
437
|
+
else if (cycle !== null) {
|
|
438
|
+
e.checked = null;
|
|
439
|
+
e.refusals = [cycleRefusal(cycle)];
|
|
440
|
+
}
|
|
441
|
+
else if (this.fresherThan(def, r.definition.bases, names) !== null) {
|
|
442
|
+
e.checked = null;
|
|
443
|
+
e.refusals = [this.fresherThan(def, r.definition.bases, names)];
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
const fingerprint = createHash("sha256").update(JSON.stringify([def.sql, def.orderBy, r.definition.columns.map((c) => [c.name, c.type, c.duck])])).digest("hex");
|
|
447
|
+
e.checked = { definition: def, fingerprint, bases: r.definition.bases };
|
|
448
|
+
e.refusals = [];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (x) {
|
|
452
|
+
// NO DEFINITION LEAVES THE CHECK WITHOUT A VERDICT: what Mason did not expect while checking one is that table's
|
|
453
|
+
// refusal, in words a user reads, and the review goes on to the others. Thrown up, it stopped the review at this
|
|
454
|
+
// table, ran it again for ever, and left the table `building` with no reason (PR 202 round 8). What it said goes to
|
|
455
|
+
// the log once, for the report; the refusal stands until the definition or the catalog changes (`seen`)
|
|
456
|
+
if (!(x instanceof StatementError)) {
|
|
457
|
+
e.checked = null;
|
|
458
|
+
e.waitingFor = null;
|
|
459
|
+
e.warnings = [];
|
|
460
|
+
e.refusals = [uncheckedRefusal()];
|
|
461
|
+
this.host.log(`${def.file}: Mason could not check this definition (Mason bug: report it, with this line and the file's SQL): ${x instanceof Error ? x.stack ?? x.message : String(x)}`);
|
|
462
|
+
this.say(e);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
e.checked = null;
|
|
466
|
+
const missing = x.fields.code === "42P01" ? /^relation "([^"]+)" does not exist$/.exec(x.fields.message)?.[1] : undefined;
|
|
467
|
+
const base = missing === undefined ? undefined : missing.includes(".") ? missing : `public.${missing}`;
|
|
468
|
+
const adding = base === undefined ? null : this.host.added(base);
|
|
469
|
+
// A 42P01 IS THE CATALOG'S IT WAS PLANNED ON (issue 387): a table the sources file adds is waited for until the
|
|
470
|
+
// catalog holds it (`adding`), and also when the catalog moved while this was planned, since the host's answer is
|
|
471
|
+
// the new catalog's, which may hold a table the plan's did not (the demo's start: orders synced and in the catalog
|
|
472
|
+
// while the check's plan, begun before, waited for the catalog Postgres to start without it). `seen` keeps the
|
|
473
|
+
// schema the plan was made on, so the next review checks it again over the one that holds it
|
|
474
|
+
const moved = this.host.schema() !== schema;
|
|
475
|
+
// a parent not built yet: waited for by name; why it is refused or its build failed is read when it is said
|
|
476
|
+
const parent = x.gap === "generated-not-built" ? /the generated table (\S+) is not built yet/.exec(x.fields.message)?.[1] : undefined;
|
|
477
|
+
// A GENERATED BASE IS THERE WHEN THE CATALOG HOLDS IT TOO (issue 474): a run sets its table deployed, so serving,
|
|
478
|
+
// BEFORE its rebuild of the catalog (which reads `served()`, so it must), and that rebuild waits behind any other.
|
|
479
|
+
// A 42P01 for a table this folder defines is the catalog the plan was made on lacking it, whatever its state:
|
|
480
|
+
// waited for by name, and the parent's own trouble read where it is said (`waitingWords`). A name nobody defines
|
|
481
|
+
// (no file, or its file gone) stays Postgres's refusal
|
|
482
|
+
const generated = base !== undefined && adding === null && base !== e.name && this.defines(base);
|
|
483
|
+
if (adding === "adding" || (adding === "copied" && moved)) {
|
|
484
|
+
e.waitingFor = base;
|
|
485
|
+
e.refusals = [];
|
|
486
|
+
}
|
|
487
|
+
else if (parent !== undefined) {
|
|
488
|
+
e.waitingFor = parent;
|
|
489
|
+
e.refusals = [];
|
|
490
|
+
}
|
|
491
|
+
else if (generated) {
|
|
492
|
+
e.waitingFor = base;
|
|
493
|
+
e.refusals = [];
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
e.waitingFor = null;
|
|
497
|
+
e.refusals = [refusalOf(x)];
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.say(e);
|
|
501
|
+
}
|
|
502
|
+
/** the `target_lag` a table is kept to: its file's, else what it was built from. A table refused in its file but
|
|
503
|
+
* deployed runs at what it was built from, so that is what a child is held to, never the refused file's */
|
|
504
|
+
targetOf(e) {
|
|
505
|
+
const running = e.fileRefused !== null || e.refusals.length > 0 ? e.deployed?.definition.targetLag : undefined;
|
|
506
|
+
return running ?? e.wanted?.targetLag ?? e.deployed?.definition.targetLag ?? null;
|
|
507
|
+
}
|
|
508
|
+
/** A CHILD'S TARGET AGAINST ITS PARENT'S: a table can be no fresher than a generated table it reads, so a `target_lag`
|
|
509
|
+
* shorter than a parent's is refused by name, with the way out; null: none is */
|
|
510
|
+
fresherThan(def, bases, names) {
|
|
511
|
+
for (const n of bases) {
|
|
512
|
+
const p = this.entries.get(n), target = p === undefined || !names.has(n) ? null : this.targetOf(p);
|
|
513
|
+
if (target === null || def.targetLag.seconds >= target.seconds)
|
|
514
|
+
continue;
|
|
515
|
+
return { key: "target_lag", message: `target_lag ${def.targetLag.every} is shorter than that of ${n}, which it reads (${target.every})`,
|
|
516
|
+
why: "A table can be no fresher than a generated table it reads: its rows wait for the parent's run.",
|
|
517
|
+
options: [`set target_lag: ${target.every} or longer`, `or shorten ${n}'s target_lag to ${def.targetLag.every}`] };
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
/** the generated tables' names, itself among them: a file refused for holding a synced table's name names no generated
|
|
522
|
+
* table */
|
|
523
|
+
names(self) { return new Set([...this.entries.keys(), self].filter((n) => n !== "" && this.host.added(n) === null)); }
|
|
524
|
+
/** why a parent blocks what reads it: refused with nothing deployed, or its last run failed; null: neither. A parent
|
|
525
|
+
* refused in its file but deployed is re-run from what it was built from, and feeds its children from that */
|
|
526
|
+
refusedOrFailed(name) {
|
|
527
|
+
const p = this.entries.get(name);
|
|
528
|
+
if (p === undefined)
|
|
529
|
+
return null;
|
|
530
|
+
if ((p.fileRefused !== null || p.refusals.length > 0) && p.deployed === null)
|
|
531
|
+
return "is refused";
|
|
532
|
+
return p.error === null ? null : "failed its last run";
|
|
533
|
+
}
|
|
534
|
+
/** the generated tables `e` is built from now */
|
|
535
|
+
parentsOf(e) {
|
|
536
|
+
const b = this.buildable(e);
|
|
537
|
+
return b === null ? [] : b.bases.filter((x) => this.entries.has(x) && this.host.added(x) === null);
|
|
538
|
+
}
|
|
539
|
+
/** the table that keeps `e` from being run: a parent, or a parent's parent, refused or failed; null: nothing blocks it */
|
|
540
|
+
blocker(e, seen = new Set()) {
|
|
541
|
+
seen.add(e.name);
|
|
542
|
+
for (const p of this.parentsOf(e)) {
|
|
543
|
+
if (seen.has(p))
|
|
544
|
+
continue;
|
|
545
|
+
if (this.refusedOrFailed(p) !== null)
|
|
546
|
+
return p;
|
|
547
|
+
const up = this.blocker(this.entries.get(p), seen);
|
|
548
|
+
if (up !== null)
|
|
549
|
+
return up;
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
/** `e` not run because of the table `by`, said once per table */
|
|
554
|
+
block(e, by) {
|
|
555
|
+
if (e.blocked !== by)
|
|
556
|
+
this.host.log(`${e.name}: not run, because ${by} ${this.refusedOrFailed(by) ?? "is not ready"}`);
|
|
557
|
+
e.blocked = by;
|
|
558
|
+
}
|
|
559
|
+
/** a base is there to read: a synced table synced, or a generated table built */
|
|
560
|
+
ready(name) {
|
|
561
|
+
const held = this.host.added(name);
|
|
562
|
+
return held === "copied" || (held === null && this.entries.get(name)?.deployed != null);
|
|
563
|
+
}
|
|
564
|
+
/** whom a run of `e` belongs to: the farthest parent with changes not in it, which builds `e` after it */
|
|
565
|
+
rootOf(e, seen = new Set()) {
|
|
566
|
+
seen.add(e.name);
|
|
567
|
+
for (const n of this.parentsOf(e)) {
|
|
568
|
+
const p = this.entries.get(n);
|
|
569
|
+
if (seen.has(n) || p.deployed === null || this.oldest(p) === null || p.running || Date.now() < p.retryAt)
|
|
570
|
+
continue;
|
|
571
|
+
return this.rootOf(p, seen);
|
|
572
|
+
}
|
|
573
|
+
return e;
|
|
574
|
+
}
|
|
575
|
+
/** what a run of `root` builds, parents before children: `root`, then every table that reads one of them whose other
|
|
576
|
+
* parents are built and blocked by nothing. Not the children of a new definition: theirs is a plan over what the
|
|
577
|
+
* catalog says once it is swapped in. A parent refused in its file runs its deployed definition, and carries them */
|
|
578
|
+
unitOf(root) {
|
|
579
|
+
const now = Date.now(), set = new Map([[root.name, root]]);
|
|
580
|
+
// a table of this run feeds its children unless it is a new definition: refused in its file, it is re-run from what it
|
|
581
|
+
// was built from, which the catalog and its children already hold (its own failure is what this run tries again)
|
|
582
|
+
const feeds = (p) => p.deployed !== null && p.deployed.fingerprint === this.buildable(p)?.fingerprint;
|
|
583
|
+
for (let grew = true; grew;) {
|
|
584
|
+
grew = false;
|
|
585
|
+
for (const c of this.entries.values()) {
|
|
586
|
+
if (set.has(c.name) || c.running || this.buildable(c) === null || now < c.retryAt)
|
|
587
|
+
continue;
|
|
588
|
+
const parents = this.parentsOf(c);
|
|
589
|
+
if (!parents.some((x) => set.has(x)))
|
|
590
|
+
continue;
|
|
591
|
+
if (parents.some((x) => set.has(x) ? !feeds(set.get(x)) : !this.ready(x) || this.refusedOrFailed(x) !== null))
|
|
592
|
+
continue;
|
|
593
|
+
set.set(c.name, c);
|
|
594
|
+
grew = true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const order = [], placed = new Set(), members = [...set.values()];
|
|
598
|
+
for (;;) {
|
|
599
|
+
const next = members.find((m) => !placed.has(m.name) && this.parentsOf(m).every((x) => !set.has(x) || placed.has(x)));
|
|
600
|
+
if (next === undefined)
|
|
601
|
+
break;
|
|
602
|
+
order.push(next);
|
|
603
|
+
placed.add(next.name);
|
|
604
|
+
}
|
|
605
|
+
return order;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* A NAME A SYNCED TABLE NOW HOLDS (`mason add` of the generated table's name): the synced table wins, so what was deployed
|
|
609
|
+
* is forgotten, its ledger row with it, and the table under that name, now the copy's, is never touched. Not while a run
|
|
610
|
+
* has it: the run's end looks again, and its swap looks on the writer's turn and abandons the run if a synced table
|
|
611
|
+
* holds the name (`swap`).
|
|
612
|
+
*/
|
|
613
|
+
async release(e) {
|
|
614
|
+
if (e.deployed === null || e.running || e.queued)
|
|
615
|
+
return;
|
|
616
|
+
e.deployed = null;
|
|
617
|
+
e.dirtySince = null;
|
|
618
|
+
e.taken = null;
|
|
619
|
+
e.error = null;
|
|
620
|
+
await this.host.onWriter(() => this.store.writer.run(`DELETE FROM ${GENERATED_LEDGER} WHERE name = ${literal(e.name)}`));
|
|
621
|
+
this.host.log(`${e.name}: a synced table now holds its name, so the generated table is no longer kept`);
|
|
622
|
+
}
|
|
623
|
+
/** what to build from now: the file's definition where the catalog passed it, else what was deployed; nothing where a
|
|
624
|
+
* synced table holds the name */
|
|
625
|
+
buildable(e) {
|
|
626
|
+
if (this.host.added(e.name) !== null)
|
|
627
|
+
return null;
|
|
628
|
+
if (e.checked !== null && e.refusals.length === 0 && e.fileRefused === null)
|
|
629
|
+
return { definition: e.checked.definition, fingerprint: e.checked.fingerprint, bases: e.checked.bases };
|
|
630
|
+
return e.deployed === null ? null : { definition: e.deployed.definition, fingerprint: e.deployed.fingerprint, bases: e.deployed.bases };
|
|
631
|
+
}
|
|
632
|
+
/** queued when it is due: never built, a new definition, or the oldest change near its target */
|
|
633
|
+
schedule(e, now = Date.now()) {
|
|
634
|
+
const b = this.buildable(e);
|
|
635
|
+
if (b === null || e.queued || e.running || now < e.retryAt || this.stopped)
|
|
636
|
+
return;
|
|
637
|
+
if (e.deployed === null || e.deployed.fingerprint !== b.fingerprint) {
|
|
638
|
+
this.queue(e);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (e.dirtySince === null || e.deployed.bases.some((x) => !this.ready(x)))
|
|
642
|
+
return;
|
|
643
|
+
if (now - e.dirtySince >= (b.definition.targetLag.seconds * 1000) - (e.deployed.seconds * 1000))
|
|
644
|
+
this.queue(e);
|
|
645
|
+
}
|
|
646
|
+
queue(e) { e.queued = true; this.host.enqueue(e.name); }
|
|
647
|
+
// ------------------------------------------------------------------------------------------------------ the run
|
|
648
|
+
/** ONE RUN, in the rebuild queue: never throws but `Stopping`; its failure is its own to say. A run of a table is its
|
|
649
|
+
* chain's: a parent's builds its children after it, and a child's is its dirty parent's (`rootOf`, `unitOf`). A table
|
|
650
|
+
* that fails takes the tables built from it out of the run, which is tried again without them.
|
|
651
|
+
* ONE RUN AT A TIME, whoever asks: a run asked while another is in flight waits for it to end. `rootOf` and `unitOf`
|
|
652
|
+
* leave out a table a run has (`running`), so a run beside another would build a parent and leave its child over the
|
|
653
|
+
* old one; the rebuild queue asks one at a time, and this holds it for any other caller too (issue 195). */
|
|
654
|
+
run(name) {
|
|
655
|
+
const ran = this.runs.then(() => this.runAlone(name));
|
|
656
|
+
// the next waits for this one's end, not its outcome: its Stopping is its caller's
|
|
657
|
+
this.runs = ran.then(() => undefined, () => undefined);
|
|
658
|
+
return ran;
|
|
659
|
+
}
|
|
660
|
+
async runAlone(name) {
|
|
661
|
+
const asked = this.entries.get(name), planner = this.planner;
|
|
662
|
+
if (asked === undefined)
|
|
663
|
+
return;
|
|
664
|
+
asked.queued = false;
|
|
665
|
+
if (this.buildable(asked) === null || planner === null || this.stopped)
|
|
666
|
+
return;
|
|
667
|
+
const why = this.blocker(asked);
|
|
668
|
+
if (why !== null) {
|
|
669
|
+
this.block(asked, why);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const root = this.rootOf(asked);
|
|
673
|
+
let members = this.unitOf(root);
|
|
674
|
+
// a child the run leaves out because a parent in it is refused, or is a new definition: said or looked at again
|
|
675
|
+
for (const c of this.entries.values()) {
|
|
676
|
+
if (members.includes(c) || !this.parentsOf(c).some((x) => members.some((m) => m.name === x)))
|
|
677
|
+
continue;
|
|
678
|
+
const blocked = this.blocker(c);
|
|
679
|
+
if (blocked !== null)
|
|
680
|
+
this.block(c, blocked);
|
|
681
|
+
}
|
|
682
|
+
while (members.length > 0) {
|
|
683
|
+
const failed = await this.build(members, planner);
|
|
684
|
+
if (failed === null)
|
|
685
|
+
return;
|
|
686
|
+
const gone = new Set([failed.name]);
|
|
687
|
+
for (const m of members)
|
|
688
|
+
if (this.parentsOf(m).some((x) => gone.has(x))) {
|
|
689
|
+
gone.add(m.name);
|
|
690
|
+
this.block(m, failed.name);
|
|
691
|
+
}
|
|
692
|
+
members = members.filter((m) => !gone.has(m.name));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** `members` built, parents first, in ONE transaction over one snapshot, each child over its parents' side tables, and
|
|
696
|
+
* swapped in on ONE writer's turn. The table that failed, its failure said; null when built or abandoned */
|
|
697
|
+
async build(members, planner) {
|
|
698
|
+
const started = Date.now();
|
|
699
|
+
let open = false, at = members[0];
|
|
700
|
+
// what each builds, read now, before anything awaits: a sources file reloaded while it plans changes nothing of this
|
|
701
|
+
// run. One with nothing to build now (its name taken since the run was asked): nothing is built, the review looks again
|
|
702
|
+
const buildables = members.map((m) => this.buildable(m));
|
|
703
|
+
if (buildables.some((b) => b === null))
|
|
704
|
+
return null;
|
|
705
|
+
const c = this.host.connection();
|
|
706
|
+
for (const m of members)
|
|
707
|
+
m.running = true;
|
|
708
|
+
try {
|
|
709
|
+
const sides = new Map();
|
|
710
|
+
const plans = [];
|
|
711
|
+
for (const [i, m] of members.entries()) {
|
|
712
|
+
at = m;
|
|
713
|
+
const b = buildables[i], d = b.definition;
|
|
714
|
+
// planned now, for what it reads as of now (a stored JSON path may have come or gone since it was checked), a
|
|
715
|
+
// parent built in this run read as its side table
|
|
716
|
+
const relations = new Map(b.bases.filter((x) => sides.has(x)).map((x) => [x, sides.get(x)]));
|
|
717
|
+
const planning = planDefinition(planner, d.sql, d.orderBy, relations);
|
|
718
|
+
this.planning = planning;
|
|
719
|
+
const planned = await planning.finally(() => { if (this.planning === planning)
|
|
720
|
+
this.planning = null; });
|
|
721
|
+
// a stop came while it planned: nothing is built, and nothing failed
|
|
722
|
+
if (this.stopped)
|
|
723
|
+
return null;
|
|
724
|
+
if ("refused" in planned)
|
|
725
|
+
throw new Error(planned.refused[0].message);
|
|
726
|
+
sides.set(m.name, `${d.table}${SIDE_SUFFIX}`);
|
|
727
|
+
plans.push({ e: m, b, plan: planned.definition, target: `${ident(d.schema)}.${ident(d.table)}`, side: `${ident(d.schema)}.${ident(`${d.table}${SIDE_SUFFIX}`)}` });
|
|
728
|
+
}
|
|
729
|
+
at = members[0];
|
|
730
|
+
await this.host.settle();
|
|
731
|
+
for (const s of new Set(plans.map((p) => p.b.definition.schema)))
|
|
732
|
+
await c.run(`CREATE SCHEMA IF NOT EXISTS ${ident(s)}`);
|
|
733
|
+
await c.run("BEGIN TRANSACTION");
|
|
734
|
+
open = true;
|
|
735
|
+
const bases = [...new Set(plans.flatMap((p) => p.plan.bases))];
|
|
736
|
+
// THE SNAPSHOT AND THE MARKS, TOGETHER: this read takes the snapshot on the writer's turn, so a commit before it is
|
|
737
|
+
// in the snapshot and a commit after it marks the next run. Every table of the chain reads this one snapshot
|
|
738
|
+
const clock = await this.host.onWriter(async () => {
|
|
739
|
+
await c.runAndReadAll(`SELECT count(*) FROM ${GENERATED_LEDGER}`);
|
|
740
|
+
for (const m of members) {
|
|
741
|
+
m.taken = m.dirtySince;
|
|
742
|
+
m.dirtySince = null;
|
|
743
|
+
}
|
|
744
|
+
// a parent of this run is recorded at this snapshot, one served at its own, read on this turn as its swap is
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
return { at: now, synced: Object.fromEntries(bases.map((x) => [x, members.some((m) => m.name === x) ? now : this.baseSynced(x)])) };
|
|
747
|
+
});
|
|
748
|
+
for (const m of members)
|
|
749
|
+
await this.onStep("snapshot", m.name);
|
|
750
|
+
const written = [];
|
|
751
|
+
for (const p of plans) {
|
|
752
|
+
at = p.e;
|
|
753
|
+
const { plan } = p.plan;
|
|
754
|
+
// a computed numeric's DECIMAL arithmetic, over the engine types of the SQL that runs (a parent's side table now
|
|
755
|
+
// there), never assumed: what the check held, the write holds again
|
|
756
|
+
const stored = !p.plan.columns.some(isNumeric) ? { columns: p.plan.columns, layers: [] } : storedColumns(p.plan, await engineTypes(c, plan.sql.text));
|
|
757
|
+
// refused here, it is a refusal as the check's are, with its options: never a failure retried on a timer
|
|
758
|
+
if ("refused" in stored)
|
|
759
|
+
throw new RefusedAtWrite(p.e, stored.refused);
|
|
760
|
+
const { columns, layers } = stored;
|
|
761
|
+
const params = [];
|
|
762
|
+
if (plan.clock?.exec !== undefined)
|
|
763
|
+
params[plan.clock.exec - 1] = new Date(clock.at).toISOString();
|
|
764
|
+
const aliases = columns.map((_, i) => `__mason_c${i}`);
|
|
765
|
+
// a computed numeric's shared nodes, each written once as a column of its own, a layer over the one before
|
|
766
|
+
// (src/duck/generated-plan.ts `storedProgram`): the columns' expressions read them by name
|
|
767
|
+
const from = layers.reduce((inner, layer) => `(SELECT *, ${layer} FROM ${inner}) AS __mason_output`, `(${plan.sql.text}) AS __mason_output(${aliases.join(", ")})`);
|
|
768
|
+
const sql = `CREATE OR REPLACE TABLE ${p.side} (${columns.map((x) => ident(x.name)).join(", ")}) AS SELECT ${columns.map((x, i) => { const v = x.expr ?? aliases[i]; return x.duck === null ? v : `CAST(${v} AS ${x.duck})`; }).join(", ")} ` +
|
|
769
|
+
`FROM ${from}`;
|
|
770
|
+
const { rowsChanged } = await this.host.interruptible(() => c.run(sql, params.length > 0 ? params : undefined));
|
|
771
|
+
await this.onStep("written", p.e.name);
|
|
772
|
+
written.push({ e: p.e, side: p.side, target: p.target, n: { definition: p.b.definition, fingerprint: p.b.fingerprint, columns, bases: p.plan.bases,
|
|
773
|
+
basesSynced: Object.fromEntries(p.plan.bases.map((x) => [x, clock.synced[x] ?? null])), snapshotAt: clock.at, builtAt: 0, rows: Number(rowsChanged), seconds: 0 } });
|
|
774
|
+
}
|
|
775
|
+
at = members[0];
|
|
776
|
+
await c.run("COMMIT");
|
|
777
|
+
open = false;
|
|
778
|
+
for (const m of members)
|
|
779
|
+
await this.onStep("committed", m.name);
|
|
780
|
+
const seconds = (Date.now() - started) / 1000, builtAt = Date.now();
|
|
781
|
+
for (const w of written) {
|
|
782
|
+
w.n.seconds = seconds;
|
|
783
|
+
w.n.builtAt = builtAt;
|
|
784
|
+
}
|
|
785
|
+
if (!await this.host.onWriter(() => this.swap(written))) {
|
|
786
|
+
// a synced table took a name while it ran: what it built is dropped, and the next review lets the name go; what
|
|
787
|
+
// this run took is still not in the table, so it stays dirty should the name come back (the sources file reverted)
|
|
788
|
+
for (const m of members) {
|
|
789
|
+
m.dirtySince = this.oldest(m);
|
|
790
|
+
m.taken = null;
|
|
791
|
+
}
|
|
792
|
+
this.host.log(`${members.map((m) => m.name).join(", ")}: a synced table now holds ${members.length === 1 ? "its name" : "one of their names"}, so what this run built was dropped`);
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
for (const m of members)
|
|
796
|
+
await this.onStep("swapped", m.name);
|
|
797
|
+
// what each swap brought, across the run: the oldest change any member at or below it took (parents come first in
|
|
798
|
+
// `written`), so a child left out under a member that took nothing itself is marked at the base change, never at
|
|
799
|
+
// the swap
|
|
800
|
+
const chainTook = new Map();
|
|
801
|
+
for (const w of written) {
|
|
802
|
+
let t = w.e.taken;
|
|
803
|
+
for (const p of w.n.bases) {
|
|
804
|
+
const q = chainTook.get(p);
|
|
805
|
+
if (q !== undefined && q !== null && (t === null || q < t))
|
|
806
|
+
t = q;
|
|
807
|
+
}
|
|
808
|
+
chainTook.set(w.e.name, t);
|
|
809
|
+
}
|
|
810
|
+
for (const w of written) {
|
|
811
|
+
const e = w.e, was = e.error;
|
|
812
|
+
e.deployed = w.n;
|
|
813
|
+
e.taken = null;
|
|
814
|
+
e.error = null;
|
|
815
|
+
e.failures = 0;
|
|
816
|
+
e.retryAt = 0;
|
|
817
|
+
e.blocked = null;
|
|
818
|
+
// what reads it and was not built with it is behind it, whatever its definition: a new one, a child this run
|
|
819
|
+
// left out, or one whose own build failed and was taken out of the run (its marks were the parent's)
|
|
820
|
+
this.changedGenerated(e.name, members, chainTook.get(e.name) ?? null);
|
|
821
|
+
if (was !== null)
|
|
822
|
+
this.host.event({ kind: "recovered", table: e.name, message: `its re-run succeeded (it had failed: ${was})` });
|
|
823
|
+
}
|
|
824
|
+
await this.host.rebuildCatalog();
|
|
825
|
+
for (const w of written)
|
|
826
|
+
this.host.log(`generated ${w.e.name}: ${w.n.rows.toLocaleString("en-US")} row(s) in ${seconds.toFixed(1)} s`);
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
catch (x) {
|
|
830
|
+
if (open)
|
|
831
|
+
await c.run("ROLLBACK");
|
|
832
|
+
// what this run took is still not in the tables
|
|
833
|
+
for (const m of members) {
|
|
834
|
+
m.dirtySince = this.oldest(m);
|
|
835
|
+
m.taken = null;
|
|
836
|
+
}
|
|
837
|
+
if (this.host.stopping(x))
|
|
838
|
+
throw x;
|
|
839
|
+
if (x instanceof RefusedAtWrite) {
|
|
840
|
+
// what the write's engine types refuse is the table's refusal, with its ways out, as at a check: it keeps serving
|
|
841
|
+
// what it holds and is not run again until its definition or the schema changes (the check's `seen`)
|
|
842
|
+
const e = x.entry;
|
|
843
|
+
e.checked = null;
|
|
844
|
+
e.refusals = x.refused;
|
|
845
|
+
this.say(e);
|
|
846
|
+
this.host.log(`${e.name}: refused at its run: ${x.refused.map((r) => r.message).join("; ")}`);
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
at.failures++;
|
|
850
|
+
const attempt = Date.now() - started;
|
|
851
|
+
at.retryAt = Date.now() + this.host.retryDelayMs(at.failures, attempt);
|
|
852
|
+
// a numeric past the 38 digits a stored DECIMAL holds is the data's, not the definition's: said with its way out,
|
|
853
|
+
// and run again on the back-off, since a change to the rows can bring it back within them
|
|
854
|
+
at.error = failureWords(x);
|
|
855
|
+
const m = `its re-run failed (it serves what it last held; tried again after ${new Date(at.retryAt).toISOString()}): ${at.error}`;
|
|
856
|
+
this.host.log(`${at.name}: ${m}`);
|
|
857
|
+
this.host.event({ kind: "generated_failed", table: at.name, message: m });
|
|
858
|
+
return at;
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
for (const m of members)
|
|
862
|
+
m.running = false;
|
|
863
|
+
}
|
|
864
|
+
// a file gone, or changed, while it ran: the next review looks
|
|
865
|
+
}
|
|
866
|
+
/** a generated table swapped in: what reads it and was not built with it is marked, as a base's commit marks, at the
|
|
867
|
+
* oldest base change the swap brought (`took`), so its lag is still measured against the base; now for a new
|
|
868
|
+
* definition, which no base change brought */
|
|
869
|
+
changedGenerated(name, built, took) {
|
|
870
|
+
const at = took ?? Date.now();
|
|
871
|
+
for (const e of this.entries.values()) {
|
|
872
|
+
if (built.includes(e) || !e.deployed?.bases.includes(name))
|
|
873
|
+
continue;
|
|
874
|
+
if (e.dirtySince === null || at < e.dirtySince)
|
|
875
|
+
e.dirtySince = at;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/** THE SWAP, ON THE WRITER'S TURN, as a copy's is: every side table of the run replaces its table, with its ledger
|
|
879
|
+
* row, in ONE transaction, never beside a write, so a chain swaps in at one moment. The ledger row is what the table
|
|
880
|
+
* is re-run from after a restart. False, and the side tables dropped, when a synced table now holds a name: on this
|
|
881
|
+
* turn, so no copy's swap comes between. */
|
|
882
|
+
async swap(built) {
|
|
883
|
+
const w = this.store.writer;
|
|
884
|
+
if (built.some((b) => this.host.added(b.e.name) !== null)) {
|
|
885
|
+
for (const b of built)
|
|
886
|
+
await w.run(`DROP TABLE IF EXISTS ${b.side}`);
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
await w.run("BEGIN TRANSACTION");
|
|
890
|
+
try {
|
|
891
|
+
for (const { e, n, side, target } of built) {
|
|
892
|
+
const d = n.definition;
|
|
893
|
+
await w.run(`DROP TABLE IF EXISTS ${target}`);
|
|
894
|
+
await w.run(`ALTER TABLE ${side} RENAME TO ${ident(d.table)}`);
|
|
895
|
+
await w.run(`INSERT OR REPLACE INTO ${GENERATED_LEDGER} VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::TIMESTAMPTZ, $12::TIMESTAMPTZ, $13, $14, $15)`, [e.name, d.file, d.sql, d.orderBy, d.targetLag.every, d.targetLag.seconds, d.comment, n.fingerprint, JSON.stringify(n.columns), JSON.stringify(n.bases),
|
|
896
|
+
new Date(n.snapshotAt).toISOString(), new Date(n.builtAt).toISOString(), n.rows, n.seconds, JSON.stringify(n.basesSynced)]);
|
|
897
|
+
}
|
|
898
|
+
await w.run("COMMIT");
|
|
899
|
+
}
|
|
900
|
+
catch (x) {
|
|
901
|
+
await w.run("ROLLBACK");
|
|
902
|
+
throw x;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
}
|