@intentius/chant-lexicon-sql 0.114.0 → 0.115.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.
Files changed (77) hide show
  1. package/dist/clickhouse/apply/mutations.d.ts +7 -1
  2. package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
  3. package/dist/clickhouse/container.d.ts +22 -7
  4. package/dist/clickhouse/container.d.ts.map +1 -1
  5. package/dist/clickhouse/plan/deep.d.ts +5 -0
  6. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  8. package/dist/clickhouse/rebuild/backfill.d.ts +28 -5
  9. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  10. package/dist/clickhouse/rebuild/observe.d.ts +4 -3
  11. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  12. package/dist/clickhouse/rebuild/op.d.ts +29 -0
  13. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
  15. package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
  17. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  18. package/dist/clickhouse/rebuild/shards.d.ts +59 -0
  19. package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
  20. package/dist/clickhouse/rebuild/steps.d.ts +8 -3
  21. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/verify.d.ts +45 -18
  23. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  24. package/dist/config.d.ts +14 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/core/receipts.d.ts +5 -3
  27. package/dist/core/receipts.d.ts.map +1 -1
  28. package/dist/integrity.json +2 -2
  29. package/dist/manifest.json +1 -1
  30. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +13 -1
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/op/activities/sql-exec.d.ts +41 -0
  34. package/dist/op/activities/sql-exec.d.ts.map +1 -0
  35. package/dist/op/activity-contracts.d.ts +20 -0
  36. package/dist/op/activity-contracts.d.ts.map +1 -1
  37. package/dist/postgres/live/catalog.d.ts +1 -1
  38. package/dist/postgres/live/catalog.d.ts.map +1 -1
  39. package/dist/postgres/migrate/receipts.d.ts +8 -5
  40. package/dist/postgres/migrate/receipts.d.ts.map +1 -1
  41. package/dist/postgres/plan/deep.d.ts.map +1 -1
  42. package/dist/receipts.d.ts +102 -0
  43. package/dist/receipts.d.ts.map +1 -0
  44. package/package.json +2 -2
  45. package/src/clickhouse/apply/mutations.ts +9 -3
  46. package/src/clickhouse/container.ts +39 -11
  47. package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
  48. package/src/clickhouse/plan/deep.ts +102 -3
  49. package/src/clickhouse/plan/normalize.ts +10 -0
  50. package/src/clickhouse/plan/plan.test.ts +10 -0
  51. package/src/clickhouse/rebuild/backfill.ts +190 -26
  52. package/src/clickhouse/rebuild/observe.ts +4 -3
  53. package/src/clickhouse/rebuild/op.ts +80 -35
  54. package/src/clickhouse/rebuild/partitions.ts +11 -0
  55. package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
  56. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +216 -5
  57. package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
  58. package/src/clickhouse/rebuild/receipts.ts +4 -6
  59. package/src/clickhouse/rebuild/shards.ts +104 -0
  60. package/src/clickhouse/rebuild/steps.ts +18 -4
  61. package/src/clickhouse/rebuild/verify.ts +140 -36
  62. package/src/config.ts +7 -0
  63. package/src/core/receipts.ts +5 -3
  64. package/src/op/activities/clickhouse-rebuild.ts +32 -7
  65. package/src/op/activities/index.ts +21 -4
  66. package/src/op/activities/sql-exec.ts +84 -0
  67. package/src/op/activity-contracts.ts +15 -0
  68. package/src/op/effect-receipts.e2e.test.ts +277 -0
  69. package/src/postgres/live/catalog.ts +3 -1
  70. package/src/postgres/migrate/migrate.test.ts +3 -2
  71. package/src/postgres/migrate/receipts.ts +18 -10
  72. package/src/postgres/plan/deep.test.ts +76 -0
  73. package/src/postgres/plan/deep.ts +13 -2
  74. package/src/postgres/plan/plan.e2e.test.ts +26 -0
  75. package/src/receipts.test.ts +91 -0
  76. package/src/receipts.ts +259 -0
  77. package/src/skills/chant-sql-rebuild.md +5 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Drift on a multi-shard cluster (#3664), against a scratch cluster of the
3
+ * pinned image: two shards (two replicas and one server) with Keeper and a
4
+ * cluster `chant` over all three, the profile naming the first server with
5
+ * topology `cluster:chant`.
6
+ *
7
+ * - The schema applied `ON CLUSTER`, with a `Distributed` table declared with
8
+ * unquoted identifiers, reads as no drift: the server's quoted arguments
9
+ * are the same arguments.
10
+ * - A TTL changed on shard 2's server alone, without `ON CLUSTER`, is drift,
11
+ * seen on that server, though the profile's server still holds the
12
+ * declaration.
13
+ *
14
+ * Runs the sql lexicon's deep reader and core's deep diff, as
15
+ * `chant lifecycle diff --live` does. Needs Docker; skips cleanly without it.
16
+ */
17
+
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { diffDeepObservation } from "@intentius/chant/lifecycle/deep-observe";
23
+ import { normalizeDeepObservation } from "@intentius/chant/deep-observation";
24
+ import { dockerAvailable, startScratchCluster, type ScratchCluster } from "../container";
25
+ import { clickhouseQuery } from "../http";
26
+ import { clickhouseImage } from "../../spec/pin";
27
+ import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
28
+ import { normalizeApply } from "@intentius/chant/apply";
29
+ import { database, table } from "../entities";
30
+ import { observeResourcesDeep, sqlDeepNormalizationHooks } from "./deep";
31
+
32
+ const enabled = await dockerAvailable();
33
+ const DB = "chant_e2e_3664";
34
+ let cluster: ScratchCluster | undefined;
35
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-cluster-drift-"));
36
+
37
+ const DB_DDL = `CREATE DATABASE ${DB}`;
38
+ const EVENTS_DDL = `CREATE TABLE ${DB}.events
39
+ (
40
+ id UInt64,
41
+ ts DateTime
42
+ )
43
+ ENGINE = MergeTree
44
+ ORDER BY id
45
+ TTL ts + INTERVAL 30 DAY`;
46
+ const EVENTS_ALL_DDL = `CREATE TABLE ${DB}.events_all
47
+ (
48
+ id UInt64,
49
+ ts DateTime
50
+ )
51
+ ENGINE = Distributed(chant, ${DB}, events, id)`;
52
+
53
+ const config = () => ({ ownership: { stack: "e2e", env: "test" }, sql: { profiles: { e2e: { url: cluster!.replicas[0]!.url, topology: "cluster:chant" } } } });
54
+ const tag = (fn: (strings: TemplateStringsArray) => unknown, ddl: string) => fn(Object.assign([ddl], { raw: [ddl] }) as unknown as TemplateStringsArray) as unknown as { entityType: string; props: Record<string, unknown> };
55
+
56
+ const ENTITIES = new Map([
57
+ ["db", tag(database, DB_DDL)],
58
+ ["events", tag(table, EVENTS_DDL)],
59
+ ["eventsAll", tag(table, EVENTS_ALL_DDL)],
60
+ ]);
61
+
62
+ async function drift() {
63
+ const entities = new Map([...ENTITIES].map(([k, v]) => [k, { entityType: v.entityType, props: v.props }]));
64
+ const live = await observeResourcesDeep({ environment: "e2e", entityNames: [...entities.keys()], entities, config: config(), env: {} });
65
+ return diffDeepObservation(entities, normalizeDeepObservation(live), sqlDeepNormalizationHooks).drifted;
66
+ }
67
+
68
+ beforeAll(async () => {
69
+ if (!enabled) return;
70
+ cluster = await startScratchCluster(clickhouseImage(), { shards: [2, 1], cluster: "chant", namePrefix: "chant-sql-cluster-drift" });
71
+ }, 600_000);
72
+
73
+ afterAll(async () => {
74
+ await cluster?.stop();
75
+ rmSync(dir, { recursive: true, force: true });
76
+ });
77
+
78
+ describe.skipIf(!enabled)("drift on a two-shard cluster (#3664)", () => {
79
+ test("applied ON CLUSTER, with unquoted Distributed arguments, it reads as no drift", async () => {
80
+ const build = join(dir, "schema.json");
81
+ writeFileSync(
82
+ build,
83
+ JSON.stringify({
84
+ dialect: "clickhouse",
85
+ applyOrder: ["db", "events", "eventsAll"],
86
+ objects: [
87
+ { export: "db", type: "ClickHouse::Database", ddl: DB_DDL, dependsOn: [] },
88
+ { export: "events", type: "ClickHouse::Table", ddl: EVENTS_DDL, dependsOn: ["db"] },
89
+ { export: "eventsAll", type: "ClickHouse::Table", ddl: EVENTS_ALL_DDL, dependsOn: ["events"] },
90
+ ],
91
+ }),
92
+ );
93
+ const applied = normalizeApply(toApplyResult(await clickhouseApply({ buildPath: build, environment: "e2e" }, undefined, { config: config(), env: {}, log: () => undefined })));
94
+ expect(applied.notAttempted).toEqual([]);
95
+ // Every server has both tables.
96
+ for (const endpoint of cluster!.replicas) {
97
+ const rows = await clickhouseQuery<{ name: string }>(endpoint, `SELECT name FROM system.tables WHERE database = '${DB}' ORDER BY name`);
98
+ expect(rows.map((r) => r.name)).toEqual(["events", "events_all"]);
99
+ }
100
+ expect(await drift()).toEqual([]);
101
+ }, 300_000);
102
+
103
+ test("a TTL changed on shard 2 alone is drift, seen on that server", async () => {
104
+ const shard2 = cluster!.layout.findIndex((l) => l.shard === "s2");
105
+ await clickhouseQuery(cluster!.replicas[shard2]!, `ALTER TABLE ${DB}.events MODIFY TTL ts + INTERVAL 7 DAY`);
106
+ // The profile's server still holds the declaration.
107
+ const [mine] = await clickhouseQuery<{ q: string }>(cluster!.replicas[0]!, `SELECT create_table_query AS q FROM system.tables WHERE database = '${DB}' AND name = 'events'`);
108
+ expect(mine!.q).toContain("toIntervalDay(30)");
109
+
110
+ const drifted = await drift();
111
+ expect(drifted.map((d) => d.name)).toEqual(["events"]);
112
+ const ttl = drifted[0]!.changes.find((c) => c.path.startsWith("ttl"));
113
+ expect(ttl, JSON.stringify(drifted[0]!.changes)).toBeDefined();
114
+ expect(String(ttl!.live)).toMatch(/7/);
115
+ expect(ttl!.seenOn).toBe(cluster!.layout[shard2]!.host);
116
+ }, 300_000);
117
+ });
@@ -12,12 +12,20 @@
12
12
  * `deepNormalizationHooks` prunes what is not a property of the object (the
13
13
  * DDL text, the template source, lineage, a view's reads and `TO` target).
14
14
  * `chant sql plan` covers the target, and classifies every change.
15
+ *
16
+ * On a `cluster:<name>` topology every replica of every shard is read
17
+ * (#3664). When a server's copy of a table differs from the profile
18
+ * server's, that copy is what is reported, with the server in `observedOn`,
19
+ * which the drift report prints as `seen on`.
15
20
  */
16
21
 
17
22
  import { deepObservation, type DeepObservationResult, type DeepResourceObservation, type DeepNormalizationHooks } from "@intentius/chant/deep-observation";
18
23
  import type { UnobservedEntity } from "@intentius/chant/lexicon";
19
- import { bindClickHouse, classifyClickHouseFailure, type BindOptions } from "../live/bind";
20
- import { readLiveSchema, type LiveObject } from "../live/catalog";
24
+ import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "../live/bind";
25
+ import { readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
26
+ import { clickhouseQuery } from "../http";
27
+ import { sqlString } from "../apply/statements";
28
+ import { stripMarkerFromStatement } from "../ownership";
21
29
  import { database, table, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
22
30
  import { canonicalObject, type CanonicalColumn, type CanonicalObject } from "./normalize";
23
31
  import { renderFor } from "../topology";
@@ -76,6 +84,73 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
76
84
  return out;
77
85
  }
78
86
 
87
+ /** Every server's copy of each table and view on a cluster (#3664), as `<database>.<name>` → host → statement. */
88
+ interface ClusterCopies {
89
+ /** The host the profile names, whose copy the rest are compared with. */
90
+ self: string;
91
+ byKey: Map<string, Map<string, string>>;
92
+ }
93
+
94
+ /**
95
+ * On a `cluster:<name>` topology, read `system.tables` on every replica of
96
+ * every shard through the profile's server (`clusterAllReplicas`). A change
97
+ * made on one server without `ON CLUSTER` (a TTL altered on shard 2 alone)
98
+ * is then seen, which the profile's server alone would not show. Undefined
99
+ * for any other topology: there is one copy.
100
+ */
101
+ async function clusterCopies(target: ClickHouseTarget): Promise<ClusterCopies | undefined> {
102
+ if (target.topology?.kind !== "cluster") return undefined;
103
+ const scope = target.databases
104
+ ? `IN (${target.databases.map(sqlString).join(", ")})`
105
+ : `NOT IN (${[...SYSTEM_DATABASES].map(sqlString).join(", ")})`;
106
+ const [self] = await clickhouseQuery<{ host: string }>(target.endpoint, "SELECT hostName() AS host");
107
+ const rows = await clickhouseQuery<{ host: string; database: string; name: string; statement: string }>(
108
+ target.endpoint,
109
+ `SELECT hostName() AS host, database, name, create_table_query AS statement ` +
110
+ `FROM clusterAllReplicas(${sqlString(target.topology.cluster)}, system.tables) ` +
111
+ `WHERE database ${scope} AND NOT is_temporary AND name NOT LIKE '.inner%' AND engine != 'Dictionary' ORDER BY host, database, name`,
112
+ );
113
+ const byKey = new Map<string, Map<string, string>>();
114
+ for (const r of rows) {
115
+ const key = `${r.database}.${r.name}`;
116
+ if (!byKey.has(key)) byKey.set(key, new Map());
117
+ byKey.get(key)!.set(r.host, stripMarkerFromStatement(r.statement));
118
+ }
119
+ return { self: self?.host ?? "", byKey };
120
+ }
121
+
122
+ /**
123
+ * When the servers' copies of `key` do not all read the same, the copy to
124
+ * report and its server: whichever departs furthest from the declaration
125
+ * `d`, the profile server's own winning a tie. A copy that differs from the
126
+ * declaration on any server is then the one the diff sees. Undefined when
127
+ * every copy reads the same.
128
+ */
129
+ function copyToReport(copies: ClusterCopies, key: string, d: CanonicalObject, defaultDatabase: string): { host: string; statement: string } | undefined {
130
+ const byHost = copies.byKey.get(key);
131
+ const mine = byHost?.get(copies.self);
132
+ if (!byHost || mine === undefined) return undefined;
133
+ const shape = (c: CanonicalObject) => {
134
+ const { columns, ...rest } = c;
135
+ return JSON.stringify({ ...rest, columns: columns.map(({ text: _t, position: _p, ...col }) => col) });
136
+ };
137
+ const reference = shape(canonicalObject(mine, defaultDatabase));
138
+ const declared = JSON.parse(shape(d)) as Record<string, unknown>;
139
+ /** How many of the declaration's fields this copy holds differently. */
140
+ const departures = (statement: string) => {
141
+ const live = JSON.parse(shape(canonicalObject(statement, defaultDatabase))) as Record<string, unknown>;
142
+ return Object.keys({ ...declared, ...live }).filter((k) => k !== "name" && k !== "database" && JSON.stringify(declared[k]) !== JSON.stringify(live[k])).length;
143
+ };
144
+ let pick: { host: string; statement: string; score: number } | undefined;
145
+ for (const [host, statement] of byHost) {
146
+ if (host === copies.self || shape(canonicalObject(statement, defaultDatabase)) === reference) continue;
147
+ const score = departures(statement);
148
+ if (!pick || score > pick.score) pick = { host, statement, score };
149
+ }
150
+ if (!pick) return undefined;
151
+ return pick.score > departures(mine) ? { host: pick.host, statement: pick.statement } : { host: copies.self, statement: mine };
152
+ }
153
+
79
154
  export async function observeResourcesDeep(
80
155
  options: {
81
156
  environment: string;
@@ -98,6 +173,17 @@ export async function observeResourcesDeep(
98
173
  return deepObservation({}, unobserved);
99
174
  }
100
175
  const byKey = new Map(live.map((o) => [`${o.database ?? ""}.${o.name}`, o]));
176
+ let copies: ClusterCopies | undefined;
177
+ try {
178
+ copies = await clusterCopies(target);
179
+ } catch (err) {
180
+ // A cluster whose servers cannot all be read is not reported as matching.
181
+ const why = classifyClickHouseFailure(err);
182
+ for (const name of options.entityNames) {
183
+ unobserved[name] = { type: options.entities.get(name)?.entityType ?? "", reason: why.reason, detail: `reading every server of the cluster: ${why.detail}` };
184
+ }
185
+ return deepObservation({}, unobserved);
186
+ }
101
187
  for (const name of options.entityNames) {
102
188
  const entity = options.entities.get(name);
103
189
  if (!entity || !entity.entityType.startsWith("ClickHouse::")) {
@@ -111,10 +197,23 @@ export async function observeResourcesDeep(
111
197
  if (!o) continue;
112
198
  try {
113
199
  const d = canonicalObject(renderFor(String(props.ddl), target.topology), target.defaultDatabase);
200
+ const physicalId = o.uuid ?? `${db ? `${db}.` : ""}${o.name}`;
201
+ const other = copies && !isDb ? copyToReport(copies, `${db ?? ""}.${o.name}`, d, target.defaultDatabase) : undefined;
202
+ if (other) {
203
+ // The servers disagree: report the copy that departs from the declaration, and where it was seen.
204
+ const copy: LiveObject = { ...o, statement: other.host === copies!.self ? o.statement : other.statement };
205
+ resources[name] = {
206
+ type: o.type,
207
+ physicalId,
208
+ properties: inDeclaredVocabulary(props, liveProps(copy), d, canonicalObject(copy.statement, target.defaultDatabase)),
209
+ observedOn: other.host,
210
+ };
211
+ continue;
212
+ }
114
213
  const l = canonicalObject(o.statement, target.defaultDatabase);
115
214
  resources[name] = {
116
215
  type: o.type,
117
- physicalId: o.uuid ?? `${db ? `${db}.` : ""}${o.name}`,
216
+ physicalId,
118
217
  properties: inDeclaredVocabulary(props, liveProps(o), d, l),
119
218
  };
120
219
  } catch (err) {
@@ -284,6 +284,10 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
284
284
  name = shared[1]!;
285
285
  if (/^'.*'$/s.test(args[0] ?? "") && /^'.*'$/s.test(args[1] ?? "")) args = args.slice(2);
286
286
  }
287
+ // `Distributed(cluster, db, table, ...)` takes its first three arguments
288
+ // as identifiers or as strings, and the server prints them back quoted
289
+ // (#3664): an unquoted declaration says the same.
290
+ if (name === "Distributed") args = args.map((a, i) => (i < 3 ? quotedIdentifier(a) : a));
287
291
  obj.engineName = name;
288
292
  obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
289
293
  }
@@ -359,3 +363,9 @@ function column(tokens: Token[], c: ColumnNode, position: number, database?: str
359
363
  if (prev) out.previously = prev;
360
364
  return out;
361
365
  }
366
+
367
+ /** A bare or backquoted identifier as the string literal the server prints it as; anything else as it is. */
368
+ function quotedIdentifier(arg: string): string {
369
+ const bare = /^[A-Za-z_][A-Za-z0-9_]*$/.test(arg) ? arg : /^`((?:[^`]|``)+)`$/.exec(arg)?.[1]?.replace(/``/g, "`");
370
+ return bare === undefined ? arg : `'${bare.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
371
+ }
@@ -87,6 +87,16 @@ describe("normalization", () => {
87
87
  expect(shown("ReplicatedMergeTree('/clickhouse/tables/{shard}/t', '{replica}')")).not.toBe(shown("ReplicatedMergeTree"));
88
88
  });
89
89
 
90
+ test("a Distributed engine's cluster, database and table read the same quoted or not (#3664)", () => {
91
+ const shown = (engine: string) => canonicalObject(`CREATE TABLE d.events_all (id UInt64) ENGINE = ${engine}`).engine;
92
+ expect(shown("Distributed(my_cluster, d, events, id)")).toBe(shown("Distributed('my_cluster', 'd', 'events', id)"));
93
+ expect(shown("Distributed(`my_cluster`, `d`, `events`)")).toBe(shown("Distributed('my_cluster', 'd', 'events')"));
94
+ expect(shown("Distributed('{cluster}', currentDatabase(), events, rand())")).toBe(shown("Distributed('{cluster}', currentDatabase(), 'events', rand())"));
95
+ // The sharding key is an expression, not a name: it stays as written.
96
+ expect(shown("Distributed(c, d, events, id)")).not.toBe(shown("Distributed(c, d, events, 'id')"));
97
+ expect(shown("Distributed(c, d, events)")).not.toBe(shown("Distributed(c, d, other)"));
98
+ });
99
+
90
100
  test("a setting at its default is not part of the definition", () => {
91
101
  expect(canonicalObject("CREATE TABLE t (a UInt8) ENGINE = MergeTree ORDER BY a SETTINGS index_granularity = 8192").settings).toEqual({});
92
102
  expect(canonicalObject("CREATE TABLE t (a UInt8) ENGINE = MergeTree ORDER BY a SETTINGS min_bytes_for_wide_part = '0'").settings).toEqual({ min_bytes_for_wide_part: "0" });
@@ -41,6 +41,26 @@
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
+ * On a cluster of more than one shard (#3663) the unit is a partition on one
46
+ * shard: read from that shard, written back to it, cleared on it alone, with
47
+ * its own receipt (`./shards.ts`), so a rerun after a failure on one shard
48
+ * skips what every shard already copied.
49
+ *
50
+ * In materialized-view mode a partition is copied in two parts
51
+ * (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
52
+ * above. The rows at or after it are the view's only when they were written
53
+ * after the view was made; the ones the old table already held (a booking
54
+ * next month, an expiry date) reach the new table only through the backfill.
55
+ * The two kinds cannot be told apart by their values, so the second part
56
+ * copies the difference: for each distinct row at or after the cut-over, as
57
+ * many copies as the old table has more of it than the new one. That copy
58
+ * converges on its own, so a run killed during it, or a partition copied
59
+ * again, copies only what is still missing. A row the view is a moment from
60
+ * committing can be copied by both: the copy is followed by a check for rows
61
+ * the new table holds more often than the old one, which deletes and copies
62
+ * those again. Whatever is left, the verification finds, and the swap does
63
+ * not go ahead.
44
64
  */
45
65
 
46
66
  import { EffectReceipt, receiptExpectation } from "@intentius/chant/effect-receipt";
@@ -49,8 +69,11 @@ import { ident, sqlString } from "../apply/statements";
49
69
  import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } from "./receipts";
50
70
  import { RebuildRefusal, type RebuildObservation } from "./observe";
51
71
  import { DDL_SETTINGS } from "./replicas";
52
- import { sourcePartitionExpression, sourcePartitions } from "./partitions";
72
+ import { shardPartitions, sourcePartitionExpression, sourcePartitions } from "./partitions";
73
+ import { rowHash } from "./verify";
53
74
  import { cutoverOf, observe, serverNow, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
75
+ import { intoShard, onShard, onShardServers, shardTable, SHARD_DELETE_SETTINGS, SHARD_INSERT_SETTINGS, type Shard } from "./shards";
76
+ import { renderFor } from "../topology";
54
77
 
55
78
  export interface BackfillResult {
56
79
  state: RebuildObservation["state"];
@@ -62,19 +85,30 @@ export interface BackfillResult {
62
85
  skipped: number;
63
86
  /** Partitions this run found partly copied and cleared before copying again. */
64
87
  cleared: number;
88
+ /** 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. */
89
+ repaired: number;
65
90
  }
66
91
 
67
92
  /** The effect a partition's copy is, and its receipt's address suffix. */
68
- export const partitionEffect = (key: string, partition: string): string => `rebuild/${key}/${partition}`;
93
+ export const partitionEffect = (key: string, partition: string, shard?: Shard): string => (shard ? `rebuild/${key}/shard${shard.num}/${partition}` : `rebuild/${key}/${partition}`);
69
94
 
70
- /** The receipt's expected value for one partition's copy. */
71
- export function partitionExpectation(o: RebuildObservation, partition: string, cutover: number | undefined): string {
72
- const effect = partitionEffect(o.names.key, partition);
95
+ /** The receipt's expected value for one partition's copy, on one shard of a cluster when `shard` is given. */
96
+ export function partitionExpectation(o: RebuildObservation, partition: string, cutover: number | undefined, shard?: Shard): string {
97
+ const effect = partitionEffect(o.names.key, partition, shard);
73
98
  return receiptExpectation(
74
99
  EffectReceipt(effect, {
75
100
  effect,
76
101
  flavor: "hash",
77
- inputs: { table: o.names.key, newTable: o.newTable!.uuid, partition, copied: o.copied, cutover: cutover === undefined ? null : new Date(cutover).toISOString() },
102
+ inputs: {
103
+ table: o.names.key,
104
+ newTable: o.newTable!.uuid,
105
+ partition,
106
+ ...(shard ? { shard: shard.num } : {}),
107
+ copied: o.copied,
108
+ cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
109
+ // 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.
110
+ ...(cutover === undefined ? {} : { after: "difference" }),
111
+ },
78
112
  }),
79
113
  );
80
114
  }
@@ -82,13 +116,13 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
82
116
  export interface BackfillDeps {
83
117
  /** The receipt store. Default: the receipts table on the target server. */
84
118
  receipts?: ClickHouseReceiptStore;
85
- /** Called after each partition is copied and its receipt written; a test interrupts the backfill here. */
86
- afterPartition?: (partition: string) => void | Promise<void>;
119
+ /** Called after each partition is copied and its receipt written, with its shard on a cluster of shards; a test interrupts the backfill here. */
120
+ afterPartition?: (partition: string, shard?: number) => void | Promise<void>;
87
121
  }
88
122
 
89
123
  export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
90
124
  const o = await observe(run);
91
- const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0 };
125
+ const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0, repaired: 0 };
92
126
  if (o.state !== "rebuild") return result;
93
127
  if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
94
128
  const n = o.names;
@@ -113,6 +147,13 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
113
147
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
114
148
  const columns = o.copied.map((c) => ident(c.name)).join(", ");
115
149
  const select = o.copied.map((c) => ident(c.source)).join(", ");
150
+ const later = cutColumn && cutover !== undefined ? await laterRowsCopy(run, o, cutColumn, cutover, fromOld) : undefined;
151
+ const sharding = run.sharding;
152
+ // The old and new tables as one shard's rows (#3663, `./shards.ts`), or as this server's.
153
+ const oldFrom = (shard?: Shard) => (shard && sharding ? `${shardTable(sharding, n.database, n.name)} WHERE ${onShard(shard)} AND` : `${n.table} WHERE`);
154
+ const newFrom = (shard?: Shard) => (shard && sharding ? `${shardTable(sharding, n.database, n.newName)} WHERE ${onShard(shard)} AND` : `${n.newTable} WHERE`);
155
+ const into = (shard?: Shard) => (shard && sharding ? intoShard(sharding, shard, n.database, n.newName) : n.newTable);
156
+ const insertSettings = sharding ? SHARD_INSERT_SETTINGS : {};
116
157
 
117
158
  const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
118
159
  const receipts =
@@ -125,53 +166,176 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
125
166
  });
126
167
  const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
127
168
 
128
- const partitions = await sourcePartitions(run.target, n);
129
- result.partitions = partitions.length;
130
- for (const p of partitions) {
169
+ // The unit of work: a partition of the old table, on each shard of a cluster that has more than one.
170
+ const units: Array<{ shard?: Shard; partition: string }> = sharding
171
+ ? (await shardPartitions(run, sharding, n.database, n.name)).map((u) => ({ shard: sharding.shards.find((x) => x.num === u.shard)!, partition: u.partition }))
172
+ : (await sourcePartitions(run.target, n)).map((partition) => ({ partition }));
173
+ result.partitions = units.length;
174
+ for (const { shard, partition: p } of units) {
131
175
  run.signal?.throwIfAborted();
132
- const effect = partitionEffect(n.key, p);
133
- const expectation = partitionExpectation(o, p, cutover);
176
+ const effect = partitionEffect(n.key, p, shard);
177
+ const expectation = partitionExpectation(o, p, cutover, shard);
134
178
  if (recorded.get(receiptAddress(identity, effect)) === expectation) {
135
179
  result.skipped++;
136
180
  continue;
137
181
  }
138
182
 
139
- const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
140
- if (o.replicated) {
183
+ const queryId = `chant-rebuild-${o.newTable.uuid}-${shard ? `shard${shard.num}-` : ""}${p}`;
184
+ const laterQueryId = `${queryId}-after`;
185
+ const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
186
+ if (sharding) {
187
+ // The copy reads and writes every shard from this server; what it started on the others goes with it.
188
+ await clickhouseQuery(
189
+ run.target.endpoint,
190
+ `KILL QUERY ON CLUSTER ${sqlString(sharding.cluster)} WHERE ${killWhere} OR initial_query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)}) SYNC`,
191
+ { settings: DDL_SETTINGS },
192
+ );
193
+ } else if (o.replicated) {
141
194
  // On every replica that answers: the copy may be running on another one
142
195
  // than this run's. A replica that is down is skipped once Keeper sees it
143
196
  // inactive; nothing of its can be running a copy that still writes,
144
197
  // and a part it wrote before going down is waited for by the sync below.
145
- await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE query_id = ${sqlString(queryId)} SYNC`, {
198
+ await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE ${killWhere} SYNC`, {
146
199
  settings: DDL_SETTINGS,
147
200
  });
148
201
  await syncReplica(run, o, n.database, n.newName);
149
202
  } else {
150
- await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE query_id = ${sqlString(queryId)} SYNC`);
203
+ await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
151
204
  }
152
205
  const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
153
- const [left] = await clickhouseQuery<{ n: string | number }>(run.target.endpoint, `SELECT count() AS n FROM ${n.newTable} WHERE ${mine}`);
206
+ const where = shard ? `partition ${p} on shard ${shard.num}` : `partition ${p}`;
207
+ const [left] = await clickhouseQuery<{ n: string | number }>(run.target.endpoint, `SELECT count() AS n FROM ${newFrom(shard)} ${mine}`);
154
208
  if (Number(left?.n ?? 0) > 0) {
155
- run.log(`-- partition ${p}: ${left!.n} row(s) from a copy that did not finish; deleting them before copying again`);
156
- const sql = `ALTER TABLE ${n.newTable} DELETE WHERE ${mine}`;
209
+ run.log(`-- ${where}: ${left!.n} row(s) from a copy that did not finish; deleting them before copying again`);
210
+ const sql =
211
+ shard && sharding
212
+ ? renderFor(`ALTER TABLE ${n.newTable} DELETE WHERE ${onShardServers(shard)} AND ${mine}`, run.target.topology)
213
+ : `ALTER TABLE ${n.newTable} DELETE WHERE ${mine}`;
157
214
  run.log(sql);
158
- await clickhouseQuery(run.target.endpoint, sql);
215
+ await clickhouseQuery(run.target.endpoint, sql, shard && sharding ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
159
216
  await waitOn(run, n.database, n.newName);
160
217
  result.cleared++;
161
218
  }
162
219
 
163
- const sql = `INSERT INTO ${n.newTable} (${columns}) SELECT ${select} FROM ${n.table} WHERE _partition_id = ${sqlString(p)}${sourceRange}`;
220
+ const sql = `INSERT INTO ${into(shard)} (${columns}) SELECT ${select} FROM ${oldFrom(shard)} _partition_id = ${sqlString(p)}${sourceRange}`;
164
221
  run.log(sql);
165
- await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
222
+ await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0", ...insertSettings }, ...(run.signal ? { signal: run.signal } : {}) });
223
+ if (later) result.repaired += await copyAfterCutover(run, o, later, p, laterQueryId, shard);
166
224
  // The receipt, last, on success only.
167
225
  await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
168
226
  result.copied++;
169
- await deps.afterPartition?.(p);
227
+ await deps.afterPartition?.(p, shard?.num);
170
228
  }
171
- run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first`);
229
+ 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` : ""}`);
172
230
  return result;
173
231
  }
174
232
 
233
+ /** The statements that copy one old partition's rows at or after the cut-over. */
234
+ interface LaterRows {
235
+ /**
236
+ * Per distinct row (by its hash), the old table's copies past the number
237
+ * the new table holds, into the new table. On a cluster of shards, one
238
+ * shard's rows into the same shard.
239
+ */
240
+ copy: (partition: string, shard?: Shard) => string;
241
+ /** The hashes of the rows the new table holds more copies of than the old one. */
242
+ extras: (partition: string, shard?: Shard) => string;
243
+ /** Delete every copy of those rows from the new table. */
244
+ drop: (partition: string, hashes: readonly string[], shard?: Shard) => string;
245
+ }
246
+
247
+ /** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
248
+ const REPAIR_LIMIT = 1000;
249
+ const REPAIR_ROUNDS = 3;
250
+
251
+ /**
252
+ * The statements for the rows at or after the cut-over. The old side casts
253
+ * each column to its type in the new table before hashing, as the
254
+ * verification does, so a row and its copy hash the same.
255
+ */
256
+ async function laterRowsCopy(
257
+ run: RebuildRun,
258
+ o: RebuildObservation,
259
+ cut: RebuildObservation["copied"][number],
260
+ cutover: number,
261
+ fromOld: string,
262
+ ): Promise<LaterRows> {
263
+ const n = o.names;
264
+ const types = new Map(
265
+ (
266
+ await clickhouseQuery<{ name: string; type: string }>(
267
+ run.target.endpoint,
268
+ `SELECT name, type FROM system.columns WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)}`,
269
+ )
270
+ ).map((c) => [c.name, c.type]),
271
+ );
272
+ const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
273
+ const newHash = rowHash(o.copied.map((c) => ident(c.name)));
274
+ const columns = o.copied.map((c) => ident(c.name)).join(", ");
275
+ const aliases = o.copied.map((_, i) => `__chant_c${i}`);
276
+ const fromOldSide = o.copied.map((c, i) => `${ident(c.source)} AS ${aliases[i]}`).join(", ");
277
+ const sharding = run.sharding;
278
+ const on = (shard?: Shard) => (shard && sharding ? ` AND ${onShard(shard)}` : "");
279
+ const oldTable = (shard?: Shard) => (shard && sharding ? shardTable(sharding, n.database, n.name) : n.table);
280
+ const newTable = (shard?: Shard) => (shard && sharding ? shardTable(sharding, n.database, n.newName) : n.newTable);
281
+ const oldRows = (p: string, shard?: Shard) => `FROM ${oldTable(shard)} WHERE _partition_id = ${sqlString(p)} AND ${ident(cut.source)} >= ${utcLiteral(cutover)}${on(shard)}`;
282
+ const newRows = (p: string, shard?: Shard) => `FROM ${newTable(shard)} WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)}${on(shard)}`;
283
+ return {
284
+ copy: (p, shard) =>
285
+ `INSERT INTO ${shard && sharding ? intoShard(sharding, shard, n.database, n.newName) : n.newTable} (${columns}) SELECT ${aliases.join(", ")} FROM (` +
286
+ `SELECT ${fromOldSide}, ${oldHash} AS __chant_h, row_number() OVER (PARTITION BY __chant_h) AS __chant_k ${oldRows(p, shard)}` +
287
+ `) AS old_rows LEFT JOIN (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p, shard)} GROUP BY __chant_h) AS new_rows ` +
288
+ `USING (__chant_h) WHERE __chant_k > ifNull(__chant_n, 0)`,
289
+ extras: (p, shard) =>
290
+ `SELECT toString(__chant_h) AS h FROM (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p, shard)} GROUP BY __chant_h) AS new_rows ` +
291
+ `LEFT JOIN (SELECT ${oldHash} AS __chant_h, count() AS __chant_o ${oldRows(p, shard)} GROUP BY __chant_h) AS old_rows ` +
292
+ `USING (__chant_h) WHERE __chant_n > ifNull(__chant_o, 0) LIMIT ${REPAIR_LIMIT + 1}`,
293
+ drop: (p, hashes, shard) => {
294
+ const where = `${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)} AND ${newHash} IN (${hashes.join(", ")})`;
295
+ return shard && sharding
296
+ ? renderFor(`ALTER TABLE ${n.newTable} DELETE WHERE ${onShardServers(shard)} AND ${where}`, run.target.topology)
297
+ : `ALTER TABLE ${n.newTable} DELETE WHERE ${where}`;
298
+ },
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Copy one old partition's rows at or after the cut-over that the new table
304
+ * lacks. A row the view had written to the old table but not yet to the new
305
+ * one when the copy read them is copied by both, and is then in the new
306
+ * table once too often: every copy of such a row is deleted from the new
307
+ * table and the difference copied again, a few rounds at most. What is left
308
+ * after that, the verification finds. Returns the rows repaired.
309
+ */
310
+ async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string, shard?: Shard): Promise<number> {
311
+ const n = o.names;
312
+ const sharded = shard !== undefined && run.sharding !== undefined;
313
+ const insert = async () => {
314
+ const sql = later.copy(p, shard);
315
+ run.log(sql);
316
+ await clickhouseQuery(run.target.endpoint, sql, {
317
+ queryId,
318
+ settings: { async_insert: "0", insert_deduplicate: "0", ...(sharded ? SHARD_INSERT_SETTINGS : {}) },
319
+ ...(run.signal ? { signal: run.signal } : {}),
320
+ });
321
+ };
322
+ await insert();
323
+ let repaired = 0;
324
+ for (let round = 0; round < REPAIR_ROUNDS; round++) {
325
+ await syncReplica(run, o, n.database, n.newName);
326
+ const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p, shard))).map((r) => r.h);
327
+ if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
328
+ 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`);
329
+ const sql = later.drop(p, extras, shard);
330
+ run.log(sql);
331
+ await clickhouseQuery(run.target.endpoint, sql, sharded ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
332
+ await waitOn(run, n.database, n.newName);
333
+ repaired += extras.length;
334
+ await insert();
335
+ }
336
+ return repaired;
337
+ }
338
+
175
339
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
176
340
  return new Promise((resolve, reject) => {
177
341
  const t = setTimeout(resolve, ms);
@@ -55,9 +55,10 @@ export type DualWrite =
55
55
  /**
56
56
  * A materialized view on the old table writes every row whose
57
57
  * `cutoverColumn` is at or after the cut-over into the new table; the
58
- * backfill copies the rows before it. The cut-over is the server's time
59
- * when the view is made plus `cutoverDelay` (default `1m`), and the
60
- * backfill starts once it has passed. The column must be a time
58
+ * backfill copies the rows before it, and the rows at or after it that
59
+ * the table held before the view was made. The cut-over is the server's
60
+ * time when the view is made plus `cutoverDelay` (default `1m`), rounded
61
+ * up to a whole second, and the backfill starts once it has passed. The column must be a time
61
62
  * (`Date`, `DateTime`, `DateTime64`) that rows arrive in order of, give
62
63
  * or take the delay; a row that arrives later than that is caught by
63
64
  * the verification, which then fails the run.