@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
|
@@ -17,17 +17,29 @@
|
|
|
17
17
|
* (#3664). When a server's copy of a table differs from the profile
|
|
18
18
|
* server's, that copy is what is reported, with the server in `observedOn`,
|
|
19
19
|
* which the drift report prints as `seen on`.
|
|
20
|
+
*
|
|
21
|
+
* Where the profile manages access, the grants an apply would give or take
|
|
22
|
+
* from each declared grantee are reported as `pending` (#3733), the same
|
|
23
|
+
* statements `chant sql plan` plans (SQLCH273, SQLCH274), so a privilege
|
|
24
|
+
* granted or revoked by hand after an approval moves the plan digest.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
deepObservation,
|
|
29
|
+
type DeepObservationResult,
|
|
30
|
+
type DeepPendingChange,
|
|
31
|
+
type DeepResourceObservation,
|
|
32
|
+
type DeepNormalizationHooks,
|
|
33
|
+
} from "@intentius/chant/deep-observation";
|
|
23
34
|
import type { UnobservedEntity } from "@intentius/chant/lexicon";
|
|
24
35
|
import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "../live/bind";
|
|
25
|
-
import { accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
|
|
36
|
+
import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
|
|
26
37
|
import { clickhouseQuery } from "../http";
|
|
27
38
|
import { sqlString } from "../apply/statements";
|
|
28
39
|
import { stripMarkerFromStatement } from "../ownership";
|
|
29
40
|
import { database, dictionary, func, policy, role, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
30
|
-
import { canonicalObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
41
|
+
import { canonicalObject, grantsObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
42
|
+
import { grantStatement, grantsByGrantee, revokeStatement, type GrantAtom } from "../access";
|
|
31
43
|
import { renderFor } from "../topology";
|
|
32
44
|
|
|
33
45
|
type Props = Record<string, unknown>;
|
|
@@ -207,7 +219,7 @@ export async function observeResourcesDeep(
|
|
|
207
219
|
let live: LiveObject[];
|
|
208
220
|
try {
|
|
209
221
|
target = await bindClickHouse(options);
|
|
210
|
-
live = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)))];
|
|
222
|
+
live = [...(await readLiveSchema(target)), ...(target.access === true ? await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)) : [])];
|
|
211
223
|
} catch (err) {
|
|
212
224
|
const why = classifyClickHouseFailure(err);
|
|
213
225
|
for (const name of options.entityNames) {
|
|
@@ -227,17 +239,34 @@ export async function observeResourcesDeep(
|
|
|
227
239
|
}
|
|
228
240
|
return deepObservation({}, unobserved);
|
|
229
241
|
}
|
|
242
|
+
const pending: DeepPendingChange[] = [];
|
|
243
|
+
if (target.access === true) {
|
|
244
|
+
try {
|
|
245
|
+
pending.push(...(await pendingGrants(target, options.entities)));
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const why = classifyClickHouseFailure(err);
|
|
248
|
+
for (const name of options.entityNames) {
|
|
249
|
+
if (options.entities.get(name)?.entityType !== CLICKHOUSE_ENTITY_TYPES.grant) continue;
|
|
250
|
+
unobserved[name] = { type: CLICKHOUSE_ENTITY_TYPES.grant, reason: "read-failed", detail: `the server's grants could not be compared: ${why.detail}` };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
230
254
|
for (const name of options.entityNames) {
|
|
231
255
|
const entity = options.entities.get(name);
|
|
232
256
|
if (!entity || !entity.entityType.startsWith("ClickHouse::")) {
|
|
233
257
|
unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
|
|
234
258
|
continue;
|
|
235
259
|
}
|
|
260
|
+
if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.entityType)) {
|
|
261
|
+
unobserved[name] = { type: entity.entityType, reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL };
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
236
264
|
if (entity.entityType === CLICKHOUSE_ENTITY_TYPES.grant) {
|
|
265
|
+
if (unobserved[name]) continue;
|
|
237
266
|
unobserved[name] = {
|
|
238
267
|
type: entity.entityType,
|
|
239
268
|
reason: "unsupported-kind",
|
|
240
|
-
detail: "a grant is compared as part of its grantees' grants
|
|
269
|
+
detail: "a grant is compared as part of its grantees' grants: what an apply would grant or revoke is listed as pending (SQLCH273, SQLCH274)",
|
|
241
270
|
};
|
|
242
271
|
continue;
|
|
243
272
|
}
|
|
@@ -280,5 +309,40 @@ export async function observeResourcesDeep(
|
|
|
280
309
|
unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
|
|
281
310
|
}
|
|
282
311
|
}
|
|
283
|
-
return deepObservation(resources, unobserved);
|
|
312
|
+
return deepObservation(resources, unobserved, pending);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The grants an apply would give or take from each declared grantee (#3733),
|
|
317
|
+
* as `chant sql plan` compares them: the declared grants against what
|
|
318
|
+
* `SHOW GRANTS FOR` prints, atom by atom. One change per grantee, its
|
|
319
|
+
* REVOKEs before its GRANTs as the applier sends them, with the grant
|
|
320
|
+
* declarations behind it. A grantee the server does not hold yet is given
|
|
321
|
+
* every declared grant.
|
|
322
|
+
*/
|
|
323
|
+
export async function pendingGrants(
|
|
324
|
+
target: ClickHouseTarget,
|
|
325
|
+
entities: ReadonlyMap<string, { entityType: string; props: Record<string, unknown> }>,
|
|
326
|
+
): Promise<DeepPendingChange[]> {
|
|
327
|
+
const declared = grantsByGrantee([...entities].map(([name, e]) => ({ export: name, type: e.entityType, ddl: String(e.props.ddl ?? "") })));
|
|
328
|
+
if (declared.length === 0) return [];
|
|
329
|
+
const live = await readLiveAccess(target, declared.map((g) => ({ kind: "grants", name: g.grantee })));
|
|
330
|
+
const held = new Map(live.filter((o) => o.type === CLICKHOUSE_ENTITY_TYPES.grant).map((o) => [o.name, grantsObject(o.statement, o.name, target.defaultDatabase).access ?? {}]));
|
|
331
|
+
const atom = (json: string) => JSON.parse(json) as GrantAtom;
|
|
332
|
+
const out: DeepPendingChange[] = [];
|
|
333
|
+
for (const g of declared) {
|
|
334
|
+
const want = grantsObject(g.ddl, g.grantee, target.defaultDatabase).access ?? {};
|
|
335
|
+
const have = held.get(g.grantee) ?? {};
|
|
336
|
+
const statements = [
|
|
337
|
+
...Object.keys(have)
|
|
338
|
+
.filter((k) => !(k in want))
|
|
339
|
+
.map((k) => revokeStatement(atom(have[k]!), g.grantee)),
|
|
340
|
+
...Object.keys(want)
|
|
341
|
+
.filter((k) => !(k in have))
|
|
342
|
+
.map((k) => grantStatement(atom(want[k]!), g.grantee)),
|
|
343
|
+
];
|
|
344
|
+
if (statements.length === 0) continue;
|
|
345
|
+
out.push({ subject: `grants ${g.grantee}`, change: statements.join("; "), entities: [...g.exports].sort() });
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
284
348
|
}
|
|
@@ -259,7 +259,10 @@ export function diffSchemas(before: readonly SchemaObject[], after: readonly Sch
|
|
|
259
259
|
for (const m of matches) {
|
|
260
260
|
if (m.kind === "created") changes.push(change(m.after.key, "object", "SQLCH200", undefined, qualified(m.after.canonical)));
|
|
261
261
|
else if (m.kind === "matched") diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints);
|
|
262
|
-
else {
|
|
262
|
+
else if (m.before.canonical.foreign) {
|
|
263
|
+
// A migration runner's history table (#3676): not chant's to drop.
|
|
264
|
+
hints.push(`${qualified(m.before.canonical)} is kept by ${m.before.canonical.foreign}; it is not chant's to drop and is left alone`);
|
|
265
|
+
} else {
|
|
263
266
|
const b = m.before;
|
|
264
267
|
changes.push(change(b.key, "object", "SQLCH250", qualified(b.canonical), undefined, { destructive: b.canonical.kind === "table" || b.canonical.kind === "materializedView" }));
|
|
265
268
|
}
|
|
@@ -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
|
+
});
|