@mason-data/cli 0.6.2 → 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/README.md +71 -12
- package/dist/src/api/postgres/catalog.js +36 -1
- package/dist/src/api/postgres/server.js +133 -32
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cli/background.js +29 -5
- package/dist/src/cli/command.js +4 -3
- package/dist/src/cli/commands.js +259 -127
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +113 -20
- package/dist/src/cli/doctor.js +6 -3
- package/dist/src/cli/guides.js +32 -8
- package/dist/src/cli/init.js +58 -18
- package/dist/src/cli/machine.js +8 -3
- package/dist/src/cli/main.js +2 -2
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/preflight.js +16 -7
- package/dist/src/cli/project.js +5 -3
- package/dist/src/cli/registry.js +8 -5
- package/dist/src/cli/run-server.js +29 -106
- package/dist/src/cli/serve.js +6 -1
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +6 -4
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +7 -2
- package/dist/src/duck/executor.js +67 -26
- package/dist/src/duck/explain.js +128 -9
- package/dist/src/duck/functions.js +60 -5
- package/dist/src/duck/gaps.js +9 -4
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +206 -39
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +21 -8
- package/dist/src/duck/rewrite.js +1168 -68
- package/dist/src/duck/store.js +132 -56
- package/dist/src/duck/sync.js +90 -25
- package/dist/src/duck/verified.js +6 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +11 -0
- package/dist/src/web/app.js +167 -12
- package/dist/src/web/index.html +6 -1
- package/package.json +1 -1
package/dist/src/duck/store.js
CHANGED
|
@@ -30,7 +30,7 @@ import { dataRow } from "../api/postgres/protocol.js";
|
|
|
30
30
|
import { cellText, exactColumn, pgTypedColumn, wireColumn } from "./types.js";
|
|
31
31
|
import { gapMark } from "./gaps.js";
|
|
32
32
|
import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
|
|
33
|
-
import { explainJson, explainText, planNodes, scannedTables } from "./explain.js";
|
|
33
|
+
import { explainJson, explainText, planNodes, profiled, qualifiedName, scannedTables } from "./explain.js";
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
export const MASON_SCHEMA = "_mason";
|
|
@@ -49,8 +49,16 @@ export const DUCKDB_VERSION = version();
|
|
|
49
49
|
* disk at 125 GB, against 0.26 GB forced; a sample of its text columns 4.5× smaller, the integer, UUID and timestamp
|
|
50
50
|
* columns keeping their own methods, BitPacking and RLE, since forcing only touches what ZSTD can store). The cost of
|
|
51
51
|
* reading a ZSTD column is not measured. tests/duck-wire.test.ts holds a string column to ZSTD.
|
|
52
|
+
*
|
|
53
|
+
* `lambda_syntax: ENABLE_SINGLE_ARROW`: the rewrite is a Postgres parse tree, printed by Postgres's deparser, which
|
|
54
|
+
* can spell DuckDB's lambda only as `x -> f(x)` (Postgres's `->` operator), never as `lambda x: f(x)`; DuckDB 2.0
|
|
55
|
+
* refuses that spelling by default. Two rewrites need a lambda: a jsonb's order under ICU or a libc locale, which maps
|
|
56
|
+
* DuckDB's `icu_sort_key` over the document's strings (src/duck/rewrite.ts `jsonbKey`; tests/duck-jsonb-order.test.ts
|
|
57
|
+
* holds the key under every collation), and a LEFT lateral's unnest of an expression, which computes the expression
|
|
58
|
+
* once with `list_transform(list_value(e), l -> CASE … l … END)[1]` (`rangeFunction`; tests/duck-wire.test.ts). A `->`
|
|
59
|
+
* that is not a lambda function's argument is still the json operator.
|
|
52
60
|
*/
|
|
53
|
-
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd" };
|
|
61
|
+
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd", lambda_syntax: "ENABLE_SINGLE_ARROW" };
|
|
54
62
|
/**
|
|
55
63
|
* DUCKDB'S MEMORY, BOUNDED FOR THIS PROCESS: DuckDB's default is 80% of the machine's RAM, in a process
|
|
56
64
|
* that also holds V8's heap and two PGlites, which ends with the system killing every session and the sync together.
|
|
@@ -140,7 +148,7 @@ export class DuckStore {
|
|
|
140
148
|
this.instance = { connect: () => executor.connect() };
|
|
141
149
|
}
|
|
142
150
|
/** `silentMs` and `closeGraceMs`: the executor's bounds (src/duck/executor.ts SILENT_MS, CLOSE_GRACE_MS), shorter only
|
|
143
|
-
* in a test */
|
|
151
|
+
* in a test; `workerFile`, the executor worker's module, another only in a test */
|
|
144
152
|
static async open(file, log, options = {}) {
|
|
145
153
|
const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
|
146
154
|
if (!MEMORY_LIMIT_RE.test(memoryLimit))
|
|
@@ -160,7 +168,7 @@ export class DuckStore {
|
|
|
160
168
|
await before.closed;
|
|
161
169
|
// every connection's, the sync's and each session's
|
|
162
170
|
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, memory_limit: memoryLimit, temp_directory: `${file}.tmp` }, log,
|
|
163
|
-
...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}) });
|
|
171
|
+
...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}), ...(options.workerFile !== undefined ? { workerFile: options.workerFile } : {}) });
|
|
164
172
|
}
|
|
165
173
|
catch (e) {
|
|
166
174
|
if (opened.get(where) === mine)
|
|
@@ -180,6 +188,8 @@ export class DuckStore {
|
|
|
180
188
|
onCrash(f) { this.executor.onCrash(f); }
|
|
181
189
|
/** how many vectors each Mason function was called with so far, on the copy's thread (a test's, a measurement's) */
|
|
182
190
|
functionCalls() { return this.executor.functionCalls(); }
|
|
191
|
+
/** how many rows each Mason function was handed so far, on the copy's thread (a plan's cost in calls) */
|
|
192
|
+
functionRows() { return this.executor.functionRows(); }
|
|
183
193
|
/** a connection for one Postgres session (on the executor worker: SessionRunner) */
|
|
184
194
|
runner() { return this.executor.runner(); }
|
|
185
195
|
/**
|
|
@@ -211,11 +221,15 @@ export class DuckStore {
|
|
|
211
221
|
throw new Error(`the database orders text by the libc collation ${collation.collate}, and its order was not read (Mason bug: report it)`);
|
|
212
222
|
await this.executor.registerLibcOrder(libc);
|
|
213
223
|
}
|
|
224
|
+
// a jsonb's stored key is this collation's (src/duck/kept.ts `orderBy`)
|
|
225
|
+
await kept?.orderBy(c, c.kind === "libc" ? libc : undefined);
|
|
214
226
|
return {
|
|
215
227
|
collation: c,
|
|
216
228
|
open: () => this.runner(),
|
|
217
|
-
plan: (catalog, text, stmt, base, paramOids, searchPath, timeZone, userTypes) => planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, c, verified, userTypes, copies, 0, kept ?? null),
|
|
229
|
+
plan: (catalog, text, stmt, base, paramOids, searchPath, timeZone, userTypes, wireBound) => planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, c, verified, userTypes, copies, 0, kept ?? null, wireBound ?? false),
|
|
218
230
|
parameters: duckParameters,
|
|
231
|
+
// the sync's state, which EXPLAIN says how each copy is kept current from (src/duck/sync.ts KeptCurrent)
|
|
232
|
+
copies,
|
|
219
233
|
// a stored JSON path ready or gone (src/duck/kept.ts): the plans kept are made again
|
|
220
234
|
...(kept !== undefined ? { generation: () => kept.generation() } : {}),
|
|
221
235
|
};
|
|
@@ -431,8 +445,9 @@ export class SessionRunner {
|
|
|
431
445
|
return p;
|
|
432
446
|
}
|
|
433
447
|
/** `run`, and where its time goes as it is read: DuckDB's (its prepare, which plans; each chunk it computes) and
|
|
434
|
-
* Mason's (the exact numerics, the encoding), ms — what an EXPLAIN ANALYZE says
|
|
435
|
-
|
|
448
|
+
* Mason's (the exact numerics, the encoding), ms — what an EXPLAIN ANALYZE says. `profile`: the file DuckDB's
|
|
449
|
+
* profiler writes this statement's profile to (EXPLAIN ANALYZE), turned on here, after the settle */
|
|
450
|
+
async streamed(planned, params, session, signal, fields, exact, profile = null) {
|
|
436
451
|
const sql = planned.text;
|
|
437
452
|
const seq = this.next;
|
|
438
453
|
await Promise.all([...this.releasing]);
|
|
@@ -445,6 +460,19 @@ export class SessionRunner {
|
|
|
445
460
|
catch (e) {
|
|
446
461
|
throw pgErrorOf(e, sql);
|
|
447
462
|
}
|
|
463
|
+
// THE PROFILER IS ON FOR THE STATEMENT ALONE: DuckDB writes the profile of the LAST statement it profiled on the
|
|
464
|
+
// connection, so it is turned on after every other statement Mason runs here (the settle's probe of the schemas,
|
|
465
|
+
// turned on before it, was reported as the plan of a count(*) that wrote no profile of its own), and the caller turns
|
|
466
|
+
// it off before it runs another (`explained`, which also holds the profile to the statement's text)
|
|
467
|
+
if (profile !== null) {
|
|
468
|
+
try {
|
|
469
|
+
await this.conn.run("SET enable_profiling = 'json'");
|
|
470
|
+
await this.conn.run(`SET profiling_output = ${quoteLiteral(profile)}`);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
throw pgErrorOf(e, sql);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
448
476
|
const timing = { prepareMs: 0, duckdbMs: 0, masonMs: 0 };
|
|
449
477
|
const t0 = performance.now();
|
|
450
478
|
const p = await this.prepare(sql);
|
|
@@ -513,7 +541,15 @@ export class SessionRunner {
|
|
|
513
541
|
// call raises) or read to its end, then released — the session's next statement waits for that (`releasing`)
|
|
514
542
|
const drain = () => {
|
|
515
543
|
const r = result;
|
|
516
|
-
|
|
544
|
+
// CLOSED WHILE DuckDB STILL STARTS IT (its `stream()` pending: an aggregate computes there whole): nothing is
|
|
545
|
+
// released under the pending start — that destroyed the prepared statement under it and, its release done, let the
|
|
546
|
+
// start's own failure skip `cleanUp`, so the connection and the instance closed while its other tasks still had
|
|
547
|
+
// calls queued for this thread, and the thread's end met them (Linux CI: `FATAL ERROR: Error::Error
|
|
548
|
+
// napi_define_properties`, 7 runs in 30 of a stop under a calling count). The start's own path releases it: its
|
|
549
|
+
// failure through `releaseAfterCleanup`, its answer drained once handed back (below `stream()`)
|
|
550
|
+
if (r === null)
|
|
551
|
+
return;
|
|
552
|
+
if (!callsFunctions) {
|
|
517
553
|
release();
|
|
518
554
|
return;
|
|
519
555
|
}
|
|
@@ -565,6 +601,18 @@ export class SessionRunner {
|
|
|
565
601
|
catch (e) {
|
|
566
602
|
throw failure(e);
|
|
567
603
|
}
|
|
604
|
+
// CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back. The two differ: one that calls no
|
|
605
|
+
// function is released at once (its close cancels the executor) and so is never read after — told the cancel
|
|
606
|
+
// here, before a column of a destroyed result is asked (DuckDB answers 0 columns for one: a review finding, an
|
|
607
|
+
// XX000 "report it" where the session had closed); one that calls is drained, which holds the result open until
|
|
608
|
+
// its calls stop, so its answer below is built from a live result and its fetch tells the cancel
|
|
609
|
+
if (closed) {
|
|
610
|
+
if (!callsFunctions) {
|
|
611
|
+
release();
|
|
612
|
+
throw cancelled();
|
|
613
|
+
}
|
|
614
|
+
drain();
|
|
615
|
+
}
|
|
568
616
|
timing.duckdbMs += performance.now() - t1;
|
|
569
617
|
const r = result;
|
|
570
618
|
if (fields !== null && r.columnCount < fields.length)
|
|
@@ -649,7 +697,7 @@ export class SessionRunner {
|
|
|
649
697
|
* EXPLAIN of a plan (src/duck/explain.ts). Without ANALYZE: DuckDB's `EXPLAIN (FORMAT JSON)` of the planned SQL, its
|
|
650
698
|
* parameters bound. With it: the statement RUN as `run` runs it — every chunk read, its exact numerics computed and
|
|
651
699
|
* encoded, none sent — with DuckDB's profiler writing each operator's rows and time to a file of its own, read after.
|
|
652
|
-
* Then the copies its scans read: their rows, and
|
|
700
|
+
* Then the copies its scans read: their rows, and how each is kept current as the sync says it (`how.copies`).
|
|
653
701
|
*/
|
|
654
702
|
explain(sql, params, session, signal, how) {
|
|
655
703
|
return this.track(this.explained(sql, params, session, signal, how));
|
|
@@ -659,47 +707,16 @@ export class SessionRunner {
|
|
|
659
707
|
const o = how.options;
|
|
660
708
|
let plan;
|
|
661
709
|
let analyzed;
|
|
662
|
-
if (!o.analyze)
|
|
663
|
-
|
|
664
|
-
await this.settle(session);
|
|
665
|
-
}
|
|
666
|
-
catch (e) {
|
|
667
|
-
throw pgErrorOf(e, sql.text);
|
|
668
|
-
}
|
|
669
|
-
const p = await this.prepare(`EXPLAIN (FORMAT JSON) ${sql.text}`);
|
|
670
|
-
const interrupt = () => this.conn.interrupt();
|
|
671
|
-
signal.addEventListener("abort", interrupt);
|
|
672
|
-
try {
|
|
673
|
-
if (signal.aborted)
|
|
674
|
-
throw failureOf(signal, null, sql.text);
|
|
675
|
-
params.forEach((v, i) => { if (v === null)
|
|
676
|
-
p.bindNull(i + 1);
|
|
677
|
-
else
|
|
678
|
-
p.bindVarchar(i + 1, v); });
|
|
679
|
-
let r;
|
|
680
|
-
try {
|
|
681
|
-
r = await p.runAndReadAll();
|
|
682
|
-
}
|
|
683
|
-
catch (e) {
|
|
684
|
-
throw failureOf(signal, e, sql.text);
|
|
685
|
-
}
|
|
686
|
-
plan = planNodes(JSON.parse(String(r.getRowsJS()[0]?.[1] ?? "[]")));
|
|
687
|
-
}
|
|
688
|
-
finally {
|
|
689
|
-
signal.removeEventListener("abort", interrupt);
|
|
690
|
-
p.destroySync();
|
|
691
|
-
}
|
|
692
|
-
}
|
|
710
|
+
if (!o.analyze)
|
|
711
|
+
plan = await this.plainPlan(sql, params, session, signal);
|
|
693
712
|
else {
|
|
694
713
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mason-explain-"));
|
|
695
714
|
const file = path.join(dir, "profile.json");
|
|
696
715
|
try {
|
|
697
716
|
let rows = 0;
|
|
698
717
|
let timing;
|
|
699
|
-
await this.conn.run("SET enable_profiling = 'json'");
|
|
700
718
|
try {
|
|
701
|
-
await this.
|
|
702
|
-
const s = await this.streamed(sql, params, session, signal, how.fields, how.exact);
|
|
719
|
+
const s = await this.streamed(sql, params, session, signal, how.fields, how.exact, file);
|
|
703
720
|
timing = s.timing;
|
|
704
721
|
try {
|
|
705
722
|
for (let chunk = await s.answer.fetch([], { timeZone: session.timeZone }); chunk !== null; chunk = await s.answer.fetch([], { timeZone: session.timeZone }))
|
|
@@ -712,28 +729,71 @@ export class SessionRunner {
|
|
|
712
729
|
finally {
|
|
713
730
|
await this.conn.run("PRAGMA disable_profiling");
|
|
714
731
|
}
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
732
|
+
// THE PROFILE IS HELD TO THE STATEMENT, never assumed its: DuckDB writes one when the result is read to its end,
|
|
733
|
+
// naming the statement it timed. None: DuckDB ran no operator (it answered while planning — a count(*) of a
|
|
734
|
+
// whole table is the table's row count), and its plan is its EXPLAIN, the rows the ones it answered
|
|
735
|
+
const profile = fs.existsSync(file) ? profiled(JSON.parse(fs.readFileSync(file, "utf8"))) : null;
|
|
736
|
+
if (profile !== null && profile.sql !== sql.text) {
|
|
737
|
+
throw new StatementError({ code: "XX000", message: `DuckDB's profile of an EXPLAIN ANALYZE is of another statement (${oneLine(profile.sql ?? "none named")}): report it (Mason's EXPLAIN)` }, sql.text);
|
|
738
|
+
}
|
|
739
|
+
if (profile !== null)
|
|
740
|
+
plan = profile.plan;
|
|
741
|
+
else {
|
|
742
|
+
plan = await this.plainPlan(sql, params, session, signal);
|
|
743
|
+
if (plan[0] !== undefined)
|
|
744
|
+
plan[0].actualRows = rows;
|
|
745
|
+
}
|
|
746
|
+
analyzed = { rows, profiled: profile !== null, planning: { ...how.planning, duckdbMs: timing.prepareMs }, execution: { duckdbMs: timing.duckdbMs, masonMs: timing.masonMs } };
|
|
718
747
|
}
|
|
719
748
|
finally {
|
|
720
749
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
721
750
|
}
|
|
722
751
|
}
|
|
723
752
|
// what it calls is the plan's (src/duck/plan.ts PlannedSql), the list that decides how it is cancelled
|
|
724
|
-
const explained = { copies: await this.copies(scannedTables(plan)), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
753
|
+
const explained = { copies: await this.copies(scannedTables(plan), how.copies), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
725
754
|
functions: [...sql.functions], plan, ...(analyzed === undefined ? {} : { analyzed }) };
|
|
726
755
|
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
727
756
|
}
|
|
728
|
-
/**
|
|
729
|
-
async
|
|
730
|
-
|
|
757
|
+
/** DuckDB's `EXPLAIN (FORMAT JSON)` of the planned SQL, its parameters bound: its plan, estimates only */
|
|
758
|
+
async plainPlan(sql, params, session, signal) {
|
|
759
|
+
try {
|
|
760
|
+
await this.settle(session);
|
|
761
|
+
}
|
|
762
|
+
catch (e) {
|
|
763
|
+
throw pgErrorOf(e, sql.text);
|
|
764
|
+
}
|
|
765
|
+
const p = await this.prepare(`EXPLAIN (FORMAT JSON) ${sql.text}`);
|
|
766
|
+
const interrupt = () => this.conn.interrupt();
|
|
767
|
+
signal.addEventListener("abort", interrupt);
|
|
768
|
+
try {
|
|
769
|
+
if (signal.aborted)
|
|
770
|
+
throw failureOf(signal, null, sql.text);
|
|
771
|
+
params.forEach((v, i) => { if (v === null)
|
|
772
|
+
p.bindNull(i + 1);
|
|
773
|
+
else
|
|
774
|
+
p.bindVarchar(i + 1, v); });
|
|
775
|
+
let r;
|
|
776
|
+
try {
|
|
777
|
+
r = await p.runAndReadAll();
|
|
778
|
+
}
|
|
779
|
+
catch (e) {
|
|
780
|
+
throw failureOf(signal, e, sql.text);
|
|
781
|
+
}
|
|
782
|
+
return planNodes(JSON.parse(String(r.getRowsJS()[0]?.[1] ?? "[]")));
|
|
783
|
+
}
|
|
784
|
+
finally {
|
|
785
|
+
signal.removeEventListener("abort", interrupt);
|
|
786
|
+
p.destroySync();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/** each copy's rows, and the sync's facts of it (how it is kept current: src/duck/sync.ts `KeptCurrent`, never the
|
|
790
|
+
* poll's ledger read here — a project switched to logical replication still holds its last poll there) */
|
|
791
|
+
async copies(tables, facts) {
|
|
731
792
|
const out = [];
|
|
732
|
-
for (const
|
|
733
|
-
const [schema, name] = table.split(".");
|
|
793
|
+
for (const { schema, name } of tables) {
|
|
734
794
|
const rows = (await this.conn.runAndReadAll("SELECT estimated_size FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = $1 AND table_name = $2", [schema, name])).getRowsJS()[0]?.[0];
|
|
735
|
-
|
|
736
|
-
out.push({ table, rows: rows === undefined || rows === null ? null : Number(rows),
|
|
795
|
+
// the sync's source is the two names as they are; EXPLAIN says the table as a statement writes it (qualifiedName)
|
|
796
|
+
out.push({ table: qualifiedName({ schema, name }), rows: rows === undefined || rows === null ? null : Number(rows), facts: facts.find((f) => f.source === `${schema}.${name}`) ?? null });
|
|
737
797
|
}
|
|
738
798
|
return out;
|
|
739
799
|
}
|
|
@@ -770,10 +830,26 @@ export class SessionRunner {
|
|
|
770
830
|
* answer released and every call of DuckDB settled (never under the executor: `drain`; a connection
|
|
771
831
|
* closed under a drain's pending fetch aborted the process). */
|
|
772
832
|
async close() {
|
|
773
|
-
this
|
|
833
|
+
// an answer left open is closed and drained — and one a statement still STARTING opens while this waits (the
|
|
834
|
+
// session closed between its ask and its answer: left open, the connection closed under it and its running tasks)
|
|
835
|
+
for (let last = null;;) {
|
|
836
|
+
const open = this.openAnswer;
|
|
837
|
+
if (open !== null && open !== last) {
|
|
838
|
+
last = open;
|
|
839
|
+
open();
|
|
840
|
+
}
|
|
841
|
+
await this.idle();
|
|
842
|
+
// released (null), or one already closed: done; a new one opened meanwhile: closed in turn
|
|
843
|
+
if (this.openAnswer === null || this.openAnswer === last)
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
this.conn.closeSync();
|
|
847
|
+
}
|
|
848
|
+
/** Settles once nothing runs on the connection: every answer released and every call of DuckDB settled (an answer
|
|
849
|
+
* closed early is answered then: src/duck/executor-worker.ts `answer.close`). */
|
|
850
|
+
async idle() {
|
|
774
851
|
for (let waiting = [...this.busy, ...this.releasing]; waiting.length > 0; waiting = [...this.busy, ...this.releasing])
|
|
775
852
|
await Promise.allSettled(waiting);
|
|
776
|
-
this.conn.closeSync();
|
|
777
853
|
}
|
|
778
854
|
}
|
|
779
855
|
function exactColumns(columns, fields, exact, typeAt) {
|
package/dist/src/duck/sync.js
CHANGED
|
@@ -623,9 +623,11 @@ export class Sync {
|
|
|
623
623
|
this.snapshots.set(source, snapshot);
|
|
624
624
|
}
|
|
625
625
|
await this.readPolledAt();
|
|
626
|
-
const r = await this.store.writer.runAndReadAll(`SELECT source, phase, "rows", bytes, seconds, copied_at
|
|
626
|
+
const r = await this.store.writer.runAndReadAll(`SELECT source, phase, "rows", bytes, seconds, epoch_ms(copied_at), error, columns::VARCHAR FROM ${MASON_SCHEMA}.copy_state`);
|
|
627
627
|
for (const row of r.getRowsJS()) {
|
|
628
|
-
const [source, phase, rows, bytes, seconds,
|
|
628
|
+
const [source, phase, rows, bytes, seconds, copiedMs, error, columns] = row;
|
|
629
|
+
// an instant as a run writes it (ISO, UTC), never DuckDB's text of it in the connection's zone
|
|
630
|
+
const copiedAt = copiedMs === null ? null : new Date(Number(copiedMs)).toISOString();
|
|
629
631
|
if (columns !== null) {
|
|
630
632
|
const c = JSON.parse(columns);
|
|
631
633
|
this.copiedAs.set(source, columnsKey(c));
|
|
@@ -644,6 +646,8 @@ export class Sync {
|
|
|
644
646
|
* here and current (said as its records say it, `addedText`), a removed one dropped.
|
|
645
647
|
* What it returns is the catalog rebuilt over them (never rejected: a failure is said in the log) */
|
|
646
648
|
setTables(tables) {
|
|
649
|
+
// (called at mason start and at mason add / remove only: the catalog's keys and constraints are the source's as of
|
|
650
|
+
// then — docs/DESIGN.md "A count of distinct keys")
|
|
647
651
|
const before = this.tables;
|
|
648
652
|
this.tables = new Map(tables.map((t) => [t.source, t]));
|
|
649
653
|
// said as it happens: what is added, each table as its records say it is (`addedText`), and what is no longer
|
|
@@ -715,12 +719,82 @@ export class Sync {
|
|
|
715
719
|
this.snapshots.set(s, "0:0:");
|
|
716
720
|
}
|
|
717
721
|
state() {
|
|
722
|
+
const now = Date.now();
|
|
718
723
|
// in the project's order, whatever order the copies ran and were recorded in (`copyOrder`)
|
|
719
724
|
return [...this.tables.keys()].flatMap((source) => this.states.get(source) ?? [])
|
|
720
725
|
.map((s) => this.retired.has(s.source) ? { ...s, retired: RETIRED_LAYOUT } : s)
|
|
721
726
|
// a copy's chunks as they are NOW: the rows the ones in flight have read by now at the measured rate
|
|
722
727
|
.map((s) => { const l = this.copyLive.get(s.source); return s.phase === "copying" && l !== undefined ? { ...s, progress: copyProgressNow(l, Date.now()) } : s; })
|
|
723
|
-
|
|
728
|
+
// how it is kept current, and a table the passes leave as copied, from ONE derivation (`keptOf`)
|
|
729
|
+
.map((s) => {
|
|
730
|
+
const t = this.tables.get(s.source);
|
|
731
|
+
if (t === undefined)
|
|
732
|
+
return s;
|
|
733
|
+
const kept = this.keptOf(t, now);
|
|
734
|
+
return { ...s, kept, ...(kept.by === "poll" && kept.unsynced !== undefined ? { unsynced: { reason: kept.unsynced, asOf: kept.polledAt } } : {}) };
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* HOW AN ADDED TABLE IS KEPT CURRENT, NOW (`KeptCurrent`): the run's mode, then in replication mode its part in the
|
|
739
|
+
* stream (`streamTable`, what `streamStatus` says of it) and where the stream is; in poll mode its last poll and
|
|
740
|
+
* whether the passes leave it as copied. The one place it is derived: `state` carries it to `mason status` and the
|
|
741
|
+
* listener's EXPLAIN alike.
|
|
742
|
+
*/
|
|
743
|
+
keptOf(t, now) {
|
|
744
|
+
const st = this.states.get(t.source);
|
|
745
|
+
if (this.mode === "poll") {
|
|
746
|
+
const polledAt = this.polledAt.get(t.source) ?? null;
|
|
747
|
+
// a copy the passes do not keep current now, and why: not copied (waiting, being copied, its copy failed:
|
|
748
|
+
// `needsCopy` holds for every phase but copied), to be copied again (its columns changed, a numeric outgrew it, a
|
|
749
|
+
// retired layout), or caught up first (`pass`'s `polled`, and a table not yet caught up on the writer's turn)
|
|
750
|
+
const pending = this.needsCopy(t) || this.behind.has(t.source) ? this.notStreamedWhy(t.source) : null;
|
|
751
|
+
return { by: "poll", polledAt, ...(overPollLimit(t) ? { unsynced: OVER_POLL_LIMIT } : {}), ...(pending !== null ? { pending } : {}) };
|
|
752
|
+
}
|
|
753
|
+
// copied again whole every pass, whatever its watermark: only a table not copied now (waiting, being copied, its copy
|
|
754
|
+
// failed) is not kept current
|
|
755
|
+
if (this.mode === "recopy")
|
|
756
|
+
return { by: "recopy", copiedAt: st?.copiedAt ?? null, ...(st?.phase !== "copied" ? { pending: this.notStreamedWhy(t.source) } : {}) };
|
|
757
|
+
const s = this.stream;
|
|
758
|
+
const part = this.streamTable(t, now, s);
|
|
759
|
+
return { by: "replication", slot: this.replication.slot, state: part.state, why: part.why, position: formatLsn(s?.position() ?? this.appliedLsn),
|
|
760
|
+
lastCommit: this.lastBatch === null ? null : new Date(this.lastBatch.committedAt).toISOString(), notRunning: this.streamDownWhy(s, st) };
|
|
761
|
+
}
|
|
762
|
+
/** why the stream is not running, in the reader's words (null: it runs): its last failure and when it starts again; the
|
|
763
|
+
* replication check a pass failed (`prepareReplication`, recorded on each copy's state); a slot not made yet; a sync
|
|
764
|
+
* stopping; else it starts at the next pass */
|
|
765
|
+
streamDownWhy(s, st) {
|
|
766
|
+
if (s !== null)
|
|
767
|
+
return null;
|
|
768
|
+
const firstLine = (e) => e.replace(/^replication: /, "").split("\n")[0];
|
|
769
|
+
if (this.streamFailure !== null)
|
|
770
|
+
return `${firstLine(this.streamFailure.error)} · it starts again after ${clock(this.streamFailure.retryAt)} UTC`;
|
|
771
|
+
if (st?.error?.startsWith("replication: "))
|
|
772
|
+
return firstLine(st.error);
|
|
773
|
+
if (!this.slotKnown)
|
|
774
|
+
return `slot ${this.replication.slot} is not made yet: it starts once it is`;
|
|
775
|
+
if (this.stopped)
|
|
776
|
+
return "Mason is stopping";
|
|
777
|
+
return "it has not started yet: it starts at the next pass";
|
|
778
|
+
}
|
|
779
|
+
/** an added table's part in the stream (`StreamTableStatus` but what it applied): streamed, being caught up, waiting
|
|
780
|
+
* for its copy, or left out of the publication, and why — what `streamStatus` and `keptOf` both say, each of the
|
|
781
|
+
* stream `s` it read once */
|
|
782
|
+
streamTable(t, now, s) {
|
|
783
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
784
|
+
if (this.unpublished.has(t.source))
|
|
785
|
+
return { state: "left-out", why: this.notStreamedWhy(t.source) };
|
|
786
|
+
const b = this.behind.get(t.source);
|
|
787
|
+
if (!this.needsCopy(t) && b !== undefined) {
|
|
788
|
+
const c = this.catchingUp?.source === t.source ? this.catchingUp : null;
|
|
789
|
+
const held = this.held.get(t.source)?.counts;
|
|
790
|
+
const at = this.retries.get(t.source)?.at ?? 0;
|
|
791
|
+
return { state: "catching-up", why: `caught up by one poll ${b.why}${at > now ? ` — its last catch-up failed, tried again after ${clock(at)} UTC` : ""}`,
|
|
792
|
+
catchUp: { phase: c?.phase ?? "queued", from: this.polledAt.get(t.source) ?? null, startedAt: c ? iso(c.startedAt) : null, held: held ? held.inserts + held.updates + held.deletes + held.truncates : 0,
|
|
793
|
+
retryAt: c === null && at > now ? iso(at) : null } };
|
|
794
|
+
}
|
|
795
|
+
if (this.joinedNow.has(t.source) || (s === null && !this.needsCopy(t)))
|
|
796
|
+
return { state: "streamed", why: null };
|
|
797
|
+
return { state: "waiting", why: this.notStreamedWhy(t.source) };
|
|
724
798
|
}
|
|
725
799
|
/** each table's last copy or poll, from the ledger (a copy writes its watermark there too) */
|
|
726
800
|
async readPolledAt() {
|
|
@@ -1237,14 +1311,18 @@ export class Sync {
|
|
|
1237
1311
|
return "its columns changed: it is copied again, then caught up by one poll";
|
|
1238
1312
|
if (st?.phase === "copied" && !this.snapshots.has(source))
|
|
1239
1313
|
return "a numeric value outgrew its copy's DECIMAL: it is copied again, wider, then caught up by one poll";
|
|
1314
|
+
if (st?.phase === "copied" && this.retired.has(source))
|
|
1315
|
+
return "its copy is in an older Mason's layout, key order with an index: it is copied again, then caught up by one poll";
|
|
1316
|
+
// what follows the copy: a catch-up poll, then the stream or the polls; copied again whole, nothing (`keptOf`'s recopy)
|
|
1317
|
+
const joins = this.mode === "recopy" ? "it is kept current once copied" : "it joins once copied and caught up by one poll";
|
|
1240
1318
|
if (st?.phase === "copying" || this.rebuildingNow?.source === source && this.rebuildingNow.kind === "copy")
|
|
1241
|
-
return
|
|
1319
|
+
return `it is being copied: ${joins}`;
|
|
1242
1320
|
if (st?.phase === "failed")
|
|
1243
|
-
return `its copy failed (${(st.error ?? "").split("\n")[0]}):
|
|
1321
|
+
return `its copy failed (${(st.error ?? "").split("\n")[0]}): ${joins}`;
|
|
1244
1322
|
const b = this.behind.get(source);
|
|
1245
1323
|
if (!this.needsCopy(t) && b !== undefined)
|
|
1246
1324
|
return `it is caught up by one poll ${b.why} first`;
|
|
1247
|
-
return
|
|
1325
|
+
return `it waits for its copy: ${joins}`;
|
|
1248
1326
|
}
|
|
1249
1327
|
/**
|
|
1250
1328
|
* WHERE THE STREAM IS (`StreamStatus`): its position, the database's, the lag, the last batch, and each added table's
|
|
@@ -1260,23 +1338,8 @@ export class Sync {
|
|
|
1260
1338
|
const db = await this.databasePosition();
|
|
1261
1339
|
const now = Date.now();
|
|
1262
1340
|
const iso = (ms) => new Date(ms).toISOString();
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
if (this.unpublished.has(t.source))
|
|
1266
|
-
return { table: t.source, state: "left-out", why: this.notStreamedWhy(t.source), applied };
|
|
1267
|
-
const b = this.behind.get(t.source);
|
|
1268
|
-
if (!this.needsCopy(t) && b !== undefined) {
|
|
1269
|
-
const c = this.catchingUp?.source === t.source ? this.catchingUp : null;
|
|
1270
|
-
const held = this.held.get(t.source)?.counts;
|
|
1271
|
-
const at = this.retries.get(t.source)?.at ?? 0;
|
|
1272
|
-
return { table: t.source, state: "catching-up", why: `caught up by one poll ${b.why}${at > now ? ` — its last catch-up failed, tried again after ${clock(at)} UTC` : ""}`,
|
|
1273
|
-
catchUp: { phase: c?.phase ?? "queued", from: this.polledAt.get(t.source) ?? null, startedAt: c ? iso(c.startedAt) : null, held: held ? held.inserts + held.updates + held.deletes + held.truncates : 0,
|
|
1274
|
-
retryAt: c === null && at > now ? iso(at) : null }, applied };
|
|
1275
|
-
}
|
|
1276
|
-
if (this.joinedNow.has(t.source) || (s === null && !this.needsCopy(t)))
|
|
1277
|
-
return { table: t.source, state: "streamed", why: null, applied };
|
|
1278
|
-
return { table: t.source, state: "waiting", why: this.notStreamedWhy(t.source), applied };
|
|
1279
|
-
});
|
|
1341
|
+
// each table's part, as `keptOf` says it for EXPLAIN (`streamTable`), with what it applied this run
|
|
1342
|
+
const tables = [...this.tables.values()].map((t) => ({ table: t.source, ...this.streamTable(t, now, s), applied: { ...(this.appliedCounts.get(t.source) ?? noCounts()) } }));
|
|
1280
1343
|
return {
|
|
1281
1344
|
slot: r.slot, publication: r.publication, running: s !== null,
|
|
1282
1345
|
failure: s === null && this.streamFailure !== null ? { error: this.streamFailure.error, retryAt: iso(this.streamFailure.retryAt) } : null,
|
|
@@ -2765,8 +2828,10 @@ export class Sync {
|
|
|
2765
2828
|
const types = await c.prepare(`SELECT * FROM ${ident(t.schema)}.${ident(t.table)} LIMIT 0`);
|
|
2766
2829
|
const duckTypes = new Map(Array.from({ length: types.columnCount }, (_, i) => [types.columnName(i), types.columnType(i)]));
|
|
2767
2830
|
types.destroySync();
|
|
2768
|
-
// the column's constraints and default
|
|
2769
|
-
//
|
|
2831
|
+
// the column's constraints and default — and below the table's keys and unique indexes — as the source had them
|
|
2832
|
+
// when the tables were last set (`setTables`: at mason start, and at mason add / remove; the run's 10-second
|
|
2833
|
+
// catalog read reaches the commands' facts, not this): what the copy recorded describes its types, which a
|
|
2834
|
+
// change of recopies
|
|
2770
2835
|
const now = new Map(t.columns.map((c) => [c.name, c]));
|
|
2771
2836
|
const columns = cols.filter((c) => duckTypes.has(c.name)).map((c) => pgColumn(c, duckTypes.get(c.name), now.get(c.name)));
|
|
2772
2837
|
const joins = t.foreignKeys.filter((f) => this.tables.has(f.target) && columns.some((c) => c.name === f.column))
|
|
@@ -242,6 +242,7 @@ export const VERIFIED = new Map([
|
|
|
242
242
|
["field:SUBLINK.subLinkType=0", "EXISTS (subquery)"],
|
|
243
243
|
["field:SUBLINK.subLinkType=2", "IN / op ANY (subquery)"],
|
|
244
244
|
["field:SUBLINK.subLinkType=4", "a scalar subquery"],
|
|
245
|
+
["field:SUBLINK.subLinkType=6", "ARRAY(subquery)"],
|
|
245
246
|
["field:WINDOWCLAUSE.frameOptions=1058", "a window frame: the default window frame"],
|
|
246
247
|
["field:WINDOWCLAUSE.frameOptions=1059", "a window frame: RANGE UNBOUNDED PRECEDING"],
|
|
247
248
|
["field:WINDOWCLAUSE.frameOptions=1061", "a window frame: ROWS UNBOUNDED PRECEDING"],
|
|
@@ -736,6 +737,11 @@ export const VERIFIED = new Map([
|
|
|
736
737
|
["operator:3212", "the operator ->(jsonb,integer)"],
|
|
737
738
|
["operator:3213", "the operator #>(jsonb,text[])"],
|
|
738
739
|
["operator:3240", "the operator =(jsonb,jsonb)"],
|
|
740
|
+
["operator:3241", "the operator <>(jsonb,jsonb)"],
|
|
741
|
+
["operator:3242", "the operator <(jsonb,jsonb)"],
|
|
742
|
+
["operator:3243", "the operator >(jsonb,jsonb)"],
|
|
743
|
+
["operator:3244", "the operator <=(jsonb,jsonb)"],
|
|
744
|
+
["operator:3245", "the operator >=(jsonb,jsonb)"],
|
|
739
745
|
["operator:3246", "the operator @>(jsonb,jsonb)"],
|
|
740
746
|
["operator:3247", "the operator ?(jsonb,text)"],
|
|
741
747
|
["operator:3248", "the operator ?|(jsonb,text[])"],
|
package/dist/src/lib/timezone.js
CHANGED
|
@@ -48,8 +48,9 @@ export function canonicalZone(z) {
|
|
|
48
48
|
const c = lowered.get(name.toLowerCase());
|
|
49
49
|
return c !== undefined && isZone(c) ? c : null;
|
|
50
50
|
}
|
|
51
|
-
/** UTC and its aliases
|
|
52
|
-
export const
|
|
51
|
+
/** UTC and its aliases (compared in any case): a zone that changes nothing. One list, read by isUtc and by the SQL that tests the session zone at execution. */
|
|
52
|
+
export const UTC_NAMES = ["UTC", "Etc/UTC", "Etc/GMT", "GMT", "Etc/Universal", "Universal", "Zulu", "Etc/Zulu"];
|
|
53
|
+
export const isUtc = (z) => UTC_NAMES.some((n) => n.toLowerCase() === z.toLowerCase());
|
|
53
54
|
/**
|
|
54
55
|
* THE ZONE OF A POSTGRES `AT TIME ZONE '<name>'`, as ClickHouse spells it, where Postgres and ClickHouse read the name
|
|
55
56
|
* alike (Cube's zoned bucket): an Area/Location name (`Europe/London`, `America/Sao_Paulo`, in any case) or
|
package/dist/src/web/app.css
CHANGED
|
@@ -63,3 +63,14 @@ input { font: inherit; padding: 4px 8px; border-radius: 6px; border: 1px solid v
|
|
|
63
63
|
.added .headline { font-weight: 600; margin: 0 0 8px; }
|
|
64
64
|
.added .tip { color: var(--dim); }
|
|
65
65
|
#poll .failure { border: 1px solid var(--bad); border-left-width: 3px; border-radius: 4px; margin-top: 16px; }
|
|
66
|
+
table.catalog td.num { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
|
|
67
|
+
.c-added .state { color: var(--ok); }
|
|
68
|
+
.c-cannot .state { color: var(--warn); font-size: 13px; }
|
|
69
|
+
tr.under > td { border-top: 0; padding-top: 0; }
|
|
70
|
+
form.pick { display: block; margin: 4px 0 8px; }
|
|
71
|
+
form.pick fieldset { border: 1px solid var(--line); border-radius: 6px; margin: 0 0 8px; padding: 6px 10px; }
|
|
72
|
+
form.pick legend { color: var(--dim); font-size: 13px; padding: 0 4px; }
|
|
73
|
+
label.tick { display: inline-flex; align-items: baseline; gap: 4px; margin: 2px 14px 2px 0; }
|
|
74
|
+
.done { border: 1px solid var(--ok); border-radius: 8px; padding: 8px 12px; margin: 4px 0 8px; }
|
|
75
|
+
.done .headline { font-weight: 600; margin: 0 0 4px; overflow-wrap: anywhere; }
|
|
76
|
+
.done .note, .done .tip { color: var(--dim); margin: 2px 0 6px; }
|