@intentius/chant-lexicon-sql 0.114.1 → 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 (70) 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 +11 -5
  9. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  10. package/dist/clickhouse/rebuild/op.d.ts +29 -0
  11. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  12. package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
  13. package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
  15. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/shards.d.ts +59 -0
  17. package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
  18. package/dist/clickhouse/rebuild/steps.d.ts +3 -0
  19. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  20. package/dist/clickhouse/rebuild/verify.d.ts +4 -0
  21. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  22. package/dist/config.d.ts +14 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/core/receipts.d.ts +5 -3
  25. package/dist/core/receipts.d.ts.map +1 -1
  26. package/dist/integrity.json +2 -2
  27. package/dist/manifest.json +1 -1
  28. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  29. package/dist/op/activities/index.d.ts +13 -1
  30. package/dist/op/activities/index.d.ts.map +1 -1
  31. package/dist/op/activities/sql-exec.d.ts +41 -0
  32. package/dist/op/activities/sql-exec.d.ts.map +1 -0
  33. package/dist/op/activity-contracts.d.ts +20 -0
  34. package/dist/op/activity-contracts.d.ts.map +1 -1
  35. package/dist/postgres/live/catalog.d.ts +1 -1
  36. package/dist/postgres/live/catalog.d.ts.map +1 -1
  37. package/dist/postgres/migrate/receipts.d.ts +8 -5
  38. package/dist/postgres/migrate/receipts.d.ts.map +1 -1
  39. package/dist/receipts.d.ts +102 -0
  40. package/dist/receipts.d.ts.map +1 -0
  41. package/package.json +2 -2
  42. package/src/clickhouse/apply/mutations.ts +9 -3
  43. package/src/clickhouse/container.ts +39 -11
  44. package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
  45. package/src/clickhouse/plan/deep.ts +102 -3
  46. package/src/clickhouse/plan/normalize.ts +10 -0
  47. package/src/clickhouse/plan/plan.test.ts +10 -0
  48. package/src/clickhouse/rebuild/backfill.ts +85 -42
  49. package/src/clickhouse/rebuild/op.ts +80 -35
  50. package/src/clickhouse/rebuild/partitions.ts +11 -0
  51. package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
  52. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +110 -4
  53. package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
  54. package/src/clickhouse/rebuild/receipts.ts +4 -6
  55. package/src/clickhouse/rebuild/shards.ts +104 -0
  56. package/src/clickhouse/rebuild/steps.ts +4 -0
  57. package/src/clickhouse/rebuild/verify.ts +14 -5
  58. package/src/config.ts +7 -0
  59. package/src/core/receipts.ts +5 -3
  60. package/src/op/activities/clickhouse-rebuild.ts +32 -7
  61. package/src/op/activities/index.ts +21 -4
  62. package/src/op/activities/sql-exec.ts +84 -0
  63. package/src/op/activity-contracts.ts +15 -0
  64. package/src/op/effect-receipts.e2e.test.ts +277 -0
  65. package/src/postgres/live/catalog.ts +3 -1
  66. package/src/postgres/migrate/migrate.test.ts +3 -2
  67. package/src/postgres/migrate/receipts.ts +18 -10
  68. package/src/receipts.test.ts +91 -0
  69. package/src/receipts.ts +259 -0
  70. package/src/skills/chant-sql-rebuild.md +5 -1
@@ -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" });
@@ -42,6 +42,11 @@
42
42
  * either finds nothing that replica alone has and returns, or stops the step
43
43
  * naming it (`./replicas.ts`).
44
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
+ *
45
50
  * In materialized-view mode a partition is copied in two parts
46
51
  * (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
47
52
  * above. The rows at or after it are the view's only when they were written
@@ -64,9 +69,11 @@ import { ident, sqlString } from "../apply/statements";
64
69
  import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } from "./receipts";
65
70
  import { RebuildRefusal, type RebuildObservation } from "./observe";
66
71
  import { DDL_SETTINGS } from "./replicas";
67
- import { sourcePartitionExpression, sourcePartitions } from "./partitions";
72
+ import { shardPartitions, sourcePartitionExpression, sourcePartitions } from "./partitions";
68
73
  import { rowHash } from "./verify";
69
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";
70
77
 
71
78
  export interface BackfillResult {
72
79
  state: RebuildObservation["state"];
@@ -83,11 +90,11 @@ export interface BackfillResult {
83
90
  }
84
91
 
85
92
  /** The effect a partition's copy is, and its receipt's address suffix. */
86
- 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}`);
87
94
 
88
- /** The receipt's expected value for one partition's copy. */
89
- export function partitionExpectation(o: RebuildObservation, partition: string, cutover: number | undefined): string {
90
- 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);
91
98
  return receiptExpectation(
92
99
  EffectReceipt(effect, {
93
100
  effect,
@@ -96,6 +103,7 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
96
103
  table: o.names.key,
97
104
  newTable: o.newTable!.uuid,
98
105
  partition,
106
+ ...(shard ? { shard: shard.num } : {}),
99
107
  copied: o.copied,
100
108
  cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
101
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.
@@ -108,8 +116,8 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
108
116
  export interface BackfillDeps {
109
117
  /** The receipt store. Default: the receipts table on the target server. */
110
118
  receipts?: ClickHouseReceiptStore;
111
- /** Called after each partition is copied and its receipt written; a test interrupts the backfill here. */
112
- 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>;
113
121
  }
114
122
 
115
123
  export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
@@ -140,6 +148,12 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
140
148
  const columns = o.copied.map((c) => ident(c.name)).join(", ");
141
149
  const select = o.copied.map((c) => ident(c.source)).join(", ");
142
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 : {};
143
157
 
144
158
  const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
145
159
  const receipts =
@@ -152,21 +166,31 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
152
166
  });
153
167
  const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
154
168
 
155
- const partitions = await sourcePartitions(run.target, n);
156
- result.partitions = partitions.length;
157
- 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) {
158
175
  run.signal?.throwIfAborted();
159
- const effect = partitionEffect(n.key, p);
160
- const expectation = partitionExpectation(o, p, cutover);
176
+ const effect = partitionEffect(n.key, p, shard);
177
+ const expectation = partitionExpectation(o, p, cutover, shard);
161
178
  if (recorded.get(receiptAddress(identity, effect)) === expectation) {
162
179
  result.skipped++;
163
180
  continue;
164
181
  }
165
182
 
166
- const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
183
+ const queryId = `chant-rebuild-${o.newTable.uuid}-${shard ? `shard${shard.num}-` : ""}${p}`;
167
184
  const laterQueryId = `${queryId}-after`;
168
185
  const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
169
- if (o.replicated) {
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) {
170
194
  // On every replica that answers: the copy may be running on another one
171
195
  // than this run's. A replica that is down is skipped once Keeper sees it
172
196
  // inactive; nothing of its can be running a copy that still writes,
@@ -179,24 +203,28 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
179
203
  await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
180
204
  }
181
205
  const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
182
- 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}`);
183
208
  if (Number(left?.n ?? 0) > 0) {
184
- run.log(`-- partition ${p}: ${left!.n} row(s) from a copy that did not finish; deleting them before copying again`);
185
- 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}`;
186
214
  run.log(sql);
187
- await clickhouseQuery(run.target.endpoint, sql);
215
+ await clickhouseQuery(run.target.endpoint, sql, shard && sharding ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
188
216
  await waitOn(run, n.database, n.newName);
189
217
  result.cleared++;
190
218
  }
191
219
 
192
- 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}`;
193
221
  run.log(sql);
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);
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);
196
224
  // The receipt, last, on success only.
197
225
  await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
198
226
  result.copied++;
199
- await deps.afterPartition?.(p);
227
+ await deps.afterPartition?.(p, shard?.num);
200
228
  }
201
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` : ""}`);
202
230
  return result;
@@ -206,13 +234,14 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
206
234
  interface LaterRows {
207
235
  /**
208
236
  * Per distinct row (by its hash), the old table's copies past the number
209
- * the new table holds, into the new table.
237
+ * the new table holds, into the new table. On a cluster of shards, one
238
+ * shard's rows into the same shard.
210
239
  */
211
- copy: (partition: string) => string;
240
+ copy: (partition: string, shard?: Shard) => string;
212
241
  /** The hashes of the rows the new table holds more copies of than the old one. */
213
- extras: (partition: string) => string;
242
+ extras: (partition: string, shard?: Shard) => string;
214
243
  /** Delete every copy of those rows from the new table. */
215
- drop: (partition: string, hashes: readonly string[]) => string;
244
+ drop: (partition: string, hashes: readonly string[], shard?: Shard) => string;
216
245
  }
217
246
 
218
247
  /** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
@@ -245,19 +274,28 @@ async function laterRowsCopy(
245
274
  const columns = o.copied.map((c) => ident(c.name)).join(", ");
246
275
  const aliases = o.copied.map((_, i) => `__chant_c${i}`);
247
276
  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)}`;
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)}`;
250
283
  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 ` +
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 ` +
255
288
  `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 ` +
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 ` +
259
292
  `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(", ")})`,
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
+ },
261
299
  };
262
300
  }
263
301
 
@@ -269,23 +307,28 @@ async function laterRowsCopy(
269
307
  * table and the difference copied again, a few rounds at most. What is left
270
308
  * after that, the verification finds. Returns the rows repaired.
271
309
  */
272
- async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string): Promise<number> {
310
+ async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string, shard?: Shard): Promise<number> {
273
311
  const n = o.names;
312
+ const sharded = shard !== undefined && run.sharding !== undefined;
274
313
  const insert = async () => {
275
- const sql = later.copy(p);
314
+ const sql = later.copy(p, shard);
276
315
  run.log(sql);
277
- await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
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
+ });
278
321
  };
279
322
  await insert();
280
323
  let repaired = 0;
281
324
  for (let round = 0; round < REPAIR_ROUNDS; round++) {
282
325
  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);
326
+ const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p, shard))).map((r) => r.h);
284
327
  if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
285
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`);
286
- const sql = later.drop(p, extras);
329
+ const sql = later.drop(p, extras, shard);
287
330
  run.log(sql);
288
- await clickhouseQuery(run.target.endpoint, sql);
331
+ await clickhouseQuery(run.target.endpoint, sql, sharded ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
289
332
  await waitOn(run, n.database, n.newName);
290
333
  repaired += extras.length;
291
334
  await insert();
@@ -25,6 +25,12 @@
25
25
  * | Drop | the old table, once its date has passed |
26
26
  * | onFailure | drop the new table and the dual-write view |
27
27
  *
28
+ * `gates: "outer"` (#3658) leaves out both approval gates and the Drop phase,
29
+ * for a caller that runs the Op under an approval of its own: the run goes
30
+ * from Plan to Retain, verification and the swap's own comparison included.
31
+ * `onFailure: "keep"` leaves out the onFailure, so a failed run's next run
32
+ * resumes the backfill from its receipts instead of starting again.
33
+ *
28
34
  * Run it until it is done: each run goes as far as the next gate. Every step
29
35
  * re-reads the server, so a run after a gate, a crash or an approval picks up
30
36
  * where the last stopped. A run killed during the backfill (Ctrl-C, a lost
@@ -55,6 +61,27 @@ export interface ClickHouseRebuildOpConfig {
55
61
  build?: boolean;
56
62
  /** How long the old table is kept after the swap, as a duration (`7d`, `36h`). Default: `7d`. */
57
63
  retain?: string;
64
+ /**
65
+ * Whose approval the swap and the drop wait for. `"own"` (the default): the
66
+ * Op's two gates. `"outer"`: an approval the caller already holds for a
67
+ * larger change that runs this Op as one of its steps. The swap gate, the
68
+ * drop gate and the Drop phase are left out, so the Op ends at Retain with
69
+ * the old table kept as `<table>__chant_old` with its retention date. Drop
70
+ * it after that date, or run the Op with `"own"`: its swap gate has
71
+ * nothing left to swap, and its drop gate binds that old table.
72
+ * The verification still runs, and the swap compares the tables again
73
+ * before the `EXCHANGE`: any difference fails the run with nothing swapped.
74
+ * App mode's writes-stopped gate is not an approval and stays.
75
+ */
76
+ gates?: "own" | "outer";
77
+ /**
78
+ * What a failed run does with the new table and the dual-write view.
79
+ * `"drop"` (the default): onFailure drops them, and the next run starts
80
+ * again from a new table. `"keep"`: there is no onFailure, and the next run
81
+ * resumes the backfill from its per-partition receipts. To start again
82
+ * instead, drop `<table>__chant_new` and `<table>__chant_dual`.
83
+ */
84
+ onFailure?: "drop" | "keep";
58
85
  /** The gate before the swap. Default name `approve-<name>`. */
59
86
  gate?: {
60
87
  gate?: string;
@@ -100,6 +127,8 @@ export interface ClickHouseRebuildArgs {
100
127
  stack?: string;
101
128
  ownershipEnv?: string;
102
129
  cwd?: string;
130
+ /** A failed run keeps the new table (`onFailure: "keep"`): a refusal says to drop it by hand. */
131
+ keepOnFailure?: boolean;
103
132
  }
104
133
 
105
134
  export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHouseRebuildOpResources {
@@ -112,6 +141,14 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
112
141
  if (config.dualWrite.mode === "materialized-view" && !config.dualWrite.cutoverColumn) {
113
142
  throw new Error(`ClickHouseRebuildOp "${config.name}": materialized-view mode needs cutoverColumn, the time column rows are cut over on`);
114
143
  }
144
+ if (config.gates !== undefined && config.gates !== "own" && config.gates !== "outer") {
145
+ throw new Error(`ClickHouseRebuildOp "${config.name}": gates must be "own" or "outer"`);
146
+ }
147
+ if (config.onFailure !== undefined && config.onFailure !== "drop" && config.onFailure !== "keep") {
148
+ throw new Error(`ClickHouseRebuildOp "${config.name}": onFailure must be "drop" or "keep"`);
149
+ }
150
+ const outer = config.gates === "outer";
151
+ const keep = config.onFailure === "keep";
115
152
 
116
153
  const args: ClickHouseRebuildArgs = {
117
154
  table: config.table,
@@ -124,6 +161,7 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
124
161
  ...(config.stack ? { stack: config.stack } : {}),
125
162
  ...(config.ownershipEnv ? { ownershipEnv: config.ownershipEnv } : {}),
126
163
  ...(config.path && config.path !== "." ? { cwd: config.path } : {}),
164
+ ...(keep ? { keepOnFailure: true } : {}),
127
165
  };
128
166
  const a = args as unknown as Record<string, unknown>;
129
167
  const swapGate = config.gate?.gate ?? `approve-${config.name}`;
@@ -133,6 +171,33 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
133
171
  const step = (fn: string, extra: Partial<StepDefinition & { id: string; outcomeAttribute: unknown; profile: string; timeout: string }> = {}): StepDefinition =>
134
172
  ({ ...activity(fn, a), ...extra }) as StepDefinition;
135
173
 
174
+ // The swap waits for this gate, unless the caller holds the approval (`gates: "outer"`).
175
+ const approve = phase("Approve", [
176
+ gate(swapGate, {
177
+ ...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
178
+ plan: stepOutput("verify", "planDigest"),
179
+ description:
180
+ config.gate?.description ??
181
+ `Approve swapping the rebuilt ${config.table} in on ${config.env}: the run record's Verification line has the counts this approval binds`,
182
+ ...(config.gate?.approval
183
+ ? {
184
+ approval: {
185
+ ...config.gate.approval,
186
+ ...(policy
187
+ ? {
188
+ context: {
189
+ verifiedPartitions: stepOutput("verify", "partitions"),
190
+ verifiedRows: stepOutput("verify", "rows"),
191
+ ...config.gate.approval.context,
192
+ },
193
+ }
194
+ : {}),
195
+ },
196
+ }
197
+ : {}),
198
+ }),
199
+ ]);
200
+
136
201
  const phases = [
137
202
  ...(config.build === false ? [] : [phase("Build", [activity("chantBuild", { path: config.path ?? "." })])]),
138
203
  phase("Plan", [step("clickhouseRebuildPlan", { id: "plan", outcomeAttribute: { name: "RebuildState", from: "state" } })]),
@@ -158,49 +223,29 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
158
223
  ],
159
224
  }),
160
225
  ]),
161
- phase("Approve", [
162
- gate(swapGate, {
163
- ...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
164
- plan: stepOutput("verify", "planDigest"),
165
- description:
166
- config.gate?.description ??
167
- `Approve swapping the rebuilt ${config.table} in on ${config.env}: the run record's Verification line has the counts this approval binds`,
168
- ...(config.gate?.approval
169
- ? {
170
- approval: {
171
- ...config.gate.approval,
172
- ...(policy
173
- ? {
174
- context: {
175
- verifiedPartitions: stepOutput("verify", "partitions"),
176
- verifiedRows: stepOutput("verify", "rows"),
177
- ...config.gate.approval.context,
178
- },
179
- }
180
- : {}),
181
- },
182
- }
183
- : {}),
184
- }),
185
- ]),
226
+ ...(outer ? [] : [approve]),
186
227
  phase("Swap", [step("clickhouseRebuildSwap", { profile: "longInfra", outcomeAttribute: { name: "Dependents", from: "dependents" } })]),
187
228
  phase("Retain", [step("clickhouseRebuildRetain", { id: "retain", outcomeAttribute: { name: "RetainUntil", from: "retainUntil" } })]),
188
- phase("Approve drop", [
189
- gate(dropGate, {
190
- ...(config.dropGate?.timeout ? { timeout: config.dropGate.timeout } : {}),
191
- plan: stepOutput("retain", "dropDigest"),
192
- description: config.dropGate?.description ?? `Approve dropping the old ${config.table} once its retention has passed`,
193
- }),
194
- ]),
195
- phase("Drop", [step("clickhouseRebuildDrop", { outcomeAttribute: { name: "Dropped", from: "dropped" } })]),
229
+ ...(outer
230
+ ? []
231
+ : [
232
+ phase("Approve drop", [
233
+ gate(dropGate, {
234
+ ...(config.dropGate?.timeout ? { timeout: config.dropGate.timeout } : {}),
235
+ plan: stepOutput("retain", "dropDigest"),
236
+ description: config.dropGate?.description ?? `Approve dropping the old ${config.table} once its retention has passed`,
237
+ }),
238
+ ]),
239
+ phase("Drop", [step("clickhouseRebuildDrop", { outcomeAttribute: { name: "Dropped", from: "dropped" } })]),
240
+ ]),
196
241
  ];
197
242
 
198
243
  const op = Op({
199
244
  name: config.name,
200
- overview: `Rebuild ${config.table} on ${config.env}: new table, ${config.dualWrite.mode === "app" ? "writes stopped" : "dual write"}, backfill, verify, gated swap`,
245
+ overview: `Rebuild ${config.table} on ${config.env}: new table, ${config.dualWrite.mode === "app" ? "writes stopped" : "dual write"}, backfill, verify, ${outer ? "swap under the caller's approval" : "gated swap"}`,
201
246
  labels: { Rebuild: "true", Env: config.env, Table: config.table },
202
247
  phases,
203
- onFailure: [phase("Compensate", [step("clickhouseRebuildCompensate")])],
248
+ ...(keep ? {} : { onFailure: [phase("Compensate", [step("clickhouseRebuildCompensate")])] }),
204
249
  });
205
250
  return { op };
206
251
  }
@@ -15,6 +15,7 @@ import { clickhouseQuery } from "../http";
15
15
  import type { ClickHouseTarget } from "../live/bind";
16
16
  import { ident, sqlString } from "../apply/statements";
17
17
  import type { CopiedColumn, RebuildNames } from "./observe";
18
+ import type { Sharding } from "./shards";
18
19
 
19
20
  /** The old table's active partitions, by id. */
20
21
  export async function sourcePartitions(target: ClickHouseTarget, names: RebuildNames): Promise<string[]> {
@@ -26,6 +27,16 @@ export async function sourcePartitions(target: ClickHouseTarget, names: RebuildN
26
27
  return rows.map((r) => r.partition_id);
27
28
  }
28
29
 
30
+ /** The old table's active partitions on each shard of a cluster (#3663), through this server. */
31
+ export async function shardPartitions(run: { target: ClickHouseTarget }, sharding: Sharding, database: string, table: string): Promise<Array<{ shard: number; partition: string }>> {
32
+ const rows = await clickhouseQuery<{ shard: number | string; partition_id: string }>(
33
+ run.target.endpoint,
34
+ `SELECT _shard_num AS shard, partition_id FROM cluster(${sqlString(sharding.cluster)}, system.parts) ` +
35
+ `WHERE database = ${sqlString(database)} AND table = ${sqlString(table)} AND active GROUP BY shard, partition_id ORDER BY shard, partition_id`,
36
+ );
37
+ return rows.map((r) => ({ shard: Number(r.shard), partition: r.partition_id }));
38
+ }
39
+
29
40
  /** Rename the columns an expression names, old name to new, leaving function names and qualified names alone. */
30
41
  export function renameColumns(expr: string, copied: readonly CopiedColumn[]): string {
31
42
  let out = expr;