@intentius/chant-lexicon-sql 0.113.0 → 0.114.1
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 +6 -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/live/bind.d.ts +11 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +7 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +4 -3
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +17 -0
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +4 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +5 -3
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +41 -18
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts +12 -0
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -0
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/statements.d.ts +2 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +8 -0
- package/dist/migration-statements.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +10 -3
- package/src/clickhouse/apply/statements.ts +19 -0
- package/src/clickhouse/live/bind.ts +13 -1
- package/src/clickhouse/live/live.test.ts +10 -0
- package/src/clickhouse/plan/commands.ts +42 -12
- package/src/clickhouse/plan/deep.ts +2 -1
- package/src/clickhouse/plan/diff.ts +3 -1
- package/src/clickhouse/plan/normalize.ts +11 -2
- package/src/clickhouse/plan/schema.ts +6 -5
- package/src/clickhouse/rebuild/backfill.ts +127 -5
- package/src/clickhouse/rebuild/observe.ts +4 -3
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
- package/src/clickhouse/rebuild/receipts.ts +14 -4
- package/src/clickhouse/rebuild/steps.ts +22 -9
- package/src/clickhouse/rebuild/verify.ts +131 -36
- package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
- package/src/clickhouse/topology.e2e.test.ts +84 -1
- package/src/clickhouse/topology.test.ts +33 -0
- package/src/clickhouse/topology.ts +26 -0
- package/src/clickhouse.ts +16 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/config.ts +33 -0
- package/src/core/statements.ts +2 -0
- package/src/migration-statements.test.ts +120 -1
- package/src/migration-statements.ts +13 -3
- package/src/op/activities/clickhouse-apply.ts +1 -1
- package/src/op/activities/clickhouse-rebuild.ts +1 -1
- package/src/op/apply-gate-create.e2e.test.ts +280 -0
- package/src/plugin.test.ts +9 -0
- package/src/postgres/plan/deep.test.ts +76 -0
- package/src/postgres/plan/deep.ts +13 -2
- package/src/postgres/plan/plan.e2e.test.ts +26 -0
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "82beced7beffe2bdd0dcf24728910539758d9d4dc85c94fe6418167bcbea9636",
|
|
5
5
|
"meta.json": "5b7a5d171aa8bd21c0d55cfbe421143d67da02b7ef924a9b66166790c3fc7b22",
|
|
6
6
|
"types/index.d.ts": "0286ebb367773e84c76818b0f69cb14cff8328fd2f8c65ed87563c8d5815458c",
|
|
7
7
|
"rules/postgres-templates.ts": "f7244417426c441180ccb5c52da32cd812960d6d4eaba1fbdb17f47fcae707b2",
|
|
@@ -57,5 +57,5 @@
|
|
|
57
57
|
"rules/sqlpg117.ts": "3d5c2d57ccdee3cf91c3ba3cb6363750787a850df8ebd75e9cc3f33113303827",
|
|
58
58
|
"rules/sqlpg118.ts": "e2a40a88a9374c4d99d02118ca4c60877ba0c6eb1ce603a93b24f5fd23d81dde"
|
|
59
59
|
},
|
|
60
|
-
"composite": "
|
|
60
|
+
"composite": "619bc77b9a85523857c04bc2fd6ab9a538a97bca5c6824aadf06c3b58a517b76"
|
|
61
61
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
33
33
|
import { type SchemaStatements } from "./core/statements.js";
|
|
34
|
+
import { type Topology } from "./clickhouse/topology.js";
|
|
34
35
|
export type { ManualStep, MigrationStep, OpStep, SchemaStatements, StatementChange, StatementStep, StepObject } from "./core/statements.js";
|
|
35
36
|
export { renderStatements } from "./core/statements.js";
|
|
36
37
|
export interface DiffStatementsOptions {
|
|
@@ -40,6 +41,13 @@ export interface DiffStatementsOptions {
|
|
|
40
41
|
env?: string;
|
|
41
42
|
/** ClickHouse: the database a bare name means. Default: `default`. */
|
|
42
43
|
defaultDatabase?: string;
|
|
44
|
+
/**
|
|
45
|
+
* ClickHouse: the topology to render the statements for
|
|
46
|
+
* (`./clickhouse/topology.ts`): `ON CLUSTER` and the engine, on both
|
|
47
|
+
* builds' declarations before they are compared, so the steps are the ones
|
|
48
|
+
* the applier sends to an environment with that topology. Default: `single`.
|
|
49
|
+
*/
|
|
50
|
+
topology?: Topology;
|
|
43
51
|
/** Postgres: the schema a bare name means. Default: `public`. */
|
|
44
52
|
defaultSchema?: string;
|
|
45
53
|
/** Postgres: the major to classify for. Default: the newer build's `postgresMajor`, else the older's, else the newest pinned. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migration-statements.d.ts","sourceRoot":"","sources":["../src/migration-statements.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAwD,KAAK,gBAAgB,EAAmB,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"migration-statements.d.ts","sourceRoot":"","sources":["../src/migration-statements.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAwD,KAAK,gBAAgB,EAAmB,MAAM,mBAAmB,CAAC;AAGjI,OAAO,EAAiB,KAAK,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAWrE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACzI,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,qBAAqB;IACpC,sIAAsI;IACtI,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,iEAAiE;IACjE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iIAAiI;IACjI,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;AAWlC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,GAAE,qBAA0B,GAAG,gBAAgB,CAQ3H"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deep.d.ts","sourceRoot":"","sources":["../../../src/postgres/plan/deep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,qBAAqB,EAAgC,MAAM,mCAAmC,CAAC;AAE9H,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI7E,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErG,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAcrC,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAGhD;AA6CD,uGAAuG;AACvG,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,iBAAiB,EAAE,CAAC,EAAE,iBAAiB,GAAG,KAAK,
|
|
1
|
+
{"version":3,"file":"deep.d.ts","sourceRoot":"","sources":["../../../src/postgres/plan/deep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,qBAAqB,EAAgC,MAAM,mCAAmC,CAAC;AAE9H,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI7E,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErG,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAcrC,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAGhD;AA6CD,uGAAuG;AACvG,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,iBAAiB,EAAE,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAmDpH;AAED,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,CAwChC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.1",
|
|
4
4
|
"description": "SQL lexicon for chant — database schema as typed declarations, one dialect at a time; ClickHouse first, with types read from a pinned server's catalog",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"typescript": "^5.9.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@intentius/chant": "^0.
|
|
73
|
+
"@intentius/chant": "^0.114.1",
|
|
74
74
|
"typescript": "^5.9.3"
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -51,6 +51,7 @@ import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities"
|
|
|
51
51
|
import { carriesMarker, isChantManaged } from "../ownership";
|
|
52
52
|
import { planStatements, sqlString, type DeclaredObject, type DropStatement, type Step } from "./statements";
|
|
53
53
|
import { waitForMutations } from "./mutations";
|
|
54
|
+
import { renderFor, type Topology } from "../topology";
|
|
54
55
|
|
|
55
56
|
export type { FailedObject } from "../../core/apply";
|
|
56
57
|
|
|
@@ -58,15 +59,20 @@ export type { FailedObject } from "../../core/apply";
|
|
|
58
59
|
* The objects a `chant build` output declares, in creation order. Takes the
|
|
59
60
|
* sql lexicon's primary output, or a multi-lexicon output holding it under
|
|
60
61
|
* `sql` (the shared core's reader).
|
|
62
|
+
*
|
|
63
|
+
* With a `topology`, each declaration is rendered for it first
|
|
64
|
+
* (`../topology.ts`): the engine and `ON CLUSTER` it runs with there, which
|
|
65
|
+
* is what the changes compare and every `CREATE` sends.
|
|
61
66
|
*/
|
|
62
|
-
export function declaredObjects(json: string, defaultDatabase = "default"): DeclaredObject[] {
|
|
67
|
+
export function declaredObjects(json: string, defaultDatabase = "default", topology?: Topology): DeclaredObject[] {
|
|
63
68
|
return readBuildObjects(json, "clickhouse", (o) => {
|
|
64
|
-
const
|
|
69
|
+
const ddl = renderFor(o.ddl, topology);
|
|
70
|
+
const canonical = canonicalObject(ddl, defaultDatabase);
|
|
65
71
|
return {
|
|
66
72
|
exportName: o.export,
|
|
67
73
|
type: o.type as ClickHouseEntityType,
|
|
68
74
|
key: canonical.database !== undefined ? `${canonical.database}.${canonical.name}` : canonical.name,
|
|
69
|
-
ddl
|
|
75
|
+
ddl,
|
|
70
76
|
canonical,
|
|
71
77
|
dependsOn: o.dependsOn,
|
|
72
78
|
};
|
|
@@ -165,6 +171,7 @@ export async function applyClickHouse(target: ClickHouseTarget, declared: readon
|
|
|
165
171
|
current: live,
|
|
166
172
|
allowDestructive: opts.prune === true,
|
|
167
173
|
carriesMarker: (key) => carriesMarker(liveByKey.get(key)?.comment, opts.marker),
|
|
174
|
+
...(target.topology ? { topology: target.topology } : {}),
|
|
168
175
|
...(opts.marker ? { marker: opts.marker } : {}),
|
|
169
176
|
});
|
|
170
177
|
|
|
@@ -23,6 +23,7 @@ import type { Change } from "../plan/diff";
|
|
|
23
23
|
import { CLASSIFIER_RULES, type ChangeClass, type ClassifierRuleId } from "../plan/rules";
|
|
24
24
|
import { stampedComment } from "../ownership";
|
|
25
25
|
import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
|
|
26
|
+
import { renderFor, type Topology } from "../topology";
|
|
26
27
|
|
|
27
28
|
/** A backquoted identifier. */
|
|
28
29
|
export const ident = (name: string): string => `\`${name.replace(/`/g, "``")}\``;
|
|
@@ -442,6 +443,13 @@ export interface StatementPlanInput {
|
|
|
442
443
|
allowDestructive?: boolean;
|
|
443
444
|
/** Whether the current object (by its key in `current`) carries the marker already; one that does not is restamped. Default: it does. */
|
|
444
445
|
carriesMarker?: (currentKey: string) => boolean;
|
|
446
|
+
/**
|
|
447
|
+
* The topology every statement is rendered for (`../topology.ts`): `ON
|
|
448
|
+
* CLUSTER` and the engine. The declarations should be rendered for it too
|
|
449
|
+
* (`declaredObjects(json, db, topology)`), so the changes compare the
|
|
450
|
+
* engine the topology runs. Default: the statements as declared.
|
|
451
|
+
*/
|
|
452
|
+
topology?: Topology;
|
|
445
453
|
}
|
|
446
454
|
|
|
447
455
|
export interface StatementPlan {
|
|
@@ -463,6 +471,17 @@ const DROP_ORDER: Record<string, number> = { materializedView: 0, view: 1, table
|
|
|
463
471
|
* both see it.
|
|
464
472
|
*/
|
|
465
473
|
export function planStatements(input: StatementPlanInput): StatementPlan {
|
|
474
|
+
const plan = planDeclared(input);
|
|
475
|
+
if (!input.topology) return plan;
|
|
476
|
+
const topology = input.topology;
|
|
477
|
+
const render = (step: Step): Step => ({ ...step, sql: renderFor(step.sql, topology) });
|
|
478
|
+
return {
|
|
479
|
+
objects: plan.objects.map((o) => (o.verdict === "create" || o.verdict === "alter" ? { ...o, steps: o.steps.map(render) } : o)),
|
|
480
|
+
drops: plan.drops.map((d) => ({ ...d, step: render(d.step) })),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function planDeclared(input: StatementPlanInput): StatementPlan {
|
|
466
485
|
const keyOf = input.keyOf ?? ((o: DeclaredObject) => o.key);
|
|
467
486
|
const byObject = new Map<string, Change[]>();
|
|
468
487
|
for (const c of input.changes) byObject.set(c.object, [...(byObject.get(c.object) ?? []), c]);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ChantConfig } from "@intentius/chant/config";
|
|
8
8
|
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
9
9
|
import { ClickHouseQueryError, clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
10
|
+
import { parseTopology, toTopology, type Topology } from "../topology";
|
|
10
11
|
|
|
11
12
|
export interface ClickHouseTarget {
|
|
12
13
|
endpoint: ClickHouseEndpoint;
|
|
@@ -15,6 +16,13 @@ export interface ClickHouseTarget {
|
|
|
15
16
|
/** The databases in scope; undefined is every database but the server's own. */
|
|
16
17
|
databases?: string[];
|
|
17
18
|
defaultDatabase: string;
|
|
19
|
+
/**
|
|
20
|
+
* The topology the environment runs (`sql.profiles.<env>.topology`, or
|
|
21
|
+
* `CLICKHOUSE_TOPOLOGY`): what the applier, the plan and the rebuild
|
|
22
|
+
* render their statements for (`../topology.ts`). Undefined: statements
|
|
23
|
+
* as declared.
|
|
24
|
+
*/
|
|
25
|
+
topology?: Topology;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export interface UnresolvedTarget {
|
|
@@ -29,7 +37,9 @@ export const isUnresolvedTarget = (v: ClickHouseTarget | UnresolvedTarget): v is
|
|
|
29
37
|
*
|
|
30
38
|
* 1. `sql.profiles.<environment>`. A profile naming a credential variable that
|
|
31
39
|
* is not set is `no-credentials`: the profile said how to authenticate.
|
|
32
|
-
* 2. `CLICKHOUSE_URL`, with `CLICKHOUSE_USER` and `CLICKHOUSE_PASSWORD` when set
|
|
40
|
+
* 2. `CLICKHOUSE_URL`, with `CLICKHOUSE_USER` and `CLICKHOUSE_PASSWORD` when set,
|
|
41
|
+
* and `CLICKHOUSE_TOPOLOGY` (`single`, `cluster:<name>`, `replicated`,
|
|
42
|
+
* `cloud`).
|
|
33
43
|
* 3. Otherwise `no-binding`.
|
|
34
44
|
*/
|
|
35
45
|
export function resolveClickHouseTarget(input: {
|
|
@@ -54,6 +64,7 @@ export function resolveClickHouseTarget(input: {
|
|
|
54
64
|
source,
|
|
55
65
|
...(profile.databases ? { databases: profile.databases } : {}),
|
|
56
66
|
defaultDatabase: profile.defaultDatabase ?? "default",
|
|
67
|
+
...(profile.topology !== undefined ? { topology: toTopology(profile.topology) } : {}),
|
|
57
68
|
};
|
|
58
69
|
}
|
|
59
70
|
const url = env.CLICKHOUSE_URL;
|
|
@@ -69,6 +80,7 @@ export function resolveClickHouseTarget(input: {
|
|
|
69
80
|
},
|
|
70
81
|
source: "env CLICKHOUSE_URL",
|
|
71
82
|
defaultDatabase: "default",
|
|
83
|
+
...(env.CLICKHOUSE_TOPOLOGY ? { topology: parseTopology(env.CLICKHOUSE_TOPOLOGY) } : {}),
|
|
72
84
|
};
|
|
73
85
|
}
|
|
74
86
|
|
|
@@ -65,6 +65,16 @@ describe("binding an environment to a server", () => {
|
|
|
65
65
|
expect(isUnresolvedTarget(t) && t.reason).toBe("no-credentials");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("the topology comes from the profile, or CLICKHOUSE_TOPOLOGY, and is undefined when neither names one (#3645)", () => {
|
|
69
|
+
const profile = (topology: unknown) =>
|
|
70
|
+
resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123", topology } } } } as never, env: {} });
|
|
71
|
+
expect(profile("cluster:main")).toMatchObject({ topology: { kind: "cluster", cluster: "main" } });
|
|
72
|
+
expect(profile({ kind: "replicated", cluster: "all" })).toMatchObject({ topology: { kind: "replicated", cluster: "all" } });
|
|
73
|
+
expect(resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123", CLICKHOUSE_TOPOLOGY: "cloud" } })).toMatchObject({ topology: { kind: "cloud" } });
|
|
74
|
+
expect("topology" in resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123" } })).toBe(false);
|
|
75
|
+
expect(() => resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123", CLICKHOUSE_TOPOLOGY: "sharded" } })).toThrow(/unknown topology/);
|
|
76
|
+
});
|
|
77
|
+
|
|
68
78
|
test("no profile and no CLICKHOUSE_URL is no-binding", () => {
|
|
69
79
|
const t = resolveClickHouseTarget({ environment: "dev", config: {}, env: {} });
|
|
70
80
|
expect(isUnresolvedTarget(t) && t.reason).toBe("no-binding");
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* says `"dialect": "postgres"`) or a Postgres binding goes to
|
|
5
5
|
* `../../postgres/plan/commands.ts`.
|
|
6
6
|
*
|
|
7
|
-
* chant sql diff <before.json> <after.json> [--json] [--statements]
|
|
7
|
+
* chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]
|
|
8
8
|
* chant sql plan <env> <build.json> [--json]
|
|
9
9
|
*
|
|
10
10
|
* `diff` is offline: it compares two `chant build` outputs, say a pull
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* each one the applier would send, with its change's rule and class, and a
|
|
20
20
|
* refused change as the Op that makes it (`../../migration-statements.ts`).
|
|
21
21
|
* The marker in each `CREATE` is the project's `ownership` config's.
|
|
22
|
+
*
|
|
23
|
+
* `--topology` (`single`, `cluster:<name>`, `replicated`, `replicated:<cluster>`,
|
|
24
|
+
* `cloud`; `../topology.ts`) renders a ClickHouse diff's declarations and
|
|
25
|
+
* statements for that topology: `ON CLUSTER` and the engine. `--statements`
|
|
26
|
+
* renders for `single` without it; the plain diff compares the declarations as
|
|
27
|
+
* written. `plan` takes the topology from `sql.profiles.<env>.topology`.
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
|
|
@@ -29,6 +35,7 @@ import { dropFormattingOnly } from "./server-format";
|
|
|
29
35
|
import { bindClickHouse } from "../live/bind";
|
|
30
36
|
import { rebuildOpSuggestions } from "./rebuild-handoff";
|
|
31
37
|
import { readFileSync } from "node:fs";
|
|
38
|
+
import { parseTopology, type Topology } from "../topology";
|
|
32
39
|
|
|
33
40
|
/** The dialect a build output names. */
|
|
34
41
|
function outputDialect(path: string): string | undefined {
|
|
@@ -39,12 +46,28 @@ function outputDialect(path: string): string | undefined {
|
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
interface DiffArgs {
|
|
50
|
+
positional: string[];
|
|
51
|
+
json: boolean;
|
|
52
|
+
statements: boolean;
|
|
53
|
+
/** `--topology <t>` or `--topology=<t>`, as written. */
|
|
54
|
+
topology?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function split(args: string[]): DiffArgs {
|
|
58
|
+
const positional: string[] = [];
|
|
59
|
+
let topology: string | undefined;
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const a = args[i]!;
|
|
62
|
+
if (a === "--topology") topology = args[++i] ?? "";
|
|
63
|
+
else if (a.startsWith("--topology=")) topology = a.slice("--topology=".length);
|
|
64
|
+
else if (!a.startsWith("--")) positional.push(a);
|
|
65
|
+
}
|
|
66
|
+
return { positional, json: args.includes("--json"), statements: args.includes("--statements"), ...(topology !== undefined ? { topology } : {}) };
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
/** `chant sql diff --statements`: the statements between two builds, as SQL with comments or as JSON. Exits 2 when a step is an Op or manual. */
|
|
47
|
-
async function runDiffStatements(before: string, after: string, json: boolean): Promise<number> {
|
|
70
|
+
async function runDiffStatements(before: string, after: string, json: boolean, topology: Topology | undefined): Promise<number> {
|
|
48
71
|
const { diffStatements, renderStatements } = await import("../../migration-statements");
|
|
49
72
|
const { resolveOwnershipMarker } = await import("../../core/apply");
|
|
50
73
|
let marker;
|
|
@@ -56,7 +79,7 @@ async function runDiffStatements(before: string, after: string, json: boolean):
|
|
|
56
79
|
console.error(err instanceof Error ? err.message : String(err));
|
|
57
80
|
return 1;
|
|
58
81
|
}
|
|
59
|
-
const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), marker ? { marker } : {});
|
|
82
|
+
const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), { ...(marker ? { marker } : {}), ...(topology ? { topology } : {}) });
|
|
60
83
|
console.log(json ? JSON.stringify(doc, null, 2) : renderStatements(doc));
|
|
61
84
|
return doc.refused ? 2 : 0;
|
|
62
85
|
}
|
|
@@ -67,18 +90,25 @@ function emit(diff: SchemaDiff, json: boolean, title: string): number {
|
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
export async function runDiff(ctx: CommandGroupContext): Promise<number> {
|
|
70
|
-
const { positional, json, statements } = split(ctx.rawArgs);
|
|
93
|
+
const { positional, json, statements, topology: topologyArg } = split(ctx.rawArgs);
|
|
71
94
|
if (positional.length !== 2) {
|
|
72
|
-
console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements]");
|
|
95
|
+
console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]");
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
let topology: Topology | undefined;
|
|
99
|
+
try {
|
|
100
|
+
topology = topologyArg !== undefined ? parseTopology(topologyArg) : undefined;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`--topology: ${(err as Error).message}`);
|
|
73
103
|
return 1;
|
|
74
104
|
}
|
|
75
|
-
if (statements) return runDiffStatements(positional[0]!, positional[1]!, json);
|
|
105
|
+
if (statements) return runDiffStatements(positional[0]!, positional[1]!, json, topology);
|
|
76
106
|
if (outputDialect(positional[1]!) === "postgres") {
|
|
77
107
|
const { diffPgBuildFiles, emitPg, projectMajor } = await import("../../postgres/plan/commands");
|
|
78
108
|
return emitPg(diffPgBuildFiles(positional[0]!, positional[1]!, await projectMajor()), json, `${positional[0]} -> ${positional[1]}`);
|
|
79
109
|
}
|
|
80
|
-
const after = schemaFromBuildFile(positional[1]
|
|
81
|
-
const diff = diffSchemas(schemaFromBuildFile(positional[0]
|
|
110
|
+
const after = schemaFromBuildFile(positional[1]!, "default", topology);
|
|
111
|
+
const diff = diffSchemas(schemaFromBuildFile(positional[0]!, "default", topology), after);
|
|
82
112
|
const rebuildOps = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "<env>");
|
|
83
113
|
return emit(rebuildOps.length > 0 ? { ...diff, rebuildOps } : diff, json, `${positional[0]} -> ${positional[1]}`);
|
|
84
114
|
}
|
|
@@ -86,7 +116,7 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
|
|
|
86
116
|
/** The declared objects against the server, keys and labels by `database.name`. */
|
|
87
117
|
export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
|
|
88
118
|
const target = await bindClickHouse({ ...options, environment });
|
|
89
|
-
const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase));
|
|
119
|
+
const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
|
|
90
120
|
const databases = new Set(declared.map((o) => o.canonical.database ?? o.canonical.name));
|
|
91
121
|
const live = await schemaFromServer(target, databases);
|
|
92
122
|
const diff = diffSchemas(live, declared);
|
|
@@ -119,7 +149,7 @@ export const sqlCommands: CommandGroup = {
|
|
|
119
149
|
name: "sql",
|
|
120
150
|
description: "Schema changes, classified: between two builds, or a build and a live server",
|
|
121
151
|
commands: [
|
|
122
|
-
{ name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes", handler: runDiff },
|
|
152
|
+
{ name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes, --topology renders them for single, cluster:<name>, replicated or cloud", handler: runDiff },
|
|
123
153
|
{ name: "plan", description: "Classify the change from an environment's server to a chant build output", handler: runPlan },
|
|
124
154
|
],
|
|
125
155
|
};
|
|
@@ -20,6 +20,7 @@ import { bindClickHouse, classifyClickHouseFailure, type BindOptions } from "../
|
|
|
20
20
|
import { readLiveSchema, type LiveObject } from "../live/catalog";
|
|
21
21
|
import { database, table, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
22
22
|
import { canonicalObject, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
23
|
+
import { renderFor } from "../topology";
|
|
23
24
|
|
|
24
25
|
type Props = Record<string, unknown>;
|
|
25
26
|
|
|
@@ -109,7 +110,7 @@ export async function observeResourcesDeep(
|
|
|
109
110
|
const o = byKey.get(`${db ?? ""}.${String(props.name)}`);
|
|
110
111
|
if (!o) continue;
|
|
111
112
|
try {
|
|
112
|
-
const d = canonicalObject(String(props.ddl), target.defaultDatabase);
|
|
113
|
+
const d = canonicalObject(renderFor(String(props.ddl), target.topology), target.defaultDatabase);
|
|
113
114
|
const l = canonicalObject(o.statement, target.defaultDatabase);
|
|
114
115
|
resources[name] = {
|
|
115
116
|
type: o.type,
|
|
@@ -127,7 +127,9 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
|
|
|
127
127
|
if (before.comment !== after.comment) out.push(change(key, "comment", "SQLCH203", before.comment, after.comment));
|
|
128
128
|
|
|
129
129
|
if (before.kind === "database") {
|
|
130
|
-
|
|
130
|
+
// A declaration with no ENGINE takes the server's default database engine
|
|
131
|
+
// (Atomic; Replicated on Cloud), whatever the server says it is (#3645).
|
|
132
|
+
if (after.engine !== undefined && before.engine !== after.engine) out.push(change(key, "engine", "SQLCH232", before.engine, after.engine));
|
|
131
133
|
return;
|
|
132
134
|
}
|
|
133
135
|
|
|
@@ -275,8 +275,17 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
275
275
|
// `ENGINE = ReplicatedMergeTree` with no Keeper path and replica name is
|
|
276
276
|
// printed back with the server's defaults filled in; the two say the same.
|
|
277
277
|
if (/^Replicated.*MergeTree$/.test(engine.name) && args[0] === DEFAULT_REPLICA_PATH && args[1] === DEFAULT_REPLICA_NAME) args = args.slice(2);
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
// ClickHouse Cloud turns a declared MergeTree-family engine into its
|
|
279
|
+
// `Shared*` one and prints it back with the path and replica it chose
|
|
280
|
+
// (#3645, `../topology.ts`): the declaration said the plain family.
|
|
281
|
+
let name = engine.name;
|
|
282
|
+
const shared = /^Shared(\w*MergeTree)$/.exec(name);
|
|
283
|
+
if (shared) {
|
|
284
|
+
name = shared[1]!;
|
|
285
|
+
if (/^'.*'$/s.test(args[0] ?? "") && /^'.*'$/s.test(args[1] ?? "")) args = args.slice(2);
|
|
286
|
+
}
|
|
287
|
+
obj.engineName = name;
|
|
288
|
+
obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
|
|
280
289
|
}
|
|
281
290
|
const comment = stringValue(spanText(tokens, node.comment));
|
|
282
291
|
if (comment !== undefined && comment !== "") obj.comment = comment;
|
|
@@ -8,23 +8,24 @@ import { canonicalObject } from "./normalize";
|
|
|
8
8
|
import type { SchemaObject } from "./diff";
|
|
9
9
|
import type { ClickHouseTarget } from "../live/bind";
|
|
10
10
|
import { readLiveSchema } from "../live/catalog";
|
|
11
|
+
import { renderFor, type Topology } from "../topology";
|
|
11
12
|
|
|
12
13
|
interface OutputDoc {
|
|
13
14
|
dialect?: string;
|
|
14
15
|
objects?: Array<{ export: string; ddl: string }>;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
/** The objects a `chant build` output holds, keyed by export name. */
|
|
18
|
-
export function schemaFromBuildOutput(json: string, defaultDatabase = "default"): SchemaObject[] {
|
|
18
|
+
/** The objects a `chant build` output holds, keyed by export name; with a `topology`, each declaration as rendered for it (`../topology.ts`). */
|
|
19
|
+
export function schemaFromBuildOutput(json: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
|
|
19
20
|
const doc = JSON.parse(json) as OutputDoc;
|
|
20
21
|
if (doc.dialect !== "clickhouse" || !Array.isArray(doc.objects)) {
|
|
21
22
|
throw new Error("not a sql lexicon build output: expected { dialect: \"clickhouse\", objects: [...] }");
|
|
22
23
|
}
|
|
23
|
-
return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(o.ddl, defaultDatabase) }));
|
|
24
|
+
return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function schemaFromBuildFile(path: string, defaultDatabase = "default"): SchemaObject[] {
|
|
27
|
-
return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase);
|
|
27
|
+
export function schemaFromBuildFile(path: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
|
|
28
|
+
return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase, topology);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -41,6 +41,21 @@
|
|
|
41
41
|
* With a replica down (#3270) the kill goes on without it, and each sync
|
|
42
42
|
* either finds nothing that replica alone has and returns, or stops the step
|
|
43
43
|
* naming it (`./replicas.ts`).
|
|
44
|
+
*
|
|
45
|
+
* In materialized-view mode a partition is copied in two parts
|
|
46
|
+
* (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
|
|
47
|
+
* above. The rows at or after it are the view's only when they were written
|
|
48
|
+
* after the view was made; the ones the old table already held (a booking
|
|
49
|
+
* next month, an expiry date) reach the new table only through the backfill.
|
|
50
|
+
* The two kinds cannot be told apart by their values, so the second part
|
|
51
|
+
* copies the difference: for each distinct row at or after the cut-over, as
|
|
52
|
+
* many copies as the old table has more of it than the new one. That copy
|
|
53
|
+
* converges on its own, so a run killed during it, or a partition copied
|
|
54
|
+
* again, copies only what is still missing. A row the view is a moment from
|
|
55
|
+
* committing can be copied by both: the copy is followed by a check for rows
|
|
56
|
+
* the new table holds more often than the old one, which deletes and copies
|
|
57
|
+
* those again. Whatever is left, the verification finds, and the swap does
|
|
58
|
+
* not go ahead.
|
|
44
59
|
*/
|
|
45
60
|
|
|
46
61
|
import { EffectReceipt, receiptExpectation } from "@intentius/chant/effect-receipt";
|
|
@@ -50,6 +65,7 @@ import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } f
|
|
|
50
65
|
import { RebuildRefusal, type RebuildObservation } from "./observe";
|
|
51
66
|
import { DDL_SETTINGS } from "./replicas";
|
|
52
67
|
import { sourcePartitionExpression, sourcePartitions } from "./partitions";
|
|
68
|
+
import { rowHash } from "./verify";
|
|
53
69
|
import { cutoverOf, observe, serverNow, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
|
|
54
70
|
|
|
55
71
|
export interface BackfillResult {
|
|
@@ -62,6 +78,8 @@ export interface BackfillResult {
|
|
|
62
78
|
skipped: number;
|
|
63
79
|
/** Partitions this run found partly copied and cleared before copying again. */
|
|
64
80
|
cleared: number;
|
|
81
|
+
/** Distinct rows at or after the cut-over found in the new table too often (copied while the view was writing them), deleted and copied again. */
|
|
82
|
+
repaired: number;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/** The effect a partition's copy is, and its receipt's address suffix. */
|
|
@@ -74,7 +92,15 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
|
|
|
74
92
|
EffectReceipt(effect, {
|
|
75
93
|
effect,
|
|
76
94
|
flavor: "hash",
|
|
77
|
-
inputs: {
|
|
95
|
+
inputs: {
|
|
96
|
+
table: o.names.key,
|
|
97
|
+
newTable: o.newTable!.uuid,
|
|
98
|
+
partition,
|
|
99
|
+
copied: o.copied,
|
|
100
|
+
cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
|
|
101
|
+
// The rows at or after the cut-over the old table held are copied too (sql-yodeler#45); a receipt from before that copied fewer.
|
|
102
|
+
...(cutover === undefined ? {} : { after: "difference" }),
|
|
103
|
+
},
|
|
78
104
|
}),
|
|
79
105
|
);
|
|
80
106
|
}
|
|
@@ -88,7 +114,7 @@ export interface BackfillDeps {
|
|
|
88
114
|
|
|
89
115
|
export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
|
|
90
116
|
const o = await observe(run);
|
|
91
|
-
const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0 };
|
|
117
|
+
const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0, repaired: 0 };
|
|
92
118
|
if (o.state !== "rebuild") return result;
|
|
93
119
|
if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
|
|
94
120
|
const n = o.names;
|
|
@@ -113,6 +139,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
113
139
|
const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
|
|
114
140
|
const columns = o.copied.map((c) => ident(c.name)).join(", ");
|
|
115
141
|
const select = o.copied.map((c) => ident(c.source)).join(", ");
|
|
142
|
+
const later = cutColumn && cutover !== undefined ? await laterRowsCopy(run, o, cutColumn, cutover, fromOld) : undefined;
|
|
116
143
|
|
|
117
144
|
const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
|
|
118
145
|
const receipts =
|
|
@@ -120,6 +147,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
120
147
|
clickhouseReceiptStore(run.target.endpoint, identity, {
|
|
121
148
|
...(run.runId ? { runId: run.runId } : {}),
|
|
122
149
|
...(o.replicated ? { replicatedIn: n.database } : {}),
|
|
150
|
+
...(run.target.topology ? { topology: run.target.topology } : {}),
|
|
123
151
|
...(run.replicaTimeoutMs !== undefined ? { replicaTimeoutMs: run.replicaTimeoutMs } : {}),
|
|
124
152
|
});
|
|
125
153
|
const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
|
|
@@ -136,17 +164,19 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
136
164
|
}
|
|
137
165
|
|
|
138
166
|
const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
|
|
167
|
+
const laterQueryId = `${queryId}-after`;
|
|
168
|
+
const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
|
|
139
169
|
if (o.replicated) {
|
|
140
170
|
// On every replica that answers: the copy may be running on another one
|
|
141
171
|
// than this run's. A replica that is down is skipped once Keeper sees it
|
|
142
172
|
// inactive; nothing of its can be running a copy that still writes,
|
|
143
173
|
// and a part it wrote before going down is waited for by the sync below.
|
|
144
|
-
await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE
|
|
174
|
+
await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE ${killWhere} SYNC`, {
|
|
145
175
|
settings: DDL_SETTINGS,
|
|
146
176
|
});
|
|
147
177
|
await syncReplica(run, o, n.database, n.newName);
|
|
148
178
|
} else {
|
|
149
|
-
await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE
|
|
179
|
+
await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
|
|
150
180
|
}
|
|
151
181
|
const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
|
|
152
182
|
const [left] = await clickhouseQuery<{ n: string | number }>(run.target.endpoint, `SELECT count() AS n FROM ${n.newTable} WHERE ${mine}`);
|
|
@@ -162,15 +192,107 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
162
192
|
const sql = `INSERT INTO ${n.newTable} (${columns}) SELECT ${select} FROM ${n.table} WHERE _partition_id = ${sqlString(p)}${sourceRange}`;
|
|
163
193
|
run.log(sql);
|
|
164
194
|
await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
|
|
195
|
+
if (later) result.repaired += await copyAfterCutover(run, o, later, p, laterQueryId);
|
|
165
196
|
// The receipt, last, on success only.
|
|
166
197
|
await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
|
|
167
198
|
result.copied++;
|
|
168
199
|
await deps.afterPartition?.(p);
|
|
169
200
|
}
|
|
170
|
-
run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first`);
|
|
201
|
+
run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first${result.repaired > 0 ? `, ${result.repaired} row(s) after the cut-over copied twice and repaired` : ""}`);
|
|
171
202
|
return result;
|
|
172
203
|
}
|
|
173
204
|
|
|
205
|
+
/** The statements that copy one old partition's rows at or after the cut-over. */
|
|
206
|
+
interface LaterRows {
|
|
207
|
+
/**
|
|
208
|
+
* Per distinct row (by its hash), the old table's copies past the number
|
|
209
|
+
* the new table holds, into the new table.
|
|
210
|
+
*/
|
|
211
|
+
copy: (partition: string) => string;
|
|
212
|
+
/** The hashes of the rows the new table holds more copies of than the old one. */
|
|
213
|
+
extras: (partition: string) => string;
|
|
214
|
+
/** Delete every copy of those rows from the new table. */
|
|
215
|
+
drop: (partition: string, hashes: readonly string[]) => string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
|
|
219
|
+
const REPAIR_LIMIT = 1000;
|
|
220
|
+
const REPAIR_ROUNDS = 3;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The statements for the rows at or after the cut-over. The old side casts
|
|
224
|
+
* each column to its type in the new table before hashing, as the
|
|
225
|
+
* verification does, so a row and its copy hash the same.
|
|
226
|
+
*/
|
|
227
|
+
async function laterRowsCopy(
|
|
228
|
+
run: RebuildRun,
|
|
229
|
+
o: RebuildObservation,
|
|
230
|
+
cut: RebuildObservation["copied"][number],
|
|
231
|
+
cutover: number,
|
|
232
|
+
fromOld: string,
|
|
233
|
+
): Promise<LaterRows> {
|
|
234
|
+
const n = o.names;
|
|
235
|
+
const types = new Map(
|
|
236
|
+
(
|
|
237
|
+
await clickhouseQuery<{ name: string; type: string }>(
|
|
238
|
+
run.target.endpoint,
|
|
239
|
+
`SELECT name, type FROM system.columns WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)}`,
|
|
240
|
+
)
|
|
241
|
+
).map((c) => [c.name, c.type]),
|
|
242
|
+
);
|
|
243
|
+
const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
|
|
244
|
+
const newHash = rowHash(o.copied.map((c) => ident(c.name)));
|
|
245
|
+
const columns = o.copied.map((c) => ident(c.name)).join(", ");
|
|
246
|
+
const aliases = o.copied.map((_, i) => `__chant_c${i}`);
|
|
247
|
+
const fromOldSide = o.copied.map((c, i) => `${ident(c.source)} AS ${aliases[i]}`).join(", ");
|
|
248
|
+
const oldRows = (p: string) => `FROM ${n.table} WHERE _partition_id = ${sqlString(p)} AND ${ident(cut.source)} >= ${utcLiteral(cutover)}`;
|
|
249
|
+
const newRows = (p: string) => `FROM ${n.newTable} WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)}`;
|
|
250
|
+
return {
|
|
251
|
+
copy: (p) =>
|
|
252
|
+
`INSERT INTO ${n.newTable} (${columns}) SELECT ${aliases.join(", ")} FROM (` +
|
|
253
|
+
`SELECT ${fromOldSide}, ${oldHash} AS __chant_h, row_number() OVER (PARTITION BY __chant_h) AS __chant_k ${oldRows(p)}` +
|
|
254
|
+
`) AS old_rows LEFT JOIN (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
|
|
255
|
+
`USING (__chant_h) WHERE __chant_k > ifNull(__chant_n, 0)`,
|
|
256
|
+
extras: (p) =>
|
|
257
|
+
`SELECT toString(__chant_h) AS h FROM (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
|
|
258
|
+
`LEFT JOIN (SELECT ${oldHash} AS __chant_h, count() AS __chant_o ${oldRows(p)} GROUP BY __chant_h) AS old_rows ` +
|
|
259
|
+
`USING (__chant_h) WHERE __chant_n > ifNull(__chant_o, 0) LIMIT ${REPAIR_LIMIT + 1}`,
|
|
260
|
+
drop: (p, hashes) => `ALTER TABLE ${n.newTable} DELETE WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)} AND ${newHash} IN (${hashes.join(", ")})`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Copy one old partition's rows at or after the cut-over that the new table
|
|
266
|
+
* lacks. A row the view had written to the old table but not yet to the new
|
|
267
|
+
* one when the copy read them is copied by both, and is then in the new
|
|
268
|
+
* table once too often: every copy of such a row is deleted from the new
|
|
269
|
+
* table and the difference copied again, a few rounds at most. What is left
|
|
270
|
+
* after that, the verification finds. Returns the rows repaired.
|
|
271
|
+
*/
|
|
272
|
+
async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string): Promise<number> {
|
|
273
|
+
const n = o.names;
|
|
274
|
+
const insert = async () => {
|
|
275
|
+
const sql = later.copy(p);
|
|
276
|
+
run.log(sql);
|
|
277
|
+
await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
|
|
278
|
+
};
|
|
279
|
+
await insert();
|
|
280
|
+
let repaired = 0;
|
|
281
|
+
for (let round = 0; round < REPAIR_ROUNDS; round++) {
|
|
282
|
+
await syncReplica(run, o, n.database, n.newName);
|
|
283
|
+
const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p))).map((r) => r.h);
|
|
284
|
+
if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
|
|
285
|
+
run.log(`-- partition ${p}: ${extras.length} row(s) after the cut-over in the new table more often than in the old one; deleting them and copying again`);
|
|
286
|
+
const sql = later.drop(p, extras);
|
|
287
|
+
run.log(sql);
|
|
288
|
+
await clickhouseQuery(run.target.endpoint, sql);
|
|
289
|
+
await waitOn(run, n.database, n.newName);
|
|
290
|
+
repaired += extras.length;
|
|
291
|
+
await insert();
|
|
292
|
+
}
|
|
293
|
+
return repaired;
|
|
294
|
+
}
|
|
295
|
+
|
|
174
296
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
175
297
|
return new Promise((resolve, reject) => {
|
|
176
298
|
const t = setTimeout(resolve, ms);
|