@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.
Files changed (70) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +6 -1
  2. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts +8 -0
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/live/bind.d.ts +11 -1
  6. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/commands.d.ts +7 -1
  8. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  10. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/schema.d.ts +4 -3
  13. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/backfill.d.ts +17 -0
  15. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/observe.d.ts +4 -3
  17. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  18. package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
  19. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  20. package/dist/clickhouse/rebuild/steps.d.ts +5 -3
  21. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/verify.d.ts +41 -18
  23. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  24. package/dist/clickhouse/topology.d.ts +12 -0
  25. package/dist/clickhouse/topology.d.ts.map +1 -1
  26. package/dist/clickhouse.d.ts +1 -0
  27. package/dist/clickhouse.d.ts.map +1 -1
  28. package/dist/codegen/classifier-page.d.ts.map +1 -1
  29. package/dist/config.d.ts +46 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/core/statements.d.ts +2 -0
  32. package/dist/core/statements.d.ts.map +1 -1
  33. package/dist/integrity.json +2 -2
  34. package/dist/manifest.json +1 -1
  35. package/dist/migration-statements.d.ts +8 -0
  36. package/dist/migration-statements.d.ts.map +1 -1
  37. package/dist/postgres/plan/deep.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/clickhouse/apply/apply.ts +10 -3
  40. package/src/clickhouse/apply/statements.ts +19 -0
  41. package/src/clickhouse/live/bind.ts +13 -1
  42. package/src/clickhouse/live/live.test.ts +10 -0
  43. package/src/clickhouse/plan/commands.ts +42 -12
  44. package/src/clickhouse/plan/deep.ts +2 -1
  45. package/src/clickhouse/plan/diff.ts +3 -1
  46. package/src/clickhouse/plan/normalize.ts +11 -2
  47. package/src/clickhouse/plan/schema.ts +6 -5
  48. package/src/clickhouse/rebuild/backfill.ts +127 -5
  49. package/src/clickhouse/rebuild/observe.ts +4 -3
  50. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
  51. package/src/clickhouse/rebuild/receipts.ts +14 -4
  52. package/src/clickhouse/rebuild/steps.ts +22 -9
  53. package/src/clickhouse/rebuild/verify.ts +131 -36
  54. package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
  55. package/src/clickhouse/topology.e2e.test.ts +84 -1
  56. package/src/clickhouse/topology.test.ts +33 -0
  57. package/src/clickhouse/topology.ts +26 -0
  58. package/src/clickhouse.ts +16 -0
  59. package/src/codegen/classifier-page.ts +2 -0
  60. package/src/config.ts +33 -0
  61. package/src/core/statements.ts +2 -0
  62. package/src/migration-statements.test.ts +120 -1
  63. package/src/migration-statements.ts +13 -3
  64. package/src/op/activities/clickhouse-apply.ts +1 -1
  65. package/src/op/activities/clickhouse-rebuild.ts +1 -1
  66. package/src/op/apply-gate-create.e2e.test.ts +280 -0
  67. package/src/plugin.test.ts +9 -0
  68. package/src/postgres/plan/deep.test.ts +76 -0
  69. package/src/postgres/plan/deep.ts +13 -2
  70. package/src/postgres/plan/plan.e2e.test.ts +26 -0
@@ -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.
@@ -7,6 +7,9 @@
7
7
  * table, keeps the old table, and drops it at the second gate.
8
8
  * - A failure: rows that land in the old table after the backfill fail the
9
9
  * verification, and onFailure drops the new table.
10
+ * - Rows dated after the cut-over (sql-yodeler#45): the ones the table held
11
+ * before the view are copied by the backfill, the ones written during the
12
+ * rebuild arrive through the view, and the swap leaves none behind.
10
13
  * - An interrupted backfill: stopped after three partitions, then killed
11
14
  * between a partition's INSERT and its receipt, then resumed: the receipts
12
15
  * skip what was copied, the half-copied partition is cleared first, and the
@@ -174,7 +177,7 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
174
177
  expect(outcome(r, "RebuildState")).toBe("rebuild");
175
178
  expect(outcome(r, "VerifiedRows")).toBe(1600);
176
179
  expect(outcome(r, "VerifiedPartitions")).toBe(4);
177
- expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) before the cut-over/);
180
+ expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) \(1600 of them before the cut-over/);
178
181
  expect(await count("shop.events__chant_new")).toBe(1600);
179
182
  expect(await exists("shop", "events__chant_dual")).toBe(true);
180
183
  // Nothing visible to a plan or an apply: the working objects are left out.
@@ -235,6 +238,108 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
235
238
  }, 300_000);
236
239
  });
237
240
 
241
+ describe.skipIf(!enabled)("rows dated after the cut-over that the table already held (sql-yodeler#45)", () => {
242
+ const bookingsDdl = (orderBy: string) => `CREATE TABLE shop.bookings
243
+ (
244
+ id UInt64,
245
+ kind LowCardinality(String),
246
+ at DateTime
247
+ )
248
+ ENGINE = MergeTree
249
+ PARTITION BY toYYYYMM(at)
250
+ ORDER BY ${orderBy}`;
251
+ const V1: Obj = { export: "bookings", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: bookingsDdl("(kind, at)") };
252
+ const V2: Obj = { ...V1, ddl: bookingsDdl("(kind, id)") };
253
+ const config = (): ClickHouseRebuildOpConfig => ({
254
+ name: "rebuild-bookings",
255
+ env: "e2e",
256
+ table: "shop.bookings",
257
+ dualWrite: { mode: "materialized-view", cutoverColumn: "at", cutoverDelay: "2s" },
258
+ build: false,
259
+ path: dir,
260
+ output: "bookings-v2.json",
261
+ retain: "0s",
262
+ stack: MARKER.stack,
263
+ ownershipEnv: MARKER.env,
264
+ });
265
+
266
+ test("every row is copied and compared, those written during the rebuild too, and the swap leaves none behind", async () => {
267
+ await clickhouseApply({ buildPath: join(dir, writeBuild("bookings-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
268
+ // 600 rows seven hours apart, from four months ago to about two months
269
+ // ahead, with an identical pair among the future ones.
270
+ await q(
271
+ "INSERT INTO shop.bookings SELECT number, ['view','cart','buy'][number % 3 + 1], toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(number * 7) FROM numbers(600)",
272
+ );
273
+ await q("INSERT INTO shop.bookings VALUES (599, 'buy', toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(599 * 7))");
274
+ const future = Number((await q<{ n: string }>("SELECT count() AS n FROM shop.bookings WHERE at > now() + 60"))[0]!.n);
275
+ expect(future).toBeGreaterThan(150);
276
+ writeBuild("bookings-v2.json", [DB, V2]);
277
+
278
+ const ledger = new Ledger();
279
+ const first = await runOp(config(), ledger.port);
280
+ expect(first.status).toBe("gated");
281
+ expect(first.gate).toMatchObject({ gate: "approve-rebuild-bookings" });
282
+ expect(outcome(first, "VerifiedRows")).toBe(601);
283
+ expect(await count("shop.bookings__chant_new")).toBe(601);
284
+
285
+ // Written during the rebuild, after the cut-over: through the view.
286
+ await q("INSERT INTO shop.bookings VALUES (1000, 'buy', now() + toIntervalDay(40)), (1001, 'cart', now())");
287
+ const again = await runOp(config(), ledger.port);
288
+ expect(again.status).toBe("gated");
289
+ expect(outcome(again, "VerifiedRows")).toBe(603);
290
+ // The rows before the cut-over are what the approval binds; later rows do not change it.
291
+ expect(again.gate!.planDigest).toBe(first.gate!.planDigest);
292
+
293
+ ledger.approveLast();
294
+ await q("INSERT INTO shop.bookings VALUES (1002, 'view', now() + toIntervalDay(70))");
295
+ const swapped = await runOp(config(), ledger.port);
296
+ expect(swapped.status).toBe("gated");
297
+ expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-bookings-drop" });
298
+ expect(await showCreate("shop.bookings")).toMatch(/ORDER BY \(kind, id\)/);
299
+ expect(await count("shop.bookings")).toBe(604);
300
+ expect(await count("shop.bookings__chant_old")).toBe(604);
301
+ const diff = await q("SELECT * FROM shop.bookings__chant_old EXCEPT ALL SELECT * FROM shop.bookings");
302
+ expect(diff).toEqual([]);
303
+ }, 300_000);
304
+
305
+ test("a row that reaches the old table alone after the verification stops the swap", async () => {
306
+ const clicksDdl = (orderBy: string) => `CREATE TABLE shop.visits
307
+ (
308
+ id UInt64,
309
+ at DateTime
310
+ )
311
+ ENGINE = MergeTree
312
+ PARTITION BY toYYYYMM(at)
313
+ ORDER BY ${orderBy}`;
314
+ const W1: Obj = { export: "visits", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("at") };
315
+ const W2: Obj = { ...W1, ddl: clicksDdl("(id, at)") };
316
+ await clickhouseApply({ buildPath: join(dir, writeBuild("visits-v1.json", [DB, W1])), environment: "e2e" }, undefined, deps());
317
+ await q("INSERT INTO shop.visits SELECT number, now() - toIntervalDay(number % 90) FROM numbers(300)");
318
+ writeBuild("visits-v2.json", [DB, W2]);
319
+ const args = {
320
+ table: "shop.visits",
321
+ buildPath: "visits-v2.json",
322
+ environment: "e2e",
323
+ dualWrite: { mode: "materialized-view" as const, cutoverColumn: "at", cutoverDelay: "2s" },
324
+ stack: MARKER.stack,
325
+ ownershipEnv: MARKER.env,
326
+ cwd: dir,
327
+ };
328
+ await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
329
+ await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
330
+ await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
331
+ expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 300 });
332
+
333
+ // Late: a time well before the cut-over, written after the verification.
334
+ await q("INSERT INTO shop.visits VALUES (9999, now() - toIntervalDay(30))");
335
+ await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition/);
336
+ expect(await showCreate("shop.visits")).toMatch(/ORDER BY at/);
337
+ expect(await count("shop.visits")).toBe(301);
338
+ expect(await count("shop.visits__chant_new")).toBe(300);
339
+ await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
340
+ }, 300_000);
341
+ });
342
+
238
343
  describe.skipIf(!enabled)("a failure runs onFailure, which drops the new table", () => {
239
344
  const clicksDdl = (orderBy: string) => `CREATE TABLE shop.clicks (id UInt64, url String) ENGINE = MergeTree ORDER BY ${orderBy}`;
240
345
  const V1: Obj = { export: "clicks", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("id") };
@@ -50,6 +50,7 @@ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
50
50
  import { ident, sqlString } from "../apply/statements";
51
51
  import { CLICKHOUSE_COMMENT_OWNERSHIP_KEYS, RECEIPTS_TRAILER_KEY } from "../ownership";
52
52
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
53
+ import { renderFor, type Topology } from "../topology";
53
54
 
54
55
  /** The database the receipts live in. Its comment marks it as chant's, so schema reads leave it out. */
55
56
  export const RECEIPTS_DATABASE = "chant_receipts";
@@ -79,22 +80,31 @@ export type ClickHouseReceiptStore = SqlReceiptStore;
79
80
  * project's ownership stack and env, which every address starts with.
80
81
  * `replicatedIn` names a Replicated database to keep them in instead, and
81
82
  * `replicaTimeoutMs` how long a read waits for this replica to catch up there.
83
+ * `topology` is the environment's (`../topology.ts`): the database and table
84
+ * are created for it, `ON CLUSTER` and a `Replicated*` engine on a cluster.
85
+ * In a Replicated database they are created for that database whatever it says.
82
86
  */
83
87
  export function clickhouseReceiptStore(
84
88
  endpoint: ClickHouseEndpoint,
85
89
  identity: { stack?: string; env?: string },
86
- opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number } = {},
90
+ opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number; topology?: Topology } = {},
87
91
  ): ClickHouseReceiptStore {
88
92
  const where = receiptsTable(opts.replicatedIn);
93
+ const topology: Topology | undefined = opts.replicatedIn ? { kind: "replicated" } : opts.topology;
89
94
  const TABLE = `${ident(where.database)}.${ident(where.name)}`;
90
95
  let ensured = false;
91
96
  const ensure = async () => {
92
97
  if (ensured) return;
93
- if (!opts.replicatedIn) await clickhouseQuery(endpoint, `CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`);
98
+ if (!opts.replicatedIn) {
99
+ await clickhouseQuery(endpoint, renderFor(`CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`, topology), { settings: DDL_SETTINGS });
100
+ }
94
101
  await clickhouseQuery(
95
102
  endpoint,
96
- `CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
97
- `ENGINE = ${opts.replicatedIn ? "ReplicatedReplacingMergeTree" : "ReplacingMergeTree"}(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`,
103
+ renderFor(
104
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
105
+ `ENGINE = ReplacingMergeTree(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`,
106
+ topology,
107
+ ),
98
108
  { settings: DDL_SETTINGS },
99
109
  );
100
110
  ensured = true;
@@ -14,7 +14,9 @@ import type { ClickHouseTarget } from "../live/bind";
14
14
  import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stampedComment } from "../ownership";
15
15
  import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString, type DeclaredObject } from "../apply/statements";
16
16
  import { waitForMutations } from "../apply/mutations";
17
+ import { renderFor } from "../topology";
17
18
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
19
+ import { assertSameRows } from "./verify";
18
20
  import {
19
21
  changesBetween,
20
22
  observeRebuild,
@@ -60,12 +62,14 @@ export const utcLiteral = (ms: number): string => `toDateTime64(${sqlString(sqlU
60
62
  export const observe = (run: RebuildRun): Promise<RebuildObservation> => observeRebuild(run.target, run.declared, run.marker);
61
63
 
62
64
  /**
63
- * One DDL statement. In a Replicated database it goes on without the
64
- * replicas that are down, which run it from the database's log when they
65
- * come back (`./replicas.ts`).
65
+ * One DDL statement, rendered for the environment's topology when it names
66
+ * one (`../topology.ts`: `ON CLUSTER` and the engine). In a Replicated
67
+ * database it goes on without the replicas that are down, which run it from
68
+ * the database's log when they come back (`./replicas.ts`).
66
69
  */
67
- const q = (run: RebuildRun, sql: string) => {
70
+ const q = (run: RebuildRun, statement: string) => {
68
71
  run.signal?.throwIfAborted();
72
+ const sql = renderFor(statement, run.target.topology);
69
73
  run.log(sql);
70
74
  return clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS, ...(run.signal ? { signal: run.signal } : {}) });
71
75
  };
@@ -152,7 +156,8 @@ export interface DualWriteResult {
152
156
  * old table that writes every row at or after the cut-over into the new
153
157
  * table. The cut-over is the server's time now plus the delay, kept in the
154
158
  * view's own comment (`cutover=`), so a later run and the backfill read it
155
- * back from the server. App mode creates nothing; its gate is the Op's.
159
+ * back from the server. The rows at or after the cut-over that the old table
160
+ * already held are the backfill's (`./backfill.ts`). App mode creates nothing; its gate is the Op's.
156
161
  */
157
162
  export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult> {
158
163
  const o = await observe(run);
@@ -169,7 +174,10 @@ export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult>
169
174
  `(copied: ${o.copied.map((c) => c.name).join(", ")})`,
170
175
  );
171
176
  }
172
- const cutover = (await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m");
177
+ // On a whole second: a DateTime column holds whole seconds, and a row written
178
+ // during the cut-over's second would otherwise carry a time before it and
179
+ // reach the old table after the backfill has started.
180
+ const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m")) / 1000) * 1000;
173
181
  const iso = new Date(cutover).toISOString();
174
182
  const select = o.copied.map((c) => (c.source === c.name ? ident(c.name) : `${ident(c.source)} AS ${ident(c.name)}`)).join(", ");
175
183
  const comment = stampedComment(`chant rebuild of ${o.names.key}: rows at or after the cut-over`, run.marker, working(o.names.key, "dual", { cutover: iso }));
@@ -202,8 +210,9 @@ export interface SwapResult {
202
210
  }
203
211
 
204
212
  /**
205
- * Swap the new table in: `EXCHANGE TABLES`, then drop the dual-write view,
206
- * rename the old table (now under the new table's name) to `t__chant_old`
213
+ * Swap the new table in: compare every row of the two tables once more, as
214
+ * the verification does, and stop on any difference; then `EXCHANGE TABLES`,
215
+ * drop the dual-write view, rename the old table (now under the new table's name) to `t__chant_old`
207
216
  * with its retention date, recreate the materialized views that read the
208
217
  * table, and stamp the table's own comment as declared.
209
218
  *
@@ -235,6 +244,10 @@ export async function swapTables(run: RebuildRun): Promise<SwapResult> {
235
244
  }
236
245
  if (!o.exchanged) {
237
246
  if (!o.newTable) throw new RebuildRefusal(`${n.key}: there is no new table to swap in`);
247
+ // The new table holds every row the old one does, or nothing is swapped
248
+ // (sql-yodeler#45): rows that reached the old table alone since the
249
+ // verification would stay behind in it.
250
+ await assertSameRows(run, o);
238
251
  await q(run, `EXCHANGE TABLES ${n.table} AND ${n.newTable}`);
239
252
  }
240
253
  // The dual-write view now reads the new table by name; drop it before
@@ -373,7 +386,7 @@ export async function compensate(run: Pick<RebuildRun, "target" | "declared" | "
373
386
  const row = rows.find((r) => r.name === name);
374
387
  const pairs = readTrailerPairs(row?.comment);
375
388
  if (!row || pairs?.get(REBUILD_TRAILER_KEY) !== n.key || pairs.get("role") !== role || !carriesMarker(row.comment, run.marker)) continue;
376
- const sql = `DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`;
389
+ const sql = renderFor(`DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`, run.target.topology);
377
390
  run.log(sql);
378
391
  await clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS });
379
392
  dropped.push(`${n.database}.${name}`);
@@ -1,20 +1,36 @@
1
1
  /**
2
- * The verification (#3198): row counts and checksums per partition, the old
3
- * table against the new one, before anyone is asked to approve the swap.
2
+ * The verification (#3198, INTENTIUS/sql-yodeler#45): row counts and
3
+ * checksums per partition, the old table against the new one, before anyone
4
+ * is asked to approve the swap.
4
5
  *
5
6
  * Per partition of the old table: the number of rows and the sum of a
6
7
  * 64-bit hash of each row's copied columns, read in one grouped query on
7
8
  * each side. The old side casts each column to its type in the new table
8
9
  * first, so a column whose type the rebuild changes hashes the same when
9
- * its values survived the change. In materialized-view mode both sides are
10
- * limited to the rows before the cut-over, which are the backfill's; the
11
- * rows after it were written by the dual-write view and keep arriving.
10
+ * its values survived the change.
11
+ *
12
+ * Every row is compared, in both modes. In materialized-view mode the rows
13
+ * at or after the cut-over are in the new table twice over: those the old
14
+ * table held before the view existed, which the backfill copies, and those
15
+ * written since, which the view writes. Comparing only the rows before the
16
+ * cut-over let a swap go ahead without the first kind (sql-yodeler#45).
17
+ * Writes go on while the tables are read, and a row the old table has
18
+ * committed may be a moment from committing in the new one through the view,
19
+ * so the old table is read first and a difference is read again a few times
20
+ * before it counts.
21
+ *
22
+ * The same comparison runs again in the Swap step just before the
23
+ * `EXCHANGE`, so rows that reach the old table alone after this step (a row
24
+ * later than the cut-over delay, with a time before the cut-over) stop the
25
+ * swap too, rather than stay behind in the old table.
12
26
  *
13
27
  * Any difference fails the run, naming the partitions; the run's onFailure
14
28
  * then drops the new table. On a match the result carries the digest the
15
- * swap gate binds: the rebuild's plan and the verification together, so an
16
- * approval is for this plan with these counts, and a changed count needs a
17
- * new approval.
29
+ * swap gate binds: the rebuild's plan and the counts and checksums of the
30
+ * rows before the cut-over, which no later write changes, so an approval
31
+ * is for this plan with these counts, and a changed count needs a new
32
+ * approval. The rows at or after the cut-over keep arriving through the view
33
+ * and are compared, not bound.
18
34
  */
19
35
 
20
36
  import { computePlanDigest } from "@intentius/chant/op";
@@ -34,10 +50,13 @@ export interface VerifyResult {
34
50
  state: RebuildObservation["state"];
35
51
  /** The digest the swap gate binds: plan plus verification. Absent once the swap has run: there is nothing left for that gate to approve. */
36
52
  planDigest?: string;
53
+ /** Partitions of the old table compared. */
37
54
  partitions: number;
55
+ /** Rows compared: every row of the old table. */
38
56
  rows: number;
39
57
  /** One line for the run record and the gate. */
40
58
  summary: string;
59
+ /** Per partition, the rows before the cut-over (every row in app mode): what the swap gate binds. */
41
60
  verification: PartitionCheck[];
42
61
  }
43
62
 
@@ -45,7 +64,7 @@ export interface VerifyResult {
45
64
  export class RebuildVerificationError extends Error {
46
65
  constructor(
47
66
  readonly table: string,
48
- readonly mismatches: Array<{ partition: string; old?: PartitionCheck; new?: PartitionCheck }>,
67
+ readonly mismatches: Mismatch[],
49
68
  ) {
50
69
  const shown = mismatches
51
70
  .slice(0, 10)
@@ -53,23 +72,38 @@ export class RebuildVerificationError extends Error {
53
72
  .join("; ");
54
73
  super(
55
74
  `${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. ` +
56
- `Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause.`,
75
+ `Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause; ` +
76
+ `in app mode, a write the application did not stop.`,
57
77
  );
58
78
  this.name = "RebuildVerificationError";
59
79
  }
60
80
  }
61
81
 
62
- export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
63
- const o = await observe(run);
64
- if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
82
+ /** How often a difference is read again before it counts, and how long apart. */
83
+ const COMPARE_ATTEMPTS = 5;
84
+ const COMPARE_INTERVAL_MS = 1000;
85
+
86
+ /** One side's counts per old partition: every row, and the rows before the cut-over. */
87
+ interface SideCounts {
88
+ all: Map<string, PartitionCheck>;
89
+ before: Map<string, PartitionCheck>;
90
+ }
91
+
92
+ /**
93
+ * Read both tables' counts and checksums per old partition, the old table
94
+ * first, until they agree or `attempts` reads have disagreed. Returns the
95
+ * last read and the partitions that differ in it.
96
+ */
97
+ async function compareTables(
98
+ run: RebuildRun,
99
+ o: RebuildObservation,
100
+ opts: { attempts?: number; intervalMs?: number } = {},
101
+ ): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[] }> {
65
102
  if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
66
103
  const n = o.names;
67
104
  const cutover = cutoverOf(run, o);
68
105
  await waitOn(run, n.database, n.name);
69
106
  await waitOn(run, n.database, n.newName);
70
- // In a Replicated database: count what every replica wrote, not what this one has fetched so far.
71
- await syncReplica(run, o, n.database, n.name);
72
- await syncReplica(run, o, n.database, n.newName);
73
107
 
74
108
  const types = new Map(
75
109
  (
@@ -79,35 +113,81 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
79
113
  )
80
114
  ).map((c) => [c.name, c.type]),
81
115
  );
82
- const oldHash = `sum(cityHash64(toString(tuple(${o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`).join(", ")}))))`;
83
- const newHash = `sum(cityHash64(toString(tuple(${o.copied.map((c) => ident(c.name)).join(", ")}))))`;
116
+ const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
117
+ const newHash = rowHash(o.copied.map((c) => ident(c.name)));
84
118
  const cut = run.dualWrite.mode === "materialized-view" ? o.copied.find((c) => c.name === (run.dualWrite as { cutoverColumn: string }).cutoverColumn) : undefined;
85
- const oldWhere = cut && cutover !== undefined ? ` WHERE ${ident(cut.source)} < ${utcLiteral(cutover)}` : "";
86
- const newWhere = cut && cutover !== undefined ? ` WHERE ${ident(cut.name)} < ${utcLiteral(cutover)}` : "";
119
+ const oldBefore = cut && cutover !== undefined ? `${ident(cut.source)} < ${utcLiteral(cutover)}` : "1";
120
+ const newBefore = cut && cutover !== undefined ? `${ident(cut.name)} < ${utcLiteral(cutover)}` : "1";
87
121
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
88
122
 
89
- const read = async (sql: string) =>
90
- new Map(
91
- (await clickhouseQuery<{ p: string; rows: string | number; checksum: string }>(run.target.endpoint, sql)).map((r) => [
92
- r.p,
93
- { partition: r.p, rows: Number(r.rows), checksum: r.checksum },
94
- ]),
123
+ const read = async (table: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
124
+ const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
125
+ run.target.endpoint,
126
+ `SELECT ${partition} AS p, count() AS rows, toString(sum(${hash})) AS checksum, countIf(${before}) AS brows, toString(sumIf(${hash}, ${before})) AS bchecksum ` +
127
+ `FROM ${table} GROUP BY p`,
95
128
  );
96
- const before = await read(`SELECT _partition_id AS p, count() AS rows, toString(${oldHash}) AS checksum FROM ${n.table}${oldWhere} GROUP BY p`);
97
- const after = await read(`SELECT ${fromOld} AS p, count() AS rows, toString(${newHash}) AS checksum FROM ${n.newTable}${newWhere} GROUP BY p`);
129
+ const side: SideCounts = { all: new Map(), before: new Map() };
130
+ for (const r of rows) {
131
+ side.all.set(r.p, { partition: r.p, rows: Number(r.rows), checksum: r.checksum });
132
+ // A partition with no row before the cut-over is left out, as a WHERE would leave it out.
133
+ if (Number(r.brows) > 0) side.before.set(r.p, { partition: r.p, rows: Number(r.brows), checksum: r.bchecksum });
134
+ }
135
+ return side;
136
+ };
137
+
138
+ const attempts = Math.max(1, opts.attempts ?? COMPARE_ATTEMPTS);
139
+ for (let attempt = 1; ; attempt++) {
140
+ // In a Replicated database: count what every replica wrote, not what this one has fetched so far.
141
+ await syncReplica(run, o, n.database, n.name);
142
+ await syncReplica(run, o, n.database, n.newName);
143
+ // The old table first: a row the view has yet to commit in the new one is read as missing there, never the other way round.
144
+ const before = await read(n.table, "_partition_id", oldHash, oldBefore);
145
+ const after = await read(n.newTable, fromOld, newHash, newBefore);
146
+ const mismatches = differences(before.all, after.all);
147
+ if (mismatches.length === 0 || attempt >= attempts) return { old: before, new: after, mismatches };
148
+ run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
149
+ await sleep(opts.intervalMs ?? COMPARE_INTERVAL_MS, run.signal);
150
+ }
151
+ }
152
+
153
+ export type Mismatch = { partition: string; old?: PartitionCheck; new?: PartitionCheck };
98
154
 
99
- const mismatches: Array<{ partition: string; old?: PartitionCheck; new?: PartitionCheck }> = [];
155
+ function differences(before: Map<string, PartitionCheck>, after: Map<string, PartitionCheck>): Mismatch[] {
156
+ const out: Mismatch[] = [];
100
157
  for (const p of [...new Set([...before.keys(), ...after.keys()])].sort()) {
101
158
  const a = before.get(p);
102
159
  const b = after.get(p);
103
- if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum) mismatches.push({ partition: p, ...(a ? { old: a } : {}), ...(b ? { new: b } : {}) });
160
+ if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum) out.push({ partition: p, ...(a ? { old: a } : {}), ...(b ? { new: b } : {}) });
104
161
  }
105
- if (mismatches.length > 0) throw new RebuildVerificationError(n.key, mismatches);
162
+ return out;
163
+ }
164
+
165
+ /** The 64-bit hash of a row's copied columns, as the backfill and the verification compute it. */
166
+ export const rowHash = (columns: readonly string[]): string => `cityHash64(toString(tuple(${columns.join(", ")})))`;
167
+
168
+ /**
169
+ * Before the swap: the new table holds every row the old one does. Throws a
170
+ * {@link RebuildVerificationError} when it does not, so nothing is swapped.
171
+ */
172
+ export async function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void> {
173
+ const { mismatches } = await compareTables(run, o);
174
+ if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches);
175
+ }
176
+
177
+ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
178
+ const o = await observe(run);
179
+ if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
180
+ const cutover = cutoverOf(run, o);
181
+ const compared = await compareTables(run, o);
182
+ if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches);
106
183
 
107
- const verification = [...before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
108
- const rows = verification.reduce((s, v) => s + v.rows, 0);
109
- const range = cutover !== undefined ? ` before the cut-over at ${new Date(cutover).toISOString()}` : "";
110
- const summary = `${n.key}: ${verification.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
184
+ const all = [...compared.old.all.values()];
185
+ const rows = all.reduce((s, v) => s + v.rows, 0);
186
+ // What the approval binds: the rows before the cut-over (every row in app mode), which later writes do not change.
187
+ const verification = [...compared.old.before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
188
+ const bound = verification.reduce((s, v) => s + v.rows, 0);
189
+ const range = cutover !== undefined ? ` (${bound} of them before the cut-over at ${new Date(cutover).toISOString()})` : "";
190
+ const summary = `${o.names.key}: ${all.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
111
191
  run.log(`-- ${summary}`);
112
192
  return {
113
193
  state: o.state,
@@ -116,9 +196,24 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
116
196
  cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
117
197
  verification,
118
198
  }),
119
- partitions: verification.length,
199
+ partitions: all.length,
120
200
  rows,
121
201
  summary,
122
202
  verification,
123
203
  };
124
204
  }
205
+
206
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
207
+ return new Promise((resolve, reject) => {
208
+ signal?.throwIfAborted();
209
+ const t = setTimeout(resolve, ms);
210
+ signal?.addEventListener(
211
+ "abort",
212
+ () => {
213
+ clearTimeout(t);
214
+ reject(signal.reason);
215
+ },
216
+ { once: true },
217
+ );
218
+ });
219
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The replicated topology against a Replicated database (#3645): two
3
+ * replicas of the pinned server, Keeper embedded in the first (the scratch
4
+ * cluster the Replicated rebuild runs on).
5
+ *
6
+ * The source is written for a single node: a plain database, MergeTree
7
+ * tables. Applied with `CLICKHOUSE_TOPOLOGY=replicated`, the database is a
8
+ * `Replicated` one and its tables `Replicated*MergeTree` with no path, so
9
+ * the second replica gets them from the database's log and rows written on
10
+ * one reach the other. Applied again on the other replica, the server's
11
+ * printed engines (with the default Keeper path filled in) compare equal to
12
+ * the declarations, and nothing is sent; a change applies there and reaches
13
+ * the first.
14
+ *
15
+ * The scratch cluster names no cluster in `remote_servers`, so the second
16
+ * replica joins the database with the `CREATE DATABASE` the applier sent to
17
+ * the first, as an operator would (`replicated:<cluster>` sends it `ON
18
+ * CLUSTER` instead). Needs Docker; skips cleanly without it.
19
+ */
20
+
21
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
25
+ import { dockerAvailable, startScratchCluster, type ScratchCluster } from "./container";
26
+ import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
27
+ import { clickhouseImage } from "../spec/pin";
28
+ import { clickhouseApply } from "../op/activities/clickhouse-apply";
29
+ import { renderStatement } from "./topology";
30
+
31
+ const DB = "yodel_3645";
32
+ const enabled = await dockerAvailable();
33
+ let cluster: ScratchCluster | undefined;
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-repl-"));
35
+
36
+ beforeAll(async () => {
37
+ if (!enabled) return;
38
+ cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-topology-repl" });
39
+ }, 600_000);
40
+
41
+ afterAll(async () => {
42
+ await cluster?.stop();
43
+ rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ interface Obj {
47
+ export: string;
48
+ type: string;
49
+ ddl: string;
50
+ dependsOn?: string[];
51
+ }
52
+
53
+ const writeBuild = (file: string, objects: Obj[]): string => {
54
+ const path = join(dir, file);
55
+ writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
56
+ return path;
57
+ };
58
+
59
+ const objects = (extraColumn = ""): Obj[] => [
60
+ { export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB}` },
61
+ {
62
+ export: "events",
63
+ type: "ClickHouse::Table",
64
+ dependsOn: ["db"],
65
+ ddl: `CREATE TABLE ${DB}.events (id UInt64, ts DateTime, kind String${extraColumn}) ENGINE = ReplacingMergeTree(ts) ORDER BY id`,
66
+ },
67
+ {
68
+ export: "byKind",
69
+ type: "ClickHouse::MaterializedView",
70
+ dependsOn: ["events"],
71
+ ddl: `CREATE MATERIALIZED VIEW ${DB}.by_kind ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
72
+ },
73
+ ];
74
+
75
+ const q = <T = Record<string, unknown>>(at: ClickHouseEndpoint, sql: string) => clickhouseQuery<T>(at, sql);
76
+
77
+ async function apply(at: ClickHouseEndpoint, buildPath: string) {
78
+ const sent: string[] = [];
79
+ const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
80
+ config: { ownership: { stack: "yodel", env: "e2e" } },
81
+ env: { CLICKHOUSE_URL: at.url, CLICKHOUSE_TOPOLOGY: "replicated" },
82
+ log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
83
+ });
84
+ return { outcome, sent };
85
+ }
86
+
87
+ describe.skipIf(!enabled)("the replicated topology on a Replicated database (#3645)", () => {
88
+ test("a single node's source becomes a Replicated database that both replicas hold, and converges on either", async () => {
89
+ const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
90
+ expect(renderStatement(`CREATE DATABASE ${DB}`, { kind: "replicated" })).toBe(`CREATE DATABASE ${DB} ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}')`);
91
+
92
+ const v1 = writeBuild("v1.json", objects());
93
+ const first = await apply(r1, v1);
94
+ expect(first.outcome.failed).toEqual([]);
95
+ // The second replica joins the database with the statement the applier sent to the first, and replays its tables from the log.
96
+ expect(first.sent[0]).toMatch(new RegExp(`^CREATE DATABASE ${DB} ENGINE = Replicated\\('/clickhouse/databases/${DB}', '\\{shard\\}', '\\{replica\\}'\\) COMMENT `));
97
+ await q(r2, first.sent[0]!);
98
+ await q(r2, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
99
+ expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
100
+ expect(first.sent[1]).toMatch(/ENGINE = ReplicatedReplacingMergeTree\(ts\) ORDER BY id/);
101
+
102
+ await q(r1, `INSERT INTO ${DB}.events (id, ts, kind) VALUES (1, now(), 'click'), (2, now(), 'view')`);
103
+ await q(r2, `SYSTEM SYNC REPLICA ${DB}.events`);
104
+ expect(Number((await q<{ n: string | number }>(r2, `SELECT count() AS n FROM ${DB}.events`))[0]!.n)).toBe(2);
105
+ const engines = await q<{ name: string; engine: string }>(r2, `SELECT name, engine FROM system.tables WHERE database = '${DB}' ORDER BY name`);
106
+ expect(engines.map((t) => [t.name.startsWith(".inner") ? ".inner" : t.name, t.engine])).toEqual([
107
+ [".inner", "ReplicatedSummingMergeTree"],
108
+ ["by_kind", "MaterializedView"],
109
+ ["events", "ReplicatedReplacingMergeTree"],
110
+ ]);
111
+ expect((await q<{ engine: string }>(r2, `SELECT engine FROM system.databases WHERE name = '${DB}'`))[0]!.engine).toBe("Replicated");
112
+
113
+ // On the other replica: the printed engines (default Keeper path filled in) are the declarations; nothing to send.
114
+ const again = await apply(r2, v1);
115
+ expect(again.sent).toEqual([]);
116
+ expect(again.outcome.notAttempted).toEqual([]);
117
+
118
+ // A change there reaches the first replica through the database's log.
119
+ const change = await apply(r2, writeBuild("v2.json", objects(", source String DEFAULT 'web'")));
120
+ expect(change.sent).toEqual([`ALTER TABLE \`${DB}\`.\`events\` ADD COLUMN source String DEFAULT 'web' AFTER \`kind\``]);
121
+ await q(r1, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
122
+ const cols = await q<{ name: string }>(r1, `SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
123
+ expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
124
+ }, 300_000);
125
+ });