@intentius/chant-lexicon-sql 0.118.0 → 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/build-root.d.ts +9 -1
- package/dist/files/build-root.d.ts.map +1 -1
- 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/build-root.ts +15 -3
- 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 +110 -1
- package/src/sql-files.ts +2 -1
|
@@ -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
|
+
});
|
|
@@ -24,6 +24,8 @@ import { partitionEffect } from "./backfill";
|
|
|
24
24
|
import { insertTarget, waitForCutover, type CutoverProbe } from "./steps";
|
|
25
25
|
import { intoShard, onShard, onShardServers, shardTable } from "./shards";
|
|
26
26
|
import { renameColumns, sourcePartitionExpression } from "./partitions";
|
|
27
|
+
import { collapseSemantics, collapsesRows, engineArguments, splitTopLevel, type EngineColumn } from "./engines";
|
|
28
|
+
import { RebuildVerificationError } from "./verify";
|
|
27
29
|
import { canonicalObject } from "../plan/normalize";
|
|
28
30
|
import { diffSchemas } from "../plan/diff";
|
|
29
31
|
import { renderDiff } from "../plan/report";
|
|
@@ -226,6 +228,42 @@ describe("the plan hands a rebuild to the Op", () => {
|
|
|
226
228
|
});
|
|
227
229
|
});
|
|
228
230
|
|
|
231
|
+
describe("a table whose engine collapses rows by sorting key (#3674)", () => {
|
|
232
|
+
test("the collapsing MergeTree engines are read under FINAL, replicated or shared too; the others are not", () => {
|
|
233
|
+
for (const e of ["SummingMergeTree", "ReplacingMergeTree", "AggregatingMergeTree", "CollapsingMergeTree", "VersionedCollapsingMergeTree", "ReplicatedSummingMergeTree", "SharedReplacingMergeTree"]) {
|
|
234
|
+
expect(collapsesRows(e), e).toBe(true);
|
|
235
|
+
}
|
|
236
|
+
for (const e of ["MergeTree", "ReplicatedMergeTree", "Log", "Distributed", undefined]) expect(collapsesRows(e), String(e)).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a difference on such a table names the engine and the OPTIMIZE that merges it, not only a late write", () => {
|
|
240
|
+
const mismatch = [{ partition: "all", old: { partition: "all", rows: 3, checksum: "1" }, new: { partition: "all", rows: 1, checksum: "2" } }];
|
|
241
|
+
const collapsing = new RebuildVerificationError("db.daily", mismatch, "SummingMergeTree").message;
|
|
242
|
+
expect(collapsing).toMatch(/Nothing was swapped\. db\.daily is a SummingMergeTree, .*compared under FINAL/);
|
|
243
|
+
expect(collapsing).toContain("run OPTIMIZE TABLE db.daily FINAL");
|
|
244
|
+
const plain = new RebuildVerificationError("db.events", mismatch).message;
|
|
245
|
+
expect(plain).not.toMatch(/FINAL/);
|
|
246
|
+
expect(plain).toMatch(/The usual cause is in materialized-view mode a row written to the old table later than the cut-over delay/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("from a plain MergeTree, a difference names what was compared per key", () => {
|
|
250
|
+
const mismatch = [{ partition: "202610", old: { partition: "202610", rows: 2, checksum: "1" }, new: { partition: "202610", rows: 1, checksum: "2" } }];
|
|
251
|
+
const message = new RebuildVerificationError("db.daily", mismatch, { engine: "SummingMergeTree", how: "keys", describe: "the sums of total" }).message;
|
|
252
|
+
expect(message).toContain("202610: old 2 keys, checksum 1, new 1 keys, checksum 2");
|
|
253
|
+
expect(message).toMatch(/The new table is a SummingMergeTree, .*compared per key on the sums of total/);
|
|
254
|
+
expect(message).not.toMatch(/OPTIMIZE/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("the plan's hand-off says so for such a table", () => {
|
|
258
|
+
const ddl = (o: string) => `CREATE TABLE db.daily (day Date, total Decimal(18, 2), n UInt64) ENGINE = SummingMergeTree ORDER BY ${o}`;
|
|
259
|
+
const after = [{ key: "daily", canonical: canonicalObject(ddl("(day, n)")) }];
|
|
260
|
+
const diff = diffSchemas([{ key: "daily", canonical: canonicalObject(ddl("day")) }], after);
|
|
261
|
+
const ops = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "prod");
|
|
262
|
+
expect(ops[0]!.note).toMatch(/^db\.daily is a SummingMergeTree: when the old table's engine collapses rows too, the Verify phase reads both tables under FINAL/);
|
|
263
|
+
expect(renderDiff({ ...diff, rebuildOps: ops })).toContain(` Note: ${ops[0]!.note}`);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
229
267
|
describe("a rebuild across the shards of a cluster (#3663)", () => {
|
|
230
268
|
const sharding = { cluster: "main", shards: [{ num: 1, macro: "s1", slot: 0 }, { num: 2, macro: "s2", slot: 1 }] };
|
|
231
269
|
const shard2 = sharding.shards[1]!;
|
|
@@ -294,3 +332,43 @@ describe("the wait for the cut-over (#3700)", () => {
|
|
|
294
332
|
expect(insertTarget("SELECT * FROM shop.events", "shop")).toBeUndefined();
|
|
295
333
|
});
|
|
296
334
|
});
|
|
335
|
+
|
|
336
|
+
describe("what a collapsing engine keeps per sorting key (#3727)", () => {
|
|
337
|
+
const col = (name: string, type: string, inSortingKey = false): EngineColumn => ({ name, type, inSortingKey });
|
|
338
|
+
const columns = [col("day", "Date", true), col("shop", "UInt32", true), col("total", "Decimal(18, 2)"), col("n", "UInt64"), col("ratio", "Float64"), col("note", "String"), col("sign", "Int8"), col("ver", "UInt32")];
|
|
339
|
+
|
|
340
|
+
test("splitTopLevel and engineArguments read engine_full, without a Replicated engine's Keeper path", () => {
|
|
341
|
+
expect(splitTopLevel("toStartOfInterval(ts, toIntervalDay(1)), id")).toEqual(["toStartOfInterval(ts, toIntervalDay(1))", "id"]);
|
|
342
|
+
expect(splitTopLevel("")).toEqual([]);
|
|
343
|
+
expect(engineArguments("SummingMergeTree((total, n)) PARTITION BY toYYYYMM(day) ORDER BY (day, shop) SETTINGS index_granularity = 8192")).toEqual(["(total, n)"]);
|
|
344
|
+
expect(engineArguments("ReplicatedCollapsingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', sign) ORDER BY id")).toEqual(["sign"]);
|
|
345
|
+
expect(engineArguments("SummingMergeTree ORDER BY id")).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("SummingMergeTree: the sums of every numeric column outside the key, or of those listed, in their own types; a key whose sums are zero is removed", () => {
|
|
349
|
+
const all = collapseSemantics("SummingMergeTree", "SummingMergeTree ORDER BY (day, shop)", columns)!;
|
|
350
|
+
expect(all.aggregates).toEqual(["CAST(sum(`total`), 'Decimal(18, 2)')", "CAST(sum(`n`), 'UInt64')", "toFloat32(sum(`ratio`))", "CAST(sum(`sign`), 'Int8')", "CAST(sum(`ver`), 'UInt32')"]);
|
|
351
|
+
expect(all.having).toBe(`NOT (${all.aggregates.map((a) => `${a} = 0`).join(" AND ")})`);
|
|
352
|
+
const listed = collapseSemantics("ReplicatedSummingMergeTree", "ReplicatedSummingMergeTree('/p', '{replica}', (total, n)) ORDER BY (day, shop)", columns)!;
|
|
353
|
+
expect(listed).toMatchObject({ engine: "SummingMergeTree", aggregates: ["CAST(sum(`total`), 'Decimal(18, 2)')", "CAST(sum(`n`), 'UInt64')"], describe: "the sums of total, n" });
|
|
354
|
+
const partitioned = collapseSemantics("SummingMergeTree", "SummingMergeTree PARTITION BY g ORDER BY k", [col("k", "UInt32", true), { ...col("g", "UInt8"), inPartitionKey: true }, col("a", "UInt32")])!;
|
|
355
|
+
expect(partitioned.aggregates).toEqual(["CAST(sum(`a`), 'UInt32')"]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("Replacing keeps the highest version, Collapsing the sum of its signs, Aggregating its order-free simple functions, Coalescing which columns hold a value", () => {
|
|
359
|
+
expect(collapseSemantics("ReplacingMergeTree", "ReplacingMergeTree(ver) ORDER BY id", columns)).toMatchObject({ aggregates: ["max(`ver`)"], describe: "the highest ver" });
|
|
360
|
+
expect(collapseSemantics("ReplacingMergeTree", "ReplacingMergeTree ORDER BY id", columns)).toMatchObject({ aggregates: [], describe: "the keys" });
|
|
361
|
+
expect(collapseSemantics("CollapsingMergeTree", "CollapsingMergeTree(sign) ORDER BY id", columns)).toMatchObject({ aggregates: ["sum(`sign`)"], having: "sum(`sign`) != 0" });
|
|
362
|
+
expect(collapseSemantics("VersionedCollapsingMergeTree", "VersionedCollapsingMergeTree(sign, ver) ORDER BY id", columns)).toMatchObject({ aggregates: ["sum(`sign`)"], having: "sum(`sign`) != 0" });
|
|
363
|
+
const agg = [col("k", "UInt32", true), col("hi", "SimpleAggregateFunction(max, UInt64)"), col("any", "SimpleAggregateFunction(anyLast, String)"), col("u", "AggregateFunction(uniq, UInt64)")];
|
|
364
|
+
expect(collapseSemantics("AggregatingMergeTree", "AggregatingMergeTree ORDER BY k", agg)).toMatchObject({ aggregates: ["max(`hi`)"], describe: "max(hi)" });
|
|
365
|
+
const coalescing = [col("k", "UInt32", true), col("v", "Nullable(String)"), col("w", "UInt8")];
|
|
366
|
+
expect(collapseSemantics("CoalescingMergeTree", "CoalescingMergeTree ORDER BY k", coalescing)).toMatchObject({ aggregates: ["count(`v`) > 0"] });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("an engine that keeps every row, or whose rows depend on the order of merges, has none", () => {
|
|
370
|
+
expect(collapseSemantics("MergeTree", "MergeTree ORDER BY id", columns)).toBeUndefined();
|
|
371
|
+
expect(collapseSemantics("GraphiteMergeTree", "GraphiteMergeTree('graphite_rollup') ORDER BY id", columns)).toBeUndefined();
|
|
372
|
+
expect(collapseSemantics("CollapsingMergeTree", "CollapsingMergeTree(missing) ORDER BY id", columns)).toBeUndefined();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* later than the cut-over delay, with a time before the cut-over) stop the
|
|
25
25
|
* swap too, rather than stay behind in the old table.
|
|
26
26
|
*
|
|
27
|
+
* A table whose engine collapses rows that share a sorting key when parts
|
|
28
|
+
* merge (Summing, Replacing, Aggregating, Collapsing, VersionedCollapsing,
|
|
29
|
+
* Coalescing and Graphite, replicated or not), rebuilt into such an engine,
|
|
30
|
+
* is read under `FINAL` on both sides (#3674). The old table can hold unmerged parts with several rows for
|
|
31
|
+
* one key, which the copy inserts in one block and the new table collapses
|
|
32
|
+
* as it writes them, so the raw counts differ although the data is the same.
|
|
33
|
+
* Under `FINAL` both sides are compared as merged.
|
|
34
|
+
*
|
|
27
35
|
* On a cluster of more than one shard (#3663) every shard is read, through
|
|
28
36
|
* the profile's server, and each partition is compared per shard
|
|
29
37
|
* (`shard<n>/<partition>`), so a shard the copy missed is a difference.
|
|
@@ -44,9 +52,11 @@ import { rebuildPlanSubject, RebuildRefusal, type RebuildObservation } from "./o
|
|
|
44
52
|
import { sourcePartitionExpression } from "./partitions";
|
|
45
53
|
import { cutoverOf, observe, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
|
|
46
54
|
import { shardTable } from "./shards";
|
|
55
|
+
import { collapseSemantics, collapsesRows, splitTopLevel, type CollapseSemantics, type EngineColumn } from "./engines";
|
|
47
56
|
|
|
48
57
|
export interface PartitionCheck {
|
|
49
58
|
partition: string;
|
|
59
|
+
/** Rows; keys of the new sorting key when the tables were compared by key (#3727). */
|
|
50
60
|
rows: number;
|
|
51
61
|
checksum: string;
|
|
52
62
|
}
|
|
@@ -67,23 +77,42 @@ export interface VerifyResult {
|
|
|
67
77
|
|
|
68
78
|
/** A verification that found the tables different. */
|
|
69
79
|
export class RebuildVerificationError extends Error {
|
|
80
|
+
/** The engine, when it collapses rows and the tables were compared under FINAL (#3674) or by sorting key (#3727). */
|
|
81
|
+
readonly collapsingEngine?: string;
|
|
70
82
|
constructor(
|
|
71
83
|
readonly table: string,
|
|
72
84
|
readonly mismatches: Mismatch[],
|
|
85
|
+
/** How the tables were compared when an engine collapses rows; an engine's name alone means under FINAL. */
|
|
86
|
+
collapsed?: string | Collapsed,
|
|
73
87
|
) {
|
|
88
|
+
const how: Collapsed | undefined = typeof collapsed === "string" ? { engine: collapsed, how: "final" } : collapsed;
|
|
74
89
|
const shown = mismatches
|
|
75
90
|
.slice(0, 10)
|
|
76
|
-
.map((m) => `${m.partition}: old ${m.old ? `${m.old.rows}
|
|
91
|
+
.map((m) => `${m.partition}: old ${m.old ? `${m.old.rows} ${unit(how)}, checksum ${m.old.checksum}` : "none"}, new ${m.new ? `${m.new.rows} ${unit(how)}, checksum ${m.new.checksum}` : "none"}`)
|
|
77
92
|
.join("; ");
|
|
93
|
+
const lateWrite =
|
|
94
|
+
`in materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, ` +
|
|
95
|
+
`and in app mode a write the application did not stop`;
|
|
78
96
|
super(
|
|
79
|
-
`${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. ` +
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
`${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. Nothing was swapped. ` +
|
|
98
|
+
(how?.how === "final"
|
|
99
|
+
? `${table} is a ${how.engine}, which collapses rows that share a sorting key when parts merge, and both tables were compared under FINAL. ` +
|
|
100
|
+
`They still differ when the new table collapses the copied rows by its sorting key differently from how the old one merges them: ` +
|
|
101
|
+
`run OPTIMIZE TABLE ${table} FINAL, drop the new table if the Op kept it, and run the rebuild again, so the copy reads merged rows. ` +
|
|
102
|
+
`The other causes are ${lateWrite}.`
|
|
103
|
+
: how?.how === "keys"
|
|
104
|
+
? `The new table is a ${how.engine}, which collapses rows that share a sorting key, so both tables were grouped by its sorting key and partition ` +
|
|
105
|
+
`and compared per key on ${how.describe ?? "the keys"}, which its merges keep. A key missing from the new table, or one whose values differ, is rows the copy lost or changed. ` +
|
|
106
|
+
`The usual cause is ${lateWrite}.`
|
|
107
|
+
: `The usual cause is ${lateWrite}.`),
|
|
82
108
|
);
|
|
83
109
|
this.name = "RebuildVerificationError";
|
|
110
|
+
if (how) this.collapsingEngine = how.engine;
|
|
84
111
|
}
|
|
85
112
|
}
|
|
86
113
|
|
|
114
|
+
const unit = (how: Collapsed | undefined) => (how?.how === "keys" ? "keys" : "rows");
|
|
115
|
+
|
|
87
116
|
/** How often a difference is read again before it counts, and how long apart. */
|
|
88
117
|
const COMPARE_ATTEMPTS = 5;
|
|
89
118
|
const COMPARE_INTERVAL_MS = 1000;
|
|
@@ -92,6 +121,81 @@ const COMPARE_INTERVAL_MS = 1000;
|
|
|
92
121
|
interface SideCounts {
|
|
93
122
|
all: Map<string, PartitionCheck>;
|
|
94
123
|
before: Map<string, PartitionCheck>;
|
|
124
|
+
/** Grouped by sorting key (#3727): the rows read, of which `all` counts the keys. */
|
|
125
|
+
rows?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How the tables were compared when an engine collapses rows: both under
|
|
130
|
+
* FINAL (#3674), or grouped by the new table's sorting key on what its
|
|
131
|
+
* engine's merges keep (#3727).
|
|
132
|
+
*/
|
|
133
|
+
export interface Collapsed {
|
|
134
|
+
engine: string;
|
|
135
|
+
how: "final" | "keys";
|
|
136
|
+
/** With `keys`: what is compared per key. */
|
|
137
|
+
describe?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Both tables grouped by the new table's sorting key and its partition, each
|
|
142
|
+
* key compared on what the new engine's merges keep (#3727): its sums, its
|
|
143
|
+
* highest version, the sum of its signs. Each side is read raw, so the
|
|
144
|
+
* comparison is the same whichever parts have merged. The old side's columns
|
|
145
|
+
* are cast to the new table's types and named as there, so the sorting key,
|
|
146
|
+
* the partition key and the aggregates read the same on both sides. A
|
|
147
|
+
* partition is the new table's (`shard<n>/` before it on a cluster), and its
|
|
148
|
+
* rows are its keys.
|
|
149
|
+
*/
|
|
150
|
+
async function keyComparison(run: RebuildRun, o: RebuildObservation, columns: readonly EngineColumn[]): Promise<{ semantics: CollapseSemantics; read: (side: "old" | "new") => Promise<SideCounts> } | undefined> {
|
|
151
|
+
const n = o.names;
|
|
152
|
+
const [t] = await clickhouseQuery<{ engine_full: string; sorting_key: string; partition_key: string }>(
|
|
153
|
+
run.target.endpoint,
|
|
154
|
+
`SELECT engine_full, sorting_key, partition_key FROM system.tables WHERE database = ${sqlString(n.database)} AND name = ${sqlString(n.newName)}`,
|
|
155
|
+
);
|
|
156
|
+
if (!t || !o.newTable) return undefined;
|
|
157
|
+
const semantics = collapseSemantics(o.newTable.engine, t.engine_full, columns);
|
|
158
|
+
if (!semantics) return undefined;
|
|
159
|
+
// Every column of the sorting key must come from the old table, or the old rows have no key to group by.
|
|
160
|
+
const copied = new Set(o.copied.map((c) => c.name));
|
|
161
|
+
if (columns.some((c) => c.inSortingKey && !copied.has(c.name))) return undefined;
|
|
162
|
+
|
|
163
|
+
const types = new Map(columns.map((c) => [c.name, c.type]));
|
|
164
|
+
const sharding = run.sharding;
|
|
165
|
+
const cutover = cutoverOf(run, o);
|
|
166
|
+
const cut = run.dualWrite.mode === "materialized-view" ? (run.dualWrite as { cutoverColumn: string }).cutoverColumn : undefined;
|
|
167
|
+
const before = cut && copied.has(cut) && cutover !== undefined ? `${ident(cut)} < ${utcLiteral(cutover)}` : "1";
|
|
168
|
+
const key = splitTopLevel(t.sorting_key).filter(Boolean);
|
|
169
|
+
const parts = splitTopLevel(t.partition_key).filter(Boolean);
|
|
170
|
+
const partition = parts.length === 0 ? "'all'" : `toString(${parts.length === 1 ? parts[0] : `tuple(${parts.join(", ")})`})`;
|
|
171
|
+
const label = sharding ? `concat('shard', toString(__shard), '/', ${partition})` : partition;
|
|
172
|
+
const hash = `cityHash64(toString(tuple(${[...key, ...semantics.aggregates].join(", ")})))`;
|
|
173
|
+
|
|
174
|
+
const read = async (side: "old" | "new"): Promise<SideCounts> => {
|
|
175
|
+
const name = side === "old" ? n.name : n.newName;
|
|
176
|
+
const from = sharding ? shardTable(sharding, n.database, name) : side === "old" ? n.table : n.newTable;
|
|
177
|
+
const projected = o.copied.map((c) => (side === "old" ? `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")}) AS ${ident(c.name)}` : ident(c.name)));
|
|
178
|
+
const rowsOf = `SELECT ${sharding ? "_shard_num AS __shard, " : ""}${projected.join(", ")} FROM ${from}`;
|
|
179
|
+
const perKey =
|
|
180
|
+
`SELECT ${label} AS __p, ${hash} AS __h, max(${before}) AS __b, count() AS __n, ${semantics.having ?? "1"} AS __k FROM (${rowsOf}) ` +
|
|
181
|
+
`GROUP BY __p${sharding ? ", __shard" : ""}${key.length > 0 ? `, ${key.join(", ")}` : ""}`;
|
|
182
|
+
// A key the engine removes (its sums all zero, its signs cancelled) is not compared, but its rows are counted as read.
|
|
183
|
+
const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string; raw: string | number }>(
|
|
184
|
+
run.target.endpoint,
|
|
185
|
+
`SELECT __p AS p, countIf(__k) AS rows, toString(sumIf(__h, __k)) AS checksum, countIf(__b AND __k) AS brows, toString(sumIf(__h, __b AND __k)) AS bchecksum, sum(__n) AS raw ` +
|
|
186
|
+
`FROM (${perKey}) GROUP BY p`,
|
|
187
|
+
);
|
|
188
|
+
const out: SideCounts = { all: new Map(), before: new Map(), rows: 0 };
|
|
189
|
+
for (const r of rows) {
|
|
190
|
+
out.rows! += Number(r.raw);
|
|
191
|
+
// A partition whose keys the engine all removes holds nothing in the new table, as if it had no rows.
|
|
192
|
+
if (Number(r.rows) === 0) continue;
|
|
193
|
+
out.all.set(r.p, { partition: r.p, rows: Number(r.rows), checksum: r.checksum });
|
|
194
|
+
if (Number(r.brows) > 0) out.before.set(r.p, { partition: r.p, rows: Number(r.brows), checksum: r.bchecksum });
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
return { semantics, read };
|
|
95
199
|
}
|
|
96
200
|
|
|
97
201
|
/**
|
|
@@ -103,21 +207,21 @@ async function compareTables(
|
|
|
103
207
|
run: RebuildRun,
|
|
104
208
|
o: RebuildObservation,
|
|
105
209
|
opts: { attempts?: number; intervalMs?: number } = {},
|
|
106
|
-
): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[] }> {
|
|
210
|
+
): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[]; collapsed?: Collapsed }> {
|
|
107
211
|
if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
|
|
108
212
|
const n = o.names;
|
|
109
213
|
const cutover = cutoverOf(run, o);
|
|
110
214
|
await waitOn(run, n.database, n.name);
|
|
111
215
|
await waitOn(run, n.database, n.newName);
|
|
112
216
|
|
|
113
|
-
const
|
|
114
|
-
(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
217
|
+
const newColumns = (
|
|
218
|
+
await clickhouseQuery<{ name: string; type: string; key: number | string; partition: number | string }>(
|
|
219
|
+
run.target.endpoint,
|
|
220
|
+
`SELECT name, type, is_in_sorting_key AS key, is_in_partition_key AS partition FROM system.columns ` +
|
|
221
|
+
`WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)} ORDER BY position`,
|
|
222
|
+
)
|
|
223
|
+
).map((c): EngineColumn => ({ name: c.name, type: c.type, inSortingKey: Number(c.key) === 1, inPartitionKey: Number(c.partition) === 1 }));
|
|
224
|
+
const types = new Map(newColumns.map((c) => [c.name, c.type]));
|
|
121
225
|
const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
|
|
122
226
|
const newHash = rowHash(o.copied.map((c) => ident(c.name)));
|
|
123
227
|
const cut = run.dualWrite.mode === "materialized-view" ? o.copied.find((c) => c.name === (run.dualWrite as { cutoverColumn: string }).cutoverColumn) : undefined;
|
|
@@ -127,8 +231,15 @@ async function compareTables(
|
|
|
127
231
|
|
|
128
232
|
// Across the shards of a cluster (#3663), every shard's rows, each partition counted per shard as `shard<n>/<partition>`.
|
|
129
233
|
const sharding = run.sharding;
|
|
130
|
-
|
|
131
|
-
|
|
234
|
+
// Tables whose engines collapse rows by sorting key are read as merged (#3674). Only when both do: a collapsing
|
|
235
|
+
// table rebuilt into a plain MergeTree keeps every row it copies, and is compared row for row.
|
|
236
|
+
const final = collapsesRows(o.live.engine) && collapsesRows(o.newTable.engine);
|
|
237
|
+
// A table that does not collapse rows rebuilt into one that does (#3727): the new table keeps one row per sorting
|
|
238
|
+
// key where the old one keeps them all, by design. Both are grouped by the new sorting key and compared on what
|
|
239
|
+
// the new engine's merges keep per key.
|
|
240
|
+
const keys = !final && collapsesRows(o.newTable.engine) ? await keyComparison(run, o, newColumns) : undefined;
|
|
241
|
+
const read = async (table: string, name: string, partition: string, hash: string, before: string, final: boolean): Promise<SideCounts> => {
|
|
242
|
+
const from = `${sharding ? shardTable(sharding, n.database, name) : table}${final ? " FINAL" : ""}`;
|
|
132
243
|
const key = sharding ? `concat('shard', toString(_shard_num), '/', ${partition})` : partition;
|
|
133
244
|
const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
|
|
134
245
|
run.target.endpoint,
|
|
@@ -150,10 +261,18 @@ async function compareTables(
|
|
|
150
261
|
await syncReplica(run, o, n.database, n.name);
|
|
151
262
|
await syncReplica(run, o, n.database, n.newName);
|
|
152
263
|
// The old table first: a row the view has yet to commit in the new one is read as missing there, never the other way round.
|
|
153
|
-
const before = await read(n.table, n.name, "_partition_id", oldHash, oldBefore);
|
|
154
|
-
const after = await read(n.newTable, n.newName, fromOld, newHash, newBefore);
|
|
264
|
+
const before = keys ? await keys.read("old") : await read(n.table, n.name, "_partition_id", oldHash, oldBefore, final);
|
|
265
|
+
const after = keys ? await keys.read("new") : await read(n.newTable, n.newName, fromOld, newHash, newBefore, final);
|
|
155
266
|
const mismatches = differences(before.all, after.all);
|
|
156
|
-
if (mismatches.length === 0 || attempt >= attempts)
|
|
267
|
+
if (mismatches.length === 0 || attempt >= attempts) {
|
|
268
|
+
return {
|
|
269
|
+
old: before,
|
|
270
|
+
new: after,
|
|
271
|
+
mismatches,
|
|
272
|
+
...(final ? { collapsed: { engine: o.live.engine, how: "final" as const } } : {}),
|
|
273
|
+
...(keys ? { collapsed: { engine: o.newTable.engine, how: "keys" as const, describe: keys.semantics.describe } } : {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
157
276
|
run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
|
|
158
277
|
await sleep(opts.intervalMs ?? COMPARE_INTERVAL_MS, run.signal);
|
|
159
278
|
}
|
|
@@ -179,8 +298,8 @@ export const rowHash = (columns: readonly string[]): string => `cityHash64(toStr
|
|
|
179
298
|
* {@link RebuildVerificationError} when it does not, so nothing is swapped.
|
|
180
299
|
*/
|
|
181
300
|
export async function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void> {
|
|
182
|
-
const { mismatches } = await compareTables(run, o);
|
|
183
|
-
if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches);
|
|
301
|
+
const { mismatches, collapsed } = await compareTables(run, o);
|
|
302
|
+
if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches, collapsed);
|
|
184
303
|
}
|
|
185
304
|
|
|
186
305
|
export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
@@ -188,15 +307,25 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
|
188
307
|
if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
|
|
189
308
|
const cutover = cutoverOf(run, o);
|
|
190
309
|
const compared = await compareTables(run, o);
|
|
191
|
-
if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches);
|
|
310
|
+
if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches, compared.collapsed);
|
|
192
311
|
|
|
193
312
|
const all = [...compared.old.all.values()];
|
|
194
|
-
const
|
|
313
|
+
const counted = all.reduce((s, v) => s + v.rows, 0);
|
|
314
|
+
const c = compared.collapsed;
|
|
315
|
+
// Grouped by sorting key (#3727), each partition counts keys; the old table's rows are still every row read.
|
|
316
|
+
const rows = c?.how === "keys" ? (compared.old.rows ?? counted) : counted;
|
|
195
317
|
// What the approval binds: the rows before the cut-over (every row in app mode), which later writes do not change.
|
|
196
318
|
const verification = [...compared.old.before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
|
|
197
319
|
const bound = verification.reduce((s, v) => s + v.rows, 0);
|
|
198
|
-
const range = cutover !== undefined ? ` (${bound} of them before the cut-over at ${new Date(cutover).toISOString()})` : "";
|
|
199
|
-
const
|
|
320
|
+
const range = cutover !== undefined ? ` (${bound} ${c?.how === "keys" ? "key(s)" : "of them"} before the cut-over at ${new Date(cutover).toISOString()})` : "";
|
|
321
|
+
const counts = c?.how === "keys" ? `${rows} row(s) of the old table as ${counted} key(s)` : `${rows} row(s)`;
|
|
322
|
+
const merged =
|
|
323
|
+
c?.how === "final"
|
|
324
|
+
? `, read under FINAL (${c.engine})`
|
|
325
|
+
: c?.how === "keys"
|
|
326
|
+
? `, grouped by the sorting key of the new ${c.engine} and compared on ${c.describe}`
|
|
327
|
+
: "";
|
|
328
|
+
const summary = `${o.names.key}: ${all.length} partition(s), ${counts}${range}, counts and checksums equal in the old and new tables${merged}`;
|
|
200
329
|
run.log(`-- ${summary}`);
|
|
201
330
|
return {
|
|
202
331
|
state: o.state,
|
|
@@ -24,6 +24,8 @@ export interface FakeOptions {
|
|
|
24
24
|
/** Answer every query with this status and body (an auth failure, say). */
|
|
25
25
|
fail?: { status: number; body: string };
|
|
26
26
|
version?: string;
|
|
27
|
+
/** The SQL user-defined functions `system.functions` lists. Default: none. */
|
|
28
|
+
functions?: Array<{ name: string; statement: string }>;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const row = (o: Record<string, unknown>) => `${JSON.stringify(o)}\n`;
|
|
@@ -52,8 +54,7 @@ export async function fakeClickHouse(objects: FakeObject[], options: FakeOptions
|
|
|
52
54
|
.join(""),
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
if (/FROM system\.functions/.test(body)) return ok("");
|
|
57
|
+
if (/FROM system\.functions/.test(body)) return ok((options.functions ?? []).map((f) => row(f)).join(""));
|
|
57
58
|
const show = /^SHOW CREATE (DATABASE|TABLE) (`(?:[^`]|``)*`)(?:\.(`(?:[^`]|``)*`))?$/.exec(body.trim());
|
|
58
59
|
if (show) {
|
|
59
60
|
const [db, name] = show[3] ? [unquote(show[2]!), unquote(show[3])] : [undefined, unquote(show[2]!)];
|
|
@@ -70,7 +70,7 @@ ALTER TABLE \`analytics\`.\`events\` MODIFY COLUMN \`kind\` LowCardinality(Strin
|
|
|
70
70
|
-- export const { op } = ClickHouseRebuildOp({ name: "rebuild-analytics-sessions", env: "<env>", table: "analytics.sessions", dualWrite: { mode: "materialized-view", cutoverColumn: "ts" } });
|
|
71
71
|
\`\`\`
|
|
72
72
|
|
|
73
|
-
A rebuild is never DDL: it is a step naming \`ClickHouseRebuildOp\`, or for a view or a database, which the Op does not rebuild, a manual step. A column or object drop is a statement marked destructive. Each \`CREATE\` carries the project's ownership marker, as the applier stamps it. The command exits 2 when a step is not a statement. Two things the applier asks the server are left out: whether a difference is formatting only, and whether a database is empty before it is dropped.
|
|
73
|
+
A rebuild is never DDL: it is a step naming \`ClickHouseRebuildOp\`, or for a view or a database, which the Op does not rebuild, a manual step. A column or object drop is a statement marked destructive. Each \`CREATE\` carries the project's ownership marker, as the applier stamps it. The command exits 2 when a step is not a statement. A migration runner's history table (\`schema_migrations\`, \`goose_db_version\`; see [Importing a Live Server](../importing/)) is never dropped: the report names it in a hint. Two things the applier asks the server are left out: whether a difference is formatting only, and whether a database is empty before it is dropped.
|
|
74
74
|
|
|
75
75
|
\`--topology\` renders the statements for where they will run: \`single\` (the default), \`cluster:<name>\`, \`replicated\` or \`cloud\`. On a cluster every statement carries \`ON CLUSTER\` and a MergeTree-family table becomes \`Replicated*MergeTree\` with its Keeper path; in a \`Replicated\` database the tables are \`Replicated*MergeTree\` with no path and no \`ON CLUSTER\`. \`diffStatements(before, after, { topology })\` takes the same choice; see [Applying to a Server](../applying/#topology).
|
|
76
76
|
|
package/src/config.ts
CHANGED
|
@@ -104,6 +104,14 @@ export const sqlProfileSchema = z.strictObject({
|
|
|
104
104
|
databases: z.array(z.string()).optional(),
|
|
105
105
|
/** The database an unqualified declaration is created in. `default` when omitted. */
|
|
106
106
|
defaultDatabase: z.string().optional(),
|
|
107
|
+
/**
|
|
108
|
+
* ClickHouse import (#3718): the SQL user-defined functions `chant import
|
|
109
|
+
* --from <env>` adopts besides those the imported objects call, by name, or
|
|
110
|
+
* by prefix with a trailing `*` (`shop_*`). A function belongs to no
|
|
111
|
+
* database, so without this only the functions the imported tables, views
|
|
112
|
+
* and dictionaries call (and the functions those call) are imported.
|
|
113
|
+
*/
|
|
114
|
+
importFunctions: z.array(z.string()).optional(),
|
|
107
115
|
/**
|
|
108
116
|
* ClickHouse: the topology this environment runs, which the applier, the
|
|
109
117
|
* plan and the rebuild migration render every statement for: `single`,
|
|
@@ -140,8 +148,11 @@ export const sqlProfileSchema = z.strictObject({
|
|
|
140
148
|
/**
|
|
141
149
|
* Postgres apply: how long one statement waits for its lock before it
|
|
142
150
|
* fails (`lock_timeout`), in milliseconds, so a change blocked behind a
|
|
143
|
-
* long query fails fast instead of queueing every query behind it.
|
|
144
|
-
*
|
|
151
|
+
* long query fails fast instead of queueing every query behind it. The
|
|
152
|
+
* catalog reads (`chant lifecycle diff --live`, `chant sql plan`, the
|
|
153
|
+
* import, and an apply's read before its first statement) wait under it
|
|
154
|
+
* too, and name the session holding the lock when it passes. 5000 when
|
|
155
|
+
* omitted.
|
|
145
156
|
*/
|
|
146
157
|
lockTimeoutMs: z.number().int().nonnegative().optional(),
|
|
147
158
|
/**
|
|
@@ -156,11 +167,12 @@ export const sqlProfileSchema = z.strictObject({
|
|
|
156
167
|
*/
|
|
157
168
|
scanTimeoutMs: z.number().int().nonnegative().optional(),
|
|
158
169
|
/**
|
|
159
|
-
*
|
|
170
|
+
* Whether chant manages access in this environment. Postgres (#3681): the
|
|
160
171
|
* declared row-level security policies and each table's row-level security,
|
|
161
172
|
* the declared roles (created when missing, never dropped), and the
|
|
162
173
|
* privileges on the declared objects and the default privileges, which a
|
|
163
|
-
* plan compares and an apply grants and revokes.
|
|
174
|
+
* plan compares and an apply grants and revokes. ClickHouse (#3716): the
|
|
175
|
+
* declared users, roles, row policies and grants. Off when omitted: the
|
|
164
176
|
* access declarations are not planned or applied, and the server's access
|
|
165
177
|
* is not read, so a project that manages access elsewhere is not affected.
|
|
166
178
|
*/
|