@intentius/chant-lexicon-sql 0.118.1 → 0.120.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/dist/clickhouse/apply/apply.d.ts +1 -1
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +8 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +1 -1
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/live-export.d.ts +19 -0
- package/dist/clickhouse/import/live-export.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +8 -0
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +15 -0
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +19 -2
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +3 -0
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
- package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/engines.d.ts +44 -0
- package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/verify.d.ts +25 -1
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts +5 -0
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/config.d.ts +32 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/core/foreign-tables.d.ts +13 -0
- package/dist/core/foreign-tables.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +7 -3
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/postgres.d.ts +3 -0
- package/dist/files/postgres.d.ts.map +1 -1
- package/dist/import-parser.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
- package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
- package/dist/op/activities/postgres-emulator.d.ts +8 -1
- package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
- package/dist/postgres/access/changes.d.ts +23 -0
- package/dist/postgres/access/changes.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +8 -1
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +3 -3
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +31 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/sql-files.d.ts +2 -1
- package/dist/sql-files.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +8 -2
- package/src/clickhouse/apply/statements.ts +32 -5
- package/src/clickhouse/import/generator.ts +8 -3
- package/src/clickhouse/import/import.test.ts +64 -0
- package/src/clickhouse/import/live-export.ts +70 -2
- package/src/clickhouse/live/access.e2e.test.ts +71 -18
- package/src/clickhouse/live/bind.ts +10 -0
- package/src/clickhouse/live/catalog.ts +25 -2
- package/src/clickhouse/live/describe-resources.ts +5 -2
- package/src/clickhouse/live/live.test.ts +8 -0
- package/src/clickhouse/plan/commands.ts +6 -3
- package/src/clickhouse/plan/deep-grants.test.ts +67 -0
- package/src/clickhouse/plan/deep.ts +70 -6
- package/src/clickhouse/plan/diff.ts +4 -1
- package/src/clickhouse/plan/normalize.ts +5 -2
- package/src/clickhouse/plan/plan.test.ts +9 -1
- package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
- package/src/clickhouse/rebuild/engines.ts +163 -0
- package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
- package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
- package/src/clickhouse/rebuild/verify.ts +153 -24
- package/src/clickhouse/testing/fake-server.ts +3 -2
- package/src/codegen/classifier-page.ts +1 -1
- package/src/config.ts +16 -4
- package/src/core/foreign-tables.ts +28 -0
- package/src/files/clickhouse.ts +71 -5
- package/src/files/postgres.ts +2 -1
- package/src/import-parser.ts +2 -0
- package/src/migration-statements.test.ts +33 -0
- package/src/migration-statements.ts +2 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
- package/src/op/activities/clickhouse-emulator.ts +22 -1
- package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
- package/src/op/activities/postgres-apply.test.ts +32 -0
- package/src/op/activities/postgres-emulator.test.ts +20 -0
- package/src/op/activities/postgres-emulator.ts +41 -1
- package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
- package/src/op/apply-gate-access.e2e.test.ts +207 -0
- package/src/postgres/access/access.test.ts +26 -1
- package/src/postgres/access/changes.ts +39 -0
- package/src/postgres/apply/apply.ts +10 -7
- package/src/postgres/entities.ts +20 -1
- package/src/postgres/live/bind.ts +16 -4
- package/src/postgres/live/catalog.ts +3 -15
- package/src/postgres/live/client.ts +85 -0
- package/src/postgres/live/live.test.ts +54 -0
- package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
- package/src/postgres/plan/commands.ts +2 -14
- package/src/postgres/plan/deep.ts +44 -2
- package/src/serializer.test.ts +4 -0
- package/src/skills/chant-sql-postgres-plan.md +1 -1
- package/src/skills/chant-sql-rebuild.md +2 -0
- package/src/skills/chant-sql.md +1 -1
- package/src/sql-files.test.ts +78 -1
- package/src/sql-files.ts +2 -1
|
@@ -98,6 +98,8 @@ export interface CanonicalObject {
|
|
|
98
98
|
access?: Record<string, string>;
|
|
99
99
|
/** `-- previously: <name>` before the statement: the object's previous name. */
|
|
100
100
|
previously?: string;
|
|
101
|
+
/** The tool that keeps a live table (a migration runner's history table): a plan never drops it. */
|
|
102
|
+
foreign?: string;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
const KEYWORDS = new Set(
|
|
@@ -560,6 +562,7 @@ export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string
|
|
|
560
562
|
}
|
|
561
563
|
|
|
562
564
|
/** A live object's canonical form: a grantee's grants from its `SHOW GRANTS` lines, anything else from its `SHOW CREATE`. */
|
|
563
|
-
export function liveCanonical(o: { type: string; name: string; statement: string }, defaultDatabase = "default"): CanonicalObject {
|
|
564
|
-
|
|
565
|
+
export function liveCanonical(o: { type: string; name: string; statement: string; foreign?: string }, defaultDatabase = "default"): CanonicalObject {
|
|
566
|
+
const canonical = o.type === "ClickHouse::Grant" ? grantsObject(o.statement, o.name, defaultDatabase) : canonicalObject(o.statement, defaultDatabase);
|
|
567
|
+
return o.foreign ? { ...canonical, foreign: o.foreign } : canonical;
|
|
565
568
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { canonicalCodec, canonicalExpression, canonicalObject, canonicalTtl, canonicalType } from "./normalize";
|
|
2
|
+
import { canonicalCodec, canonicalExpression, canonicalObject, canonicalTtl, canonicalType, liveCanonical } from "./normalize";
|
|
3
3
|
import { diffSchemas, type SchemaObject } from "./diff";
|
|
4
4
|
import { renderDiff } from "./report";
|
|
5
5
|
import { CLASSIFIER_RULES } from "./rules";
|
|
@@ -184,6 +184,14 @@ describe("classifying a change", () => {
|
|
|
184
184
|
expect(classify("CREATE VIEW events AS SELECT 1")).toEqual([["kind", "SQLCH224", "rebuild"]]);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
test("a migration runner's history table is named in a hint, never dropped (#3676)", () => {
|
|
188
|
+
const statement = "CREATE TABLE legacy.schema_migrations (version Int64, dirty UInt8, sequence UInt64) ENGINE = MergeTree ORDER BY sequence";
|
|
189
|
+
const live = [{ key: "legacy.schema_migrations", canonical: liveCanonical({ type: "ClickHouse::Table", name: "schema_migrations", statement, foreign: "a migration runner (Rails, golang-migrate, dbmate)" }) }];
|
|
190
|
+
const d = diffSchemas(live, []);
|
|
191
|
+
expect(d.changes).toEqual([]);
|
|
192
|
+
expect(d.hints).toEqual(["legacy.schema_migrations is kept by a migration runner (Rails, golang-migrate, dbmate); it is not chant's to drop and is left alone"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
187
195
|
test("against a server, previously before the statement matches the old name", () => {
|
|
188
196
|
const live = [{ key: "default.events", canonical: canonicalObject(BASE) }];
|
|
189
197
|
const declared = [{ key: "default.raw_events", canonical: canonicalObject(`-- previously: events\n${BASE.replace("TABLE events", "TABLE raw_events")}`) }];
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
import type { CanonicalObject } from "./normalize";
|
|
20
20
|
import type { SchemaDiff } from "./diff";
|
|
21
21
|
import { migrationOpName, renderMigrationOps, type MigrationOpSuggestion } from "../../core/handoff";
|
|
22
|
+
import { collapsesRows } from "../rebuild/engines";
|
|
22
23
|
|
|
23
24
|
export interface RebuildOpSuggestion extends MigrationOpSuggestion {
|
|
24
25
|
/** `database.name`, the Op's `table`. */
|
|
25
26
|
table: string;
|
|
26
27
|
dualWrite: { mode: "materialized-view"; cutoverColumn: string } | { mode: "app" };
|
|
28
|
+
/** What to know before running it: set for an engine that collapses rows by sorting key (#3674, #3727). */
|
|
29
|
+
note?: string;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const TIME_TYPE = /^(?:Nullable\s*\(\s*)?(?:Date|Date32|DateTime|DateTime64)\b/;
|
|
@@ -42,11 +45,17 @@ export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<str
|
|
|
42
45
|
const dualWrite: RebuildOpSuggestion["dualWrite"] = time ? { mode: "materialized-view", cutoverColumn: time.name } : { mode: "app" };
|
|
43
46
|
const name = migrationOpName("rebuild", table);
|
|
44
47
|
const dw = dualWrite.mode === "app" ? `{ mode: "app" }` : `{ mode: "materialized-view", cutoverColumn: ${JSON.stringify(dualWrite.cutoverColumn)} }`;
|
|
48
|
+
const note = collapsesRows(c.engineName)
|
|
49
|
+
? `${table} is a ${c.engineName}: when the old table's engine collapses rows too, the Verify phase reads both tables under FINAL, so rows the old table has not merged yet compare as merged. ` +
|
|
50
|
+
`A new sorting key that collapses rows differently from the old one still fails it; OPTIMIZE TABLE ${table} FINAL before the run avoids that. ` +
|
|
51
|
+
`From an engine that keeps every row (a plain MergeTree), it groups both tables by the new sorting key instead and compares what a ${c.engineName} keeps per key (its sums, its highest version, the sum of its signs).`
|
|
52
|
+
: undefined;
|
|
45
53
|
out.push({
|
|
46
54
|
table,
|
|
47
55
|
name,
|
|
48
56
|
env,
|
|
49
57
|
dualWrite,
|
|
58
|
+
...(note ? { note } : {}),
|
|
50
59
|
declaration: `export const { op } = ClickHouseRebuildOp({ name: ${JSON.stringify(name)}, env: ${JSON.stringify(env)}, table: ${JSON.stringify(table)}, dualWrite: ${dw} });`,
|
|
51
60
|
});
|
|
52
61
|
}
|
|
@@ -55,5 +64,6 @@ export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<str
|
|
|
55
64
|
|
|
56
65
|
/** The suggestions as text, for the report. */
|
|
57
66
|
export function renderRebuildOps(ops: readonly RebuildOpSuggestion[]): string[] {
|
|
58
|
-
|
|
67
|
+
const notes = ops.flatMap((o) => (o.note ? [` Note: ${o.note}`] : []));
|
|
68
|
+
return [...renderMigrationOps(ops, { what: "the rebuild migration Op", exportName: "ClickHouseRebuildOp", importPath: "@intentius/chant-lexicon-sql/clickhouse" }), ...notes];
|
|
59
69
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which table engines the rebuild reads as merged (#3674): the verification
|
|
3
|
+
* (`./verify.ts`) and the plan's hand-off note (`../plan/rebuild-handoff.ts`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The MergeTree engines that collapse rows sharing a sorting key when parts
|
|
8
|
+
* merge, as `system.tables.engine` names them, replicated or shared too. A
|
|
9
|
+
* count of such a table depends on which parts have merged; under `FINAL` it
|
|
10
|
+
* does not.
|
|
11
|
+
*/
|
|
12
|
+
const COLLAPSING_ENGINE = /^(?:Replicated|Shared)?(?:Summing|Replacing|Aggregating|Collapsing|VersionedCollapsing|Coalescing|Graphite)MergeTree$/;
|
|
13
|
+
|
|
14
|
+
/** Whether an engine collapses rows that share a sorting key (#3674). */
|
|
15
|
+
export const collapsesRows = (engine: string | undefined): boolean => COLLAPSING_ENGINE.test(engine ?? "");
|
|
16
|
+
|
|
17
|
+
/** A column of the new table, as `system.columns` reports it. */
|
|
18
|
+
export interface EngineColumn {
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
/** Part of the sorting key: the engine never merges its values. */
|
|
22
|
+
inSortingKey: boolean;
|
|
23
|
+
/** Part of the partition key: rows that merge share its value, so a SummingMergeTree does not sum it. */
|
|
24
|
+
inPartitionKey?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How a collapsing engine merges the rows that share a sorting key, as what a
|
|
29
|
+
* merge leaves the same (#3727): for each key, the aggregates it preserves and
|
|
30
|
+
* whether it keeps the key at all. Both tables are grouped by the new table's
|
|
31
|
+
* sorting key and compared on these, so it does not matter which parts have
|
|
32
|
+
* merged, and a table that never collapses (a plain MergeTree) compares with
|
|
33
|
+
* one that does.
|
|
34
|
+
*/
|
|
35
|
+
export interface CollapseSemantics {
|
|
36
|
+
/** The engine's name without `Replicated` or `Shared`. */
|
|
37
|
+
engine: string;
|
|
38
|
+
/** Per key: what merging leaves unchanged. SQL over the new table's column names. */
|
|
39
|
+
aggregates: string[];
|
|
40
|
+
/** Per key: whether the engine keeps a row for it. Absent: every key keeps one. */
|
|
41
|
+
having?: string;
|
|
42
|
+
/** One line for the summary and the error: what is compared per key. */
|
|
43
|
+
describe: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Split on the commas outside parentheses and quotes. */
|
|
47
|
+
export function splitTopLevel(text: string): string[] {
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
let depth = 0;
|
|
50
|
+
let quote: string | undefined;
|
|
51
|
+
let start = 0;
|
|
52
|
+
for (let i = 0; i < text.length; i++) {
|
|
53
|
+
const c = text[i]!;
|
|
54
|
+
if (quote) {
|
|
55
|
+
if (c === "\\") i++;
|
|
56
|
+
else if (c === quote) quote = undefined;
|
|
57
|
+
} else if (c === "'" || c === '"' || c === "`") quote = c;
|
|
58
|
+
else if (c === "(" || c === "[") depth++;
|
|
59
|
+
else if (c === ")" || c === "]") depth--;
|
|
60
|
+
else if (c === "," && depth === 0) {
|
|
61
|
+
out.push(text.slice(start, i).trim());
|
|
62
|
+
start = i + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const last = text.slice(start).trim();
|
|
66
|
+
if (last || out.length > 0) out.push(last);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The engine's own arguments in `system.tables.engine_full`, without a `Replicated` engine's Keeper path and replica name. */
|
|
71
|
+
export function engineArguments(engineFull: string): string[] {
|
|
72
|
+
const m = /^\s*(\w+)\s*\(/.exec(engineFull);
|
|
73
|
+
if (!m) return [];
|
|
74
|
+
let depth = 0;
|
|
75
|
+
let quote: string | undefined;
|
|
76
|
+
const from = m[0].length;
|
|
77
|
+
let end = -1;
|
|
78
|
+
for (let i = from - 1; i < engineFull.length && end < 0; i++) {
|
|
79
|
+
const c = engineFull[i]!;
|
|
80
|
+
if (quote) {
|
|
81
|
+
if (c === "\\") i++;
|
|
82
|
+
else if (c === quote) quote = undefined;
|
|
83
|
+
} else if (c === "'") quote = c;
|
|
84
|
+
else if (c === "(") depth++;
|
|
85
|
+
else if (c === ")" && --depth === 0) end = i;
|
|
86
|
+
}
|
|
87
|
+
if (end < 0) return [];
|
|
88
|
+
const args = splitTopLevel(engineFull.slice(from, end)).filter((a) => a !== "");
|
|
89
|
+
if (/^(?:Replicated|Shared)/.test(m[1]!)) while (args.length > 0 && args[0]!.startsWith("'")) args.shift();
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const NUMERIC = /^(?:U?Int(?:8|16|32|64|128|256)|Float(?:32|64)|BFloat16|Decimal(?:32|64|128|256)?\s*\(.*\))$/;
|
|
94
|
+
const FLOAT = /^(?:Float(?:32|64)|BFloat16)$/;
|
|
95
|
+
/** The SimpleAggregateFunction functions whose result a merge keeps exactly, whatever the order of the rows. */
|
|
96
|
+
const SIMPLE_ORDER_FREE = new Set(["sum", "sumWithOverflow", "min", "max", "groupBitAnd", "groupBitOr", "groupBitXor"]);
|
|
97
|
+
|
|
98
|
+
const quoted = (name: string) => `\`${name.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
|
|
99
|
+
const typeLiteral = (type: string) => `'${type.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
100
|
+
/** A sum in the column's own type, as the engine stores it; a float to 32 bits, since the order rows are added in moves its last bits. */
|
|
101
|
+
const summed = (c: EngineColumn) => (FLOAT.test(c.type) ? `toFloat32(sum(${quoted(c.name)}))` : `CAST(sum(${quoted(c.name)}), ${typeLiteral(c.type)})`);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a merge of `engine` keeps per sorting key, or undefined for an engine
|
|
105
|
+
* that does not collapse rows, or whose result depends on the order rows
|
|
106
|
+
* merge in (`GraphiteMergeTree` thins its rows by age).
|
|
107
|
+
*/
|
|
108
|
+
export function collapseSemantics(engine: string, engineFull: string, columns: readonly EngineColumn[]): CollapseSemantics | undefined {
|
|
109
|
+
if (!collapsesRows(engine)) return undefined;
|
|
110
|
+
const base = engine.replace(/^(?:Replicated|Shared)/, "").replace(/MergeTree$/, "");
|
|
111
|
+
const args = engineArguments(engineFull);
|
|
112
|
+
const values = columns.filter((c) => !c.inSortingKey);
|
|
113
|
+
const named = (n: string | undefined) => (n ? columns.find((c) => c.name === n.replace(/^`|`$/g, "")) : undefined);
|
|
114
|
+
switch (base) {
|
|
115
|
+
case "Summing": {
|
|
116
|
+
// The columns listed, else every numeric column outside the sorting and partition keys; a key whose sums are all zero is removed.
|
|
117
|
+
const listed = args[0] ? splitTopLevel(args[0].replace(/^\((.*)\)$/s, "$1")).map(named) : undefined;
|
|
118
|
+
const sums = (listed ? listed.filter((c): c is EngineColumn => c !== undefined) : values).filter((c) => !c.inSortingKey && !c.inPartitionKey && NUMERIC.test(c.type));
|
|
119
|
+
const aggregates = sums.map(summed);
|
|
120
|
+
return {
|
|
121
|
+
engine: `${base}MergeTree`,
|
|
122
|
+
aggregates,
|
|
123
|
+
...(aggregates.length > 0 ? { having: `NOT (${aggregates.map((a) => `${a} = 0`).join(" AND ")})` } : {}),
|
|
124
|
+
describe: sums.length > 0 ? `the sums of ${sums.map((c) => c.name).join(", ")}` : "the keys",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
case "Replacing": {
|
|
128
|
+
// The row with the highest version is kept; without a version, the last one written, so only the key is compared.
|
|
129
|
+
const ver = named(args[0]);
|
|
130
|
+
return { engine: `${base}MergeTree`, aggregates: ver ? [`max(${quoted(ver.name)})`] : [], describe: ver ? `the highest ${ver.name}` : "the keys" };
|
|
131
|
+
}
|
|
132
|
+
case "Collapsing":
|
|
133
|
+
case "VersionedCollapsing": {
|
|
134
|
+
// A pair of rows with opposite signs cancels: the sum of the signs is what a merge keeps, and a key whose signs cancel out is removed.
|
|
135
|
+
const sign = named(args[0]);
|
|
136
|
+
if (!sign) return undefined;
|
|
137
|
+
const s = `sum(${quoted(sign.name)})`;
|
|
138
|
+
return { engine: `${base}MergeTree`, aggregates: [s], having: `${s} != 0`, describe: `the sum of ${sign.name}` };
|
|
139
|
+
}
|
|
140
|
+
case "Aggregating": {
|
|
141
|
+
const simple = values.flatMap((c) => {
|
|
142
|
+
const m = /^SimpleAggregateFunction\(\s*(\w+)\s*,/.exec(c.type);
|
|
143
|
+
return m && SIMPLE_ORDER_FREE.has(m[1]!) ? [{ c, fn: m[1]! }] : [];
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
engine: `${base}MergeTree`,
|
|
147
|
+
aggregates: simple.map(({ c, fn }) => `${fn}(${quoted(c.name)})`),
|
|
148
|
+
describe: simple.length > 0 ? `${simple.map(({ c, fn }) => `${fn}(${c.name})`).join(", ")}` : "the keys",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
case "Coalescing": {
|
|
152
|
+
// The last value that is not NULL is kept: whether a column has one is what a merge keeps.
|
|
153
|
+
const nullable = values.filter((c) => /^Nullable\(/.test(c.type));
|
|
154
|
+
return {
|
|
155
|
+
engine: `${base}MergeTree`,
|
|
156
|
+
aggregates: nullable.map((c) => `count(${quoted(c.name)}) > 0`),
|
|
157
|
+
describe: nullable.length > 0 ? `which of ${nullable.map((c) => c.name).join(", ")} hold a value` : "the keys",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rebuild of a SummingMergeTree with unmerged parts (#3674).
|
|
3
|
+
*
|
|
4
|
+
* Three rows for one key, in three parts the server is kept from merging.
|
|
5
|
+
* The copy inserts them in one block, which the new table collapses into one
|
|
6
|
+
* row as it writes it, so the raw counts differ (3 and 1) although the data
|
|
7
|
+
* is the same. The Verify phase reads both tables under FINAL, finds them
|
|
8
|
+
* equal, and the run reaches the swap gate.
|
|
9
|
+
*
|
|
10
|
+
* Runs against `CHANT_SQL_E2E_CLICKHOUSE_URL` when it is set (a shared
|
|
11
|
+
* server: everything is in the database `chant_e2e_3674`, dropped
|
|
12
|
+
* afterwards, with this test's receipts), otherwise a scratch server, which
|
|
13
|
+
* needs Docker and skips cleanly without it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { memoryGateLedgerPort, runOpLocally, loadProfiles, type ActivityFn, type OpConfig } from "@intentius/chant/op";
|
|
21
|
+
import { dockerAvailable, startScratchServer, type ScratchServer } from "../container";
|
|
22
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
23
|
+
import { clickhouseImage } from "../../spec/pin";
|
|
24
|
+
import { clickhouseApply } from "../../op/activities/clickhouse-apply";
|
|
25
|
+
import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
|
|
26
|
+
import { ClickHouseRebuildOp } from "./op";
|
|
27
|
+
import { RECEIPTS_DATABASE, RECEIPTS_TABLE } from "./receipts";
|
|
28
|
+
|
|
29
|
+
const given = process.env.CHANT_SQL_E2E_CLICKHOUSE_URL;
|
|
30
|
+
const enabled = given ? true : await dockerAvailable();
|
|
31
|
+
const DB = "chant_e2e_3674";
|
|
32
|
+
const MARKER = { stack: "chant_e2e_3674", env: "test" };
|
|
33
|
+
let server: ScratchServer | undefined;
|
|
34
|
+
let endpoint: ClickHouseEndpoint;
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-rebuild-collapsing-"));
|
|
36
|
+
|
|
37
|
+
const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(endpoint, sql);
|
|
38
|
+
const count = async (from: string) => Number((await q<{ n: string }>(`SELECT count() AS n FROM ${from}`))[0]!.n);
|
|
39
|
+
|
|
40
|
+
async function cleanup(): Promise<void> {
|
|
41
|
+
await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
|
|
42
|
+
const receipts = await q<{ n: string }>(`SELECT count() AS n FROM system.tables WHERE database = '${RECEIPTS_DATABASE}' AND name = '${RECEIPTS_TABLE}'`);
|
|
43
|
+
if (Number(receipts[0]!.n) > 0) {
|
|
44
|
+
await clickhouseQuery(endpoint, `ALTER TABLE ${RECEIPTS_DATABASE}.${RECEIPTS_TABLE} DELETE WHERE position(address, '${MARKER.stack}') > 0`, { settings: { mutations_sync: "2" } });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
if (!enabled) return;
|
|
50
|
+
if (given) endpoint = { url: given, user: "default", password: process.env.CHANT_SQL_E2E_CLICKHOUSE_PASSWORD ?? "" };
|
|
51
|
+
else {
|
|
52
|
+
server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-rebuild-collapsing" });
|
|
53
|
+
endpoint = server.endpoint;
|
|
54
|
+
}
|
|
55
|
+
await cleanup();
|
|
56
|
+
}, 600_000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
if (enabled) await cleanup().catch(() => undefined);
|
|
60
|
+
await server?.stop();
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const deps = () => ({ config: { ownership: MARKER }, env: { CLICKHOUSE_URL: endpoint.url, CLICKHOUSE_USER: endpoint.user, CLICKHOUSE_PASSWORD: endpoint.password }, log: () => undefined });
|
|
65
|
+
|
|
66
|
+
function writeBuild(file: string, orderBy: string): string {
|
|
67
|
+
const objects = [
|
|
68
|
+
{ export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB} ENGINE = Atomic`, dependsOn: [] },
|
|
69
|
+
{
|
|
70
|
+
export: "daily",
|
|
71
|
+
type: "ClickHouse::Table",
|
|
72
|
+
dependsOn: ["db"],
|
|
73
|
+
ddl: `CREATE TABLE ${DB}.daily (day Date, total Decimal(18, 2), n UInt64 DEFAULT 0) ENGINE = SummingMergeTree ORDER BY ${orderBy}`,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
writeFileSync(join(dir, file), JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
|
|
77
|
+
return join(dir, file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe.skipIf(!enabled)("a SummingMergeTree with unmerged parts, rebuilt with a new sorting key", () => {
|
|
81
|
+
test("the Verify phase compares the tables under FINAL and the run reaches the swap gate", async () => {
|
|
82
|
+
await clickhouseApply({ buildPath: writeBuild("daily-v1.json", "day"), environment: "e2e" }, undefined, deps());
|
|
83
|
+
await q(`SYSTEM STOP MERGES ${DB}.daily`);
|
|
84
|
+
for (const total of [30, 30, 40]) await q(`INSERT INTO ${DB}.daily VALUES ('2026-10-10', ${total}, 0)`);
|
|
85
|
+
expect(await count(`${DB}.daily`)).toBe(3);
|
|
86
|
+
writeBuild("daily-v2.json", "(day, n)");
|
|
87
|
+
|
|
88
|
+
const { op } = ClickHouseRebuildOp({
|
|
89
|
+
name: "rebuild-daily",
|
|
90
|
+
env: "e2e",
|
|
91
|
+
table: `${DB}.daily`,
|
|
92
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "day", cutoverDelay: "1s" },
|
|
93
|
+
build: false,
|
|
94
|
+
path: dir,
|
|
95
|
+
output: "daily-v2.json",
|
|
96
|
+
stack: MARKER.stack,
|
|
97
|
+
ownershipEnv: MARKER.env,
|
|
98
|
+
});
|
|
99
|
+
const activities = new Map<string, ActivityFn>();
|
|
100
|
+
for (const [name, fn] of Object.entries(rebuildActivities)) {
|
|
101
|
+
if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
|
|
102
|
+
activities.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) =>
|
|
103
|
+
(fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps())) as ActivityFn);
|
|
104
|
+
}
|
|
105
|
+
const r = await runOpLocally((op as unknown as { props: OpConfig }).props, activities, await loadProfiles(), undefined, {
|
|
106
|
+
gates: memoryGateLedgerPort(),
|
|
107
|
+
now: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const verify = r.records.find((x) => x.fn === "clickhouseRebuildVerify");
|
|
111
|
+
expect(verify?.error).toBeUndefined();
|
|
112
|
+
expect(r.status).toBe("gated");
|
|
113
|
+
expect(r.gate).toMatchObject({ gate: "approve-rebuild-daily" });
|
|
114
|
+
const verification = r.records.flatMap((x) => x.outcomes ?? []).find((o) => o.name === "Verification")?.value;
|
|
115
|
+
expect(String(verification)).toMatch(/1 row\(s\).*read under FINAL \(SummingMergeTree\)/);
|
|
116
|
+
// The old table still holds its three unmerged rows; the new one holds them collapsed.
|
|
117
|
+
expect(await count(`${DB}.daily`)).toBe(3);
|
|
118
|
+
expect(await q(`SELECT toString(day) AS day, toString(total) AS total FROM ${DB}.daily__chant_new`)).toEqual([{ day: "2026-10-10", total: "100" }]);
|
|
119
|
+
}, 300_000);
|
|
120
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plain MergeTree rebuilt into a SummingMergeTree (#3727).
|
|
3
|
+
*
|
|
4
|
+
* The old table keeps every row it was given; the new one keeps one row per
|
|
5
|
+
* sorting key, its sums, and drops a key whose sums are all zero. So the row
|
|
6
|
+
* counts differ by design. The Verify phase groups both tables by the new
|
|
7
|
+
* sorting key and compares each key's sums, finds them equal, and the run
|
|
8
|
+
* swaps once approved. A key the new table lost, or a sum it changed, still
|
|
9
|
+
* fails the verification.
|
|
10
|
+
*
|
|
11
|
+
* Runs against `CHANT_SQL_E2E_CLICKHOUSE_URL` when it is set (a shared
|
|
12
|
+
* server: everything is in the databases `chant_e2e_3727` and
|
|
13
|
+
* `chant_e2e_3727_loss`, dropped afterwards, with this test's receipts),
|
|
14
|
+
* otherwise a scratch server, which needs Docker and skips cleanly without it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { memoryGateLedgerPort, runOpLocally, loadProfiles, type ActivityFn, type GateLedgerPort, type OpConfig } from "@intentius/chant/op";
|
|
22
|
+
import { dockerAvailable, startScratchServer, type ScratchServer } from "../container";
|
|
23
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
24
|
+
import { clickhouseImage } from "../../spec/pin";
|
|
25
|
+
import { clickhouseApply } from "../../op/activities/clickhouse-apply";
|
|
26
|
+
import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
|
|
27
|
+
import { ClickHouseRebuildOp } from "./op";
|
|
28
|
+
import { RECEIPTS_DATABASE, RECEIPTS_TABLE } from "./receipts";
|
|
29
|
+
|
|
30
|
+
const given = process.env.CHANT_SQL_E2E_CLICKHOUSE_URL;
|
|
31
|
+
const enabled = given ? true : await dockerAvailable();
|
|
32
|
+
const DATABASES = ["chant_e2e_3727", "chant_e2e_3727_loss"];
|
|
33
|
+
const MARKER = { stack: "chant_e2e_3727", env: "test" };
|
|
34
|
+
let server: ScratchServer | undefined;
|
|
35
|
+
let endpoint: ClickHouseEndpoint;
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-rebuild-into-collapsing-"));
|
|
37
|
+
|
|
38
|
+
const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(endpoint, sql);
|
|
39
|
+
const count = async (from: string) => Number((await q<{ n: string }>(`SELECT count() AS n FROM ${from}`))[0]!.n);
|
|
40
|
+
const mutate = (sql: string) => clickhouseQuery(endpoint, sql, { settings: { mutations_sync: "2" } });
|
|
41
|
+
|
|
42
|
+
async function cleanup(): Promise<void> {
|
|
43
|
+
for (const db of DATABASES) await q(`DROP DATABASE IF EXISTS ${db} SYNC`);
|
|
44
|
+
const receipts = await q<{ n: string }>(`SELECT count() AS n FROM system.tables WHERE database = '${RECEIPTS_DATABASE}' AND name = '${RECEIPTS_TABLE}'`);
|
|
45
|
+
if (Number(receipts[0]!.n) > 0) await mutate(`ALTER TABLE ${RECEIPTS_DATABASE}.${RECEIPTS_TABLE} DELETE WHERE position(address, '${MARKER.stack}') > 0`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
if (!enabled) return;
|
|
50
|
+
if (given) endpoint = { url: given, user: "default", password: process.env.CHANT_SQL_E2E_CLICKHOUSE_PASSWORD ?? "" };
|
|
51
|
+
else {
|
|
52
|
+
server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-rebuild-into-collapsing" });
|
|
53
|
+
endpoint = server.endpoint;
|
|
54
|
+
}
|
|
55
|
+
await cleanup();
|
|
56
|
+
}, 600_000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
if (enabled) await cleanup().catch(() => undefined);
|
|
60
|
+
await server?.stop();
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const deps = () => ({ config: { ownership: MARKER }, env: { CLICKHOUSE_URL: endpoint.url, CLICKHOUSE_USER: endpoint.user, CLICKHOUSE_PASSWORD: endpoint.password }, log: () => undefined });
|
|
65
|
+
|
|
66
|
+
function writeBuild(db: string, file: string, engine: string): string {
|
|
67
|
+
const objects = [
|
|
68
|
+
{ export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${db} ENGINE = Atomic`, dependsOn: [] },
|
|
69
|
+
{
|
|
70
|
+
export: "daily",
|
|
71
|
+
type: "ClickHouse::Table",
|
|
72
|
+
dependsOn: ["db"],
|
|
73
|
+
ddl: `CREATE TABLE ${db}.daily (day Date, shop UInt32, total Decimal(18, 2), n UInt64) ENGINE = ${engine} PARTITION BY toYYYYMM(day) ORDER BY (day, shop)`,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
writeFileSync(join(dir, file), JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
|
|
77
|
+
return join(dir, file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Eleven rows: shop 1 three times and shop 2 twice on one day, shop 3 whose rows sum to zero, and six single rows across two months. */
|
|
81
|
+
async function seed(db: string): Promise<void> {
|
|
82
|
+
await q(
|
|
83
|
+
`INSERT INTO ${db}.daily VALUES ` +
|
|
84
|
+
`('2026-09-01', 1, 10, 1), ('2026-09-01', 1, 20, 1), ('2026-09-01', 1, 30, 1), ('2026-09-01', 2, 5, 1), ('2026-09-01', 2, 7, 2), ` +
|
|
85
|
+
`('2026-09-01', 3, 4, 0), ('2026-09-01', 3, -4, 0), ` +
|
|
86
|
+
`('2026-09-02', 1, 1, 1), ('2026-09-03', 1, 2, 1), ('2026-10-01', 1, 3, 1), ('2026-10-02', 2, 4, 1)`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const activities = (): Map<string, ActivityFn> => {
|
|
91
|
+
const out = new Map<string, ActivityFn>();
|
|
92
|
+
for (const [name, fn] of Object.entries(rebuildActivities)) {
|
|
93
|
+
if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
|
|
94
|
+
out.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) => (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps())) as ActivityFn);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** An in-memory gate ledger: each approval answers the gate the last run stopped at, for the plan it recorded. */
|
|
100
|
+
class Ledger {
|
|
101
|
+
private pending: unknown[] = [];
|
|
102
|
+
private resolutions: unknown[] = [];
|
|
103
|
+
port: GateLedgerPort = memoryGateLedgerPort();
|
|
104
|
+
|
|
105
|
+
approveLast(): void {
|
|
106
|
+
const last = (this.port as ReturnType<typeof memoryGateLedgerPort>).appended.at(-1)!;
|
|
107
|
+
this.pending.push(...(this.port as ReturnType<typeof memoryGateLedgerPort>).appended);
|
|
108
|
+
this.resolutions.push({
|
|
109
|
+
version: 1,
|
|
110
|
+
kind: "resolution",
|
|
111
|
+
op: last.op,
|
|
112
|
+
gate: last.gate,
|
|
113
|
+
resolvedBy: "e2e",
|
|
114
|
+
timestamp: new Date(Date.parse(last.timestamp) + 1000).toISOString(),
|
|
115
|
+
...(last.planDigest ? { planDigest: last.planDigest } : {}),
|
|
116
|
+
});
|
|
117
|
+
this.port = memoryGateLedgerPort({ pending: this.pending as never, resolutions: this.resolutions as never });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe.skipIf(!enabled)("a MergeTree rebuilt into a SummingMergeTree", () => {
|
|
122
|
+
const DB = "chant_e2e_3727";
|
|
123
|
+
test("the Verify phase compares each sorting key's sums, and the run swaps once approved", async () => {
|
|
124
|
+
await clickhouseApply({ buildPath: writeBuild(DB, "daily-v1.json", "MergeTree"), environment: "e2e" }, undefined, deps());
|
|
125
|
+
await seed(DB);
|
|
126
|
+
writeBuild(DB, "daily-v2.json", "SummingMergeTree");
|
|
127
|
+
|
|
128
|
+
const { op } = ClickHouseRebuildOp({
|
|
129
|
+
name: "rebuild-daily-3727",
|
|
130
|
+
env: "e2e",
|
|
131
|
+
table: `${DB}.daily`,
|
|
132
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "day", cutoverDelay: "1s" },
|
|
133
|
+
build: false,
|
|
134
|
+
path: dir,
|
|
135
|
+
output: "daily-v2.json",
|
|
136
|
+
stack: MARKER.stack,
|
|
137
|
+
ownershipEnv: MARKER.env,
|
|
138
|
+
});
|
|
139
|
+
const props = (op as unknown as { props: OpConfig }).props;
|
|
140
|
+
const ledger = new Ledger();
|
|
141
|
+
const run = async () => runOpLocally(props, activities(), await loadProfiles(), undefined, { gates: ledger.port, now: new Date().toISOString() });
|
|
142
|
+
|
|
143
|
+
const first = await run();
|
|
144
|
+
expect(first.records.find((x) => x.fn === "clickhouseRebuildVerify")?.error).toBeUndefined();
|
|
145
|
+
expect(first.status).toBe("gated");
|
|
146
|
+
expect(first.gate).toMatchObject({ gate: "approve-rebuild-daily-3727" });
|
|
147
|
+
const verification = first.records.flatMap((x) => x.outcomes ?? []).find((o) => o.name === "Verification")?.value;
|
|
148
|
+
expect(String(verification)).toMatch(/2 partition\(s\), 11 row\(s\) of the old table as 6 key\(s\).*grouped by the sorting key of the new SummingMergeTree and compared on the sums of total, n/);
|
|
149
|
+
|
|
150
|
+
ledger.approveLast();
|
|
151
|
+
const swapped = await run();
|
|
152
|
+
expect(swapped.records.find((x) => x.fn === "clickhouseRebuildSwap")?.status).toBe("ok");
|
|
153
|
+
expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-daily-3727-drop" });
|
|
154
|
+
expect(await q<{ engine: string }>(`SELECT engine FROM system.tables WHERE database = '${DB}' AND name = 'daily'`)).toEqual([{ engine: "SummingMergeTree" }]);
|
|
155
|
+
expect(await q(`SELECT toString(day) AS day, shop, toString(total) AS total, toString(n) AS n FROM ${DB}.daily FINAL WHERE day = '2026-09-01' ORDER BY shop`)).toEqual([
|
|
156
|
+
{ day: "2026-09-01", shop: 1, total: "60", n: "3" },
|
|
157
|
+
{ day: "2026-09-01", shop: 2, total: "12", n: "3" },
|
|
158
|
+
]);
|
|
159
|
+
}, 300_000);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe.skipIf(!enabled)("a MergeTree rebuilt into a SummingMergeTree that lost rows", () => {
|
|
163
|
+
const DB = "chant_e2e_3727_loss";
|
|
164
|
+
const args = {
|
|
165
|
+
table: `${DB}.daily`,
|
|
166
|
+
buildPath: "loss-v2.json",
|
|
167
|
+
environment: "e2e",
|
|
168
|
+
dualWrite: { mode: "materialized-view" as const, cutoverColumn: "day", cutoverDelay: "1s" },
|
|
169
|
+
stack: MARKER.stack,
|
|
170
|
+
ownershipEnv: MARKER.env,
|
|
171
|
+
cwd: dir,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
test("a changed sum, or a key missing from the new table, fails the verification and nothing is swapped", async () => {
|
|
175
|
+
await clickhouseApply({ buildPath: writeBuild(DB, "loss-v1.json", "MergeTree"), environment: "e2e" }, undefined, deps());
|
|
176
|
+
await seed(DB);
|
|
177
|
+
writeBuild(DB, "loss-v2.json", "SummingMergeTree");
|
|
178
|
+
await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
|
|
179
|
+
await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
|
|
180
|
+
await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
|
|
181
|
+
expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 11, partitions: 2 });
|
|
182
|
+
|
|
183
|
+
// A sum the copy got wrong.
|
|
184
|
+
await mutate(`ALTER TABLE ${DB}.daily__chant_new UPDATE total = total + 1 WHERE shop = 2 AND day = '2026-09-01'`);
|
|
185
|
+
await expect(rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).rejects.toThrow(
|
|
186
|
+
/does not match the old one in 1 partition\(s\): \(?202609\)?: old 4 keys.*new 4 keys.*compared per key on the sums of total, n/,
|
|
187
|
+
);
|
|
188
|
+
await mutate(`ALTER TABLE ${DB}.daily__chant_new UPDATE total = total - 1 WHERE shop = 2 AND day = '2026-09-01'`);
|
|
189
|
+
expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 11 });
|
|
190
|
+
|
|
191
|
+
// A key the copy lost: the swap compares again before the EXCHANGE and stops.
|
|
192
|
+
await mutate(`ALTER TABLE ${DB}.daily__chant_new DELETE WHERE shop = 1 AND day = '2026-10-01'`);
|
|
193
|
+
await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition\(s\): \(?202610\)?: old 2 keys.*new 1 keys/);
|
|
194
|
+
expect(await q<{ engine: string }>(`SELECT engine FROM system.tables WHERE database = '${DB}' AND name = 'daily'`)).toEqual([{ engine: "MergeTree" }]);
|
|
195
|
+
expect(await count(`${DB}.daily`)).toBe(11);
|
|
196
|
+
await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
|
|
197
|
+
}, 300_000);
|
|
198
|
+
});
|