@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
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The rebuild migration on a cluster of two shards (#3663), against a
3
+ * scratch cluster of the pinned image: shard 1 of two replicas, shard 2 of
4
+ * one server, Keeper, and a cluster `chant` over all three. The profile
5
+ * names shard 1's first replica with topology `cluster:chant`.
6
+ *
7
+ * Each shard holds its own rows of `shop.events`, with a `Distributed`
8
+ * table over them. An `ORDER BY` change runs as a `ClickHouseRebuildOp`
9
+ * under an outer approval, keeping its new table on failure:
10
+ *
11
+ * - the first run copies shard 1's partitions and one of shard 2's, then
12
+ * fails: each (shard, partition) copied has its own receipt;
13
+ * - the rerun skips those, copies the rest of shard 2, verifies every row of
14
+ * both shards, and swaps: every shard's rows are in its live table, on
15
+ * every replica, with the new sorting key.
16
+ *
17
+ * Runs the Op through core's local executor. Needs Docker; skips cleanly
18
+ * 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 { normalizeApply } from "@intentius/chant/apply";
26
+ import { memoryGateLedgerPort, runOpLocally, loadProfiles, OpRunFailure, type ActivityFn, type OpConfig, type OpRunResult } from "@intentius/chant/op";
27
+ import { dockerAvailable, startScratchCluster, type ScratchCluster } from "../container";
28
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
29
+ import { clickhouseImage } from "../../spec/pin";
30
+ import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
31
+ import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
32
+ import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
33
+ import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
34
+
35
+ const enabled = await dockerAvailable();
36
+ let cluster: ScratchCluster | undefined;
37
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-rebuild-sharded-"));
38
+
39
+ beforeAll(async () => {
40
+ if (!enabled) return;
41
+ cluster = await startScratchCluster(clickhouseImage(), { shards: [2, 1], cluster: "chant", namePrefix: "chant-sql-rebuild-shards" });
42
+ }, 600_000);
43
+
44
+ afterAll(async () => {
45
+ await cluster?.stop();
46
+ rmSync(dir, { recursive: true, force: true });
47
+ });
48
+
49
+ const MARKER = { stack: "e2e", env: "test" };
50
+ const config = () => ({ ownership: MARKER, sql: { profiles: { e2e: { url: cluster!.replicas[0]!.url, topology: "cluster:chant" } } } });
51
+ const deps = (extra: Partial<ClickHouseRebuildDeps> = {}): ClickHouseRebuildDeps => ({ config: config(), env: {}, log: () => undefined, ...extra });
52
+ const q = <T = Record<string, unknown>>(endpoint: ClickHouseEndpoint, sql: string) => clickhouseQuery<T>(endpoint, sql);
53
+ const count = async (endpoint: ClickHouseEndpoint, table: string) => Number((await q<{ n: string }>(endpoint, `SELECT count() AS n FROM ${table}`))[0]!.n);
54
+ /** The servers of a shard, by its macro. */
55
+ const servers = (shard: string) => cluster!.layout.flatMap((l, i) => (l.shard === shard ? [cluster!.replicas[i]!] : []));
56
+
57
+ const eventsDdl = (orderBy: string) => `CREATE TABLE shop.events
58
+ (
59
+ ts DateTime,
60
+ user_id UInt64,
61
+ kind LowCardinality(String)
62
+ )
63
+ ENGINE = MergeTree
64
+ PARTITION BY toDate(ts)
65
+ ORDER BY ${orderBy}`;
66
+ const DISTRIBUTED = "CREATE TABLE shop.events_all (ts DateTime, user_id UInt64, kind LowCardinality(String)) ENGINE = Distributed(chant, shop, events, user_id)";
67
+
68
+ function writeBuild(file: string, orderBy: string): string {
69
+ const objects = [
70
+ { export: "shop", type: "ClickHouse::Database", ddl: "CREATE DATABASE shop", dependsOn: [] },
71
+ { export: "events", type: "ClickHouse::Table", ddl: eventsDdl(orderBy), dependsOn: ["shop"] },
72
+ { export: "eventsAll", type: "ClickHouse::Table", ddl: DISTRIBUTED, dependsOn: ["events"] },
73
+ ];
74
+ writeFileSync(join(dir, file), JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
75
+ return file;
76
+ }
77
+
78
+ const opConfig = (): ClickHouseRebuildOpConfig => ({
79
+ name: "rebuild-events",
80
+ env: "e2e",
81
+ table: "shop.events",
82
+ dualWrite: { mode: "materialized-view", cutoverColumn: "ts", cutoverDelay: "2s" },
83
+ build: false,
84
+ path: dir,
85
+ output: "events-v2.json",
86
+ stack: MARKER.stack,
87
+ ownershipEnv: MARKER.env,
88
+ gates: "outer",
89
+ onFailure: "keep",
90
+ });
91
+
92
+ async function runOp(extra: Partial<ClickHouseRebuildDeps> = {}): Promise<OpRunResult> {
93
+ const props = (ClickHouseRebuildOp(opConfig()).op as unknown as { props: OpConfig }).props;
94
+ const activities = new Map<string, ActivityFn>();
95
+ for (const [name, fn] of Object.entries(rebuildActivities)) {
96
+ if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
97
+ activities.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) => (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps(extra))) as ActivityFn);
98
+ }
99
+ // One attempt per step, so the failure below fails the run where it happens.
100
+ const profiles = Object.fromEntries(Object.entries(await loadProfiles()).map(([k, v]) => [k, { ...v, retry: { maximumAttempts: 1 } }]));
101
+ return runOpLocally(props, activities, profiles, undefined, { gates: memoryGateLedgerPort(), now: new Date().toISOString() });
102
+ }
103
+
104
+ const outcome = (r: OpRunResult, name: string) => r.records.flatMap((x) => x.outcomes ?? []).find((o) => o.name === name)?.value;
105
+
106
+ describe.skipIf(!enabled)("a rebuild on a cluster of two shards (#3663)", () => {
107
+ test("every shard's rows survive the rebuild, and a rerun after a failure on shard 2 skips what was copied", async () => {
108
+ const applied = normalizeApply(toApplyResult(await clickhouseApply({ buildPath: join(dir, writeBuild("events-v1.json", "(ts, user_id)")), environment: "e2e" }, undefined, deps())));
109
+ expect(applied.notAttempted).toEqual([]);
110
+ // Each shard's own rows, three days of them, written on that shard.
111
+ const [s1] = servers("s1");
112
+ const [s2] = servers("s2");
113
+ await q(s1!, "INSERT INTO shop.events SELECT toDateTime('2026-02-01 00:00:00') + (number % 3) * 86400 + number, number, 'view' FROM numbers(30)");
114
+ await q(s2!, "INSERT INTO shop.events SELECT toDateTime('2026-02-01 00:00:00') + (number % 3) * 86400 + number, 1000 + number, 'click' FROM numbers(24)");
115
+ expect(await count(s1!, "shop.events_all")).toBe(54);
116
+ writeBuild("events-v2.json", "(user_id, ts)");
117
+
118
+ // Shard 2's first partition is copied and its receipt written, then the backfill fails.
119
+ const failure = await runOp({
120
+ backfill: {
121
+ afterPartition: (_p, shard) => {
122
+ if (shard === 2) throw new Error("the backfill failed on shard 2");
123
+ },
124
+ },
125
+ }).then(
126
+ () => undefined,
127
+ (e: unknown) => e,
128
+ );
129
+ expect(failure).toBeInstanceOf(OpRunFailure);
130
+ expect((failure as OpRunFailure).result.records.find((x) => x.fn === "clickhouseRebuildBackfill")?.error).toMatch(/failed on shard 2/);
131
+ const receipts = async () =>
132
+ (await q<{ address: string }>(s1!, "SELECT DISTINCT address FROM chant_receipts.receipts WHERE address LIKE 'e2e/test/rebuild/shop.events/%' ORDER BY address")).map((r) => r.address);
133
+ expect(await receipts()).toEqual([
134
+ "e2e/test/rebuild/shop.events/shard1/20260201",
135
+ "e2e/test/rebuild/shop.events/shard1/20260202",
136
+ "e2e/test/rebuild/shop.events/shard1/20260203",
137
+ "e2e/test/rebuild/shop.events/shard2/20260201",
138
+ ]);
139
+ // Each shard's new table holds that shard's copied rows, and nothing of the other's.
140
+ expect(await count(s1!, "shop.events__chant_new")).toBe(30);
141
+ expect(await count(s2!, "shop.events__chant_new")).toBe(8);
142
+
143
+ // The rerun resumes: four (shard, partition) copies skipped, two copied, every row verified, swapped.
144
+ const r = await runOp();
145
+ expect(r.status).toBe("ok");
146
+ expect(outcome(r, "Skipped")).toBe(4);
147
+ expect(outcome(r, "Copied")).toBe(2);
148
+ expect(outcome(r, "VerifiedRows")).toBe(54);
149
+ expect(outcome(r, "VerifiedPartitions")).toBe(6);
150
+
151
+ for (const [shard, rows] of [["s1", 30], ["s2", 24]] as const) {
152
+ for (const server of servers(shard)) {
153
+ await q(server, "SYSTEM SYNC REPLICA shop.events");
154
+ expect(await count(server, "shop.events")).toBe(rows);
155
+ expect((await q<{ statement: string }>(server, "SHOW CREATE TABLE shop.events"))[0]!.statement).toMatch(/ORDER BY \(user_id, ts\)/);
156
+ expect(await count(server, "shop.events__chant_old")).toBe(rows);
157
+ }
158
+ }
159
+ expect(await count(s1!, "shop.events_all")).toBe(54);
160
+ }, 600_000);
161
+ });
@@ -10,6 +10,10 @@
10
10
  * - Rows dated after the cut-over (sql-yodeler#45): the ones the table held
11
11
  * before the view are copied by the backfill, the ones written during the
12
12
  * rebuild arrive through the view, and the swap leaves none behind.
13
+ * - Under an outer approval with the new table kept on failure (#3658): a
14
+ * backfill that fails part way leaves the new table and its receipts, and
15
+ * the next run skips the partitions already copied, verifies and swaps
16
+ * with no gate, and keeps the old table.
13
17
  * - An interrupted backfill: stopped after three partitions, then killed
14
18
  * between a partition's INSERT and its receipt, then resumed: the receipts
15
19
  * skip what was copied, the half-copied partition is cleared first, and the
@@ -31,6 +35,7 @@ import { clickhouseImage } from "../../spec/pin";
31
35
  import { planAgainstServer } from "../plan/commands";
32
36
  import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
33
37
  import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
38
+ import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
34
39
  import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
35
40
  import { clickhouseReceiptStore, RECEIPTS_DATABASE, RECEIPTS_TABLE, type ClickHouseReceiptStore } from "./receipts";
36
41
 
@@ -68,20 +73,20 @@ const exists = async (database: string, name: string) => Number((await q<{ n: st
68
73
  const showCreate = async (name: string) => (await q<{ statement: string }>(`SHOW CREATE TABLE ${name}`))[0]!.statement;
69
74
 
70
75
  /** The rebuild activities, with the test's config, server and a quiet log, as the local executor resolves them. */
71
- function activities(): Map<string, ActivityFn> {
76
+ function activities(extra: Partial<ClickHouseRebuildDeps> = {}): Map<string, ActivityFn> {
72
77
  const map = new Map<string, ActivityFn>();
73
78
  for (const [name, fn] of Object.entries(rebuildActivities)) {
74
79
  if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
75
80
  map.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) =>
76
- (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps())) as ActivityFn);
81
+ (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, { ...deps(), ...extra })) as ActivityFn);
77
82
  }
78
83
  return map;
79
84
  }
80
85
 
81
- async function runOp(config: ClickHouseRebuildOpConfig, gates: GateLedgerPort): Promise<OpRunResult> {
86
+ async function runOp(config: ClickHouseRebuildOpConfig, gates: GateLedgerPort, extra: Partial<ClickHouseRebuildDeps> = {}): Promise<OpRunResult> {
82
87
  const { op } = ClickHouseRebuildOp(config);
83
88
  const props = (op as unknown as { props: OpConfig }).props;
84
- return runOpLocally(props, activities(), await loadProfiles(), undefined, { gates, now: new Date().toISOString() });
89
+ return runOpLocally(props, activities(extra), await loadProfiles(), undefined, { gates, now: new Date().toISOString() });
85
90
  }
86
91
 
87
92
  /** An in-memory gate ledger a person approves on: each approval answers the gate the last run stopped at, for the plan it recorded. */
@@ -454,3 +459,104 @@ describe.skipIf(!enabled)("an interrupted backfill resumes from its receipts", (
454
459
  expect(dropped.dropped).toEqual(["shop.page_views__chant_new"]);
455
460
  }, 300_000);
456
461
  });
462
+
463
+ describe.skipIf(!enabled)("under an outer approval, a failed rebuild keeps its new table and the rerun resumes (#3658)", () => {
464
+ const sessionsDdl = (orderBy: string) => `CREATE TABLE shop.sessions
465
+ (
466
+ day Date,
467
+ user_id UInt64,
468
+ ts DateTime
469
+ )
470
+ ENGINE = MergeTree
471
+ PARTITION BY day
472
+ ORDER BY ${orderBy}`;
473
+ const V1: Obj = { export: "sessions", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: sessionsDdl("(day, user_id)") };
474
+ const V2: Obj = { ...V1, ddl: sessionsDdl("(user_id, day)") };
475
+ const config = (): ClickHouseRebuildOpConfig => ({
476
+ name: "rebuild-sessions",
477
+ env: "e2e",
478
+ table: "shop.sessions",
479
+ dualWrite: { mode: "materialized-view", cutoverColumn: "ts", cutoverDelay: "2s" },
480
+ build: false,
481
+ path: dir,
482
+ output: "sessions-v2.json",
483
+ stack: MARKER.stack,
484
+ ownershipEnv: MARKER.env,
485
+ gates: "outer",
486
+ onFailure: "keep",
487
+ });
488
+ const receipts = async () =>
489
+ (await q<{ address: string }>(`SELECT DISTINCT address FROM ${RECEIPTS_DATABASE}.${RECEIPTS_TABLE} WHERE address LIKE 'e2e/test/rebuild/shop.sessions/%'`)).length;
490
+
491
+ test("the failed run keeps the new table and its receipts; the rerun skips what was copied, swaps without a gate and keeps the old table", async () => {
492
+ await clickhouseApply({ buildPath: join(dir, writeBuild("sessions-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
493
+ // Six days of rows, a partition each, all well before any cut-over.
494
+ await q("INSERT INTO shop.sessions SELECT toDate('2026-02-01') + number % 6, number, toDateTime('2026-02-01 00:00:00') + (number % 6) * 86400 FROM numbers(1200)");
495
+ writeBuild("sessions-v2.json", [DB, V2]);
496
+
497
+ // Every attempt of the backfill step fails after its partition: three attempts, three partitions copied, then the run fails.
498
+ let copied = 0;
499
+ const ledger = new Ledger();
500
+ const failure = await runOp(config(), ledger.port, {
501
+ backfill: {
502
+ afterPartition: () => {
503
+ copied++;
504
+ throw new Error(`the backfill failed after ${copied} partition(s)`);
505
+ },
506
+ },
507
+ }).then(
508
+ () => undefined,
509
+ (e: unknown) => e,
510
+ );
511
+ expect(failure).toBeInstanceOf(OpRunFailure);
512
+ const records = (failure as OpRunFailure).result.records;
513
+ expect(records.find((x) => x.fn === "clickhouseRebuildBackfill")?.error).toMatch(/the backfill failed after 3 partition/);
514
+ // No onFailure ran: the new table, the dual-write view and the receipts are there.
515
+ expect(records.find((x) => x.fn === "clickhouseRebuildCompensate")).toBeUndefined();
516
+ expect(await exists("shop", "sessions__chant_new")).toBe(true);
517
+ expect(await exists("shop", "sessions__chant_dual")).toBe(true);
518
+ expect(await count("shop.sessions__chant_new")).toBe(600);
519
+ expect(await receipts()).toBe(3);
520
+ expect(await showCreate("shop.sessions")).toMatch(/ORDER BY \(day, user_id\)/);
521
+
522
+ // The rerun resumes: three partitions skipped by receipt, three copied, verified, swapped, with no gate.
523
+ const r = await runOp(config(), ledger.port);
524
+ expect(r.status).toBe("ok");
525
+ expect(ledger.port.appended).toEqual([]);
526
+ expect(outcome(r, "Skipped")).toBe(3);
527
+ expect(outcome(r, "Copied")).toBe(3);
528
+ expect(outcome(r, "VerifiedRows")).toBe(1200);
529
+ expect(r.records.map((x) => x.fn)).not.toContain("clickhouseRebuildDrop");
530
+ expect(await showCreate("shop.sessions")).toMatch(/ORDER BY \(user_id, day\)/);
531
+ expect(await count("shop.sessions")).toBe(1200);
532
+ // The old table is kept: dropping it is a run with the Op's own gates.
533
+ expect(await count("shop.sessions__chant_old")).toBe(1200);
534
+ expect(await exists("shop", "sessions__chant_new")).toBe(false);
535
+ expect(await receipts()).toBe(6);
536
+
537
+ // Run with its own gates, the Op has nothing left to swap, and its drop gate binds that old table.
538
+ const ownLedger = new Ledger();
539
+ const own = await runOp({ ...config(), gates: "own" }, ownLedger.port);
540
+ expect(own.status).toBe("gated");
541
+ expect(own.gate).toMatchObject({ gate: "approve-rebuild-sessions" });
542
+ expect(outcome(own, "RebuildState")).toBe("swapped");
543
+ ownLedger.approveLast();
544
+ const drop = await runOp({ ...config(), gates: "own" }, ownLedger.port);
545
+ expect(drop.status).toBe("gated");
546
+ expect(drop.gate).toMatchObject({ gate: "approve-rebuild-sessions-drop" });
547
+ expect(await count("shop.sessions__chant_old")).toBe(1200);
548
+ }, 300_000);
549
+
550
+ test('a refusal says to drop the kept table by hand', async () => {
551
+ // A new table made from another declaration: refused, and nothing will drop it.
552
+ writeBuild("sessions-v3.json", [DB, { ...V1, ddl: sessionsDdl("(ts, user_id)") }]);
553
+ await q("CREATE TABLE shop.sessions__chant_new AS shop.sessions");
554
+ await q(`ALTER TABLE shop.sessions__chant_new MODIFY COMMENT 'x [chant managed-by=chant stack=e2e env=test rebuild=shop.sessions role=new]'`);
555
+ const args = { table: "shop.sessions", buildPath: "sessions-v3.json", environment: "e2e", dualWrite: { mode: "app" as const }, stack: MARKER.stack, ownershipEnv: MARKER.env, cwd: dir };
556
+ await expect(rebuildActivities.clickhouseRebuildCreate({ ...args, keepOnFailure: true }, undefined, deps())).rejects.toThrow(
557
+ /keeps its new table on failure \(onFailure: "keep"\), so nothing drops it: drop shop\.sessions__chant_new/,
558
+ );
559
+ await expect(rebuildActivities.clickhouseRebuildCreate(args, undefined, deps())).rejects.not.toThrow(/onFailure: "keep"/);
560
+ await q("DROP TABLE shop.sessions__chant_new SYNC");
561
+ }, 300_000);
562
+ });
@@ -20,6 +20,8 @@ import * as contractsModule from "../../op/activity-contracts";
20
20
  import * as activitiesModule from "../../op/activities";
21
21
  import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
22
22
  import { copiedColumns } from "./observe";
23
+ import { partitionEffect } from "./backfill";
24
+ import { intoShard, onShard, onShardServers, shardTable } from "./shards";
23
25
  import { renameColumns, sourcePartitionExpression } from "./partitions";
24
26
  import { canonicalObject } from "../plan/normalize";
25
27
  import { diffSchemas } from "../plan/diff";
@@ -79,7 +81,37 @@ describe("ClickHouseRebuildOp", () => {
79
81
  expect(Object.keys(gate.approval.context)).toEqual(["verifiedPartitions", "verifiedRows"]);
80
82
  });
81
83
 
84
+ test('gates: "outer" leaves out both approval gates and the Drop phase, and keeps the verification (#3658)', () => {
85
+ const props = propsOf({ ...BASE, gates: "outer" });
86
+ expect(props.phases.map((p) => p.name)).toEqual(["Build", "Plan", "Create", "Dual write", "Backfill", "Verify", "Swap", "Retain"]);
87
+ expect(props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "gate")).toEqual([]);
88
+ const fns = props.phases.flatMap((p) => p.steps).map((s) => (s as { fn?: string }).fn);
89
+ expect(fns.indexOf("clickhouseRebuildVerify")).toBeLessThan(fns.indexOf("clickhouseRebuildSwap"));
90
+ expect(fns).not.toContain("clickhouseRebuildDrop");
91
+ // Its own onFailure is unchanged.
92
+ expect(props.onFailure?.map((p) => p.steps.map((s) => (s as { fn?: string }).fn))).toEqual([["clickhouseRebuildCompensate"]]);
93
+ expect(props.overview).toContain("the caller's approval");
94
+ });
95
+
96
+ test('gates: "outer" in app mode keeps the writes-stopped gate, which is not an approval (#3658)', () => {
97
+ const props = propsOf({ ...BASE, dualWrite: { mode: "app" }, gates: "outer" });
98
+ const gates = props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "gate") as Array<{ gate: string }>;
99
+ expect(gates.map((g) => g.gate)).toEqual(["rebuild-events-writes-stopped"]);
100
+ });
101
+
102
+ test('onFailure: "keep" has no onFailure and tells every step so (#3658)', () => {
103
+ const props = propsOf({ ...BASE, onFailure: "keep" });
104
+ expect(props.onFailure).toBeUndefined();
105
+ const steps = props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "activity" && (s as { fn: string }).fn.startsWith("clickhouseRebuild")) as Array<{ args: Record<string, unknown> }>;
106
+ expect(steps.length).toBeGreaterThan(0);
107
+ for (const s of steps) expect(s.args.keepOnFailure).toBe(true);
108
+ expect(props.phases.map((p) => p.name)).toContain("Approve");
109
+ expect((propsOf(BASE).phases[1]!.steps[0] as { args: Record<string, unknown> }).args).not.toHaveProperty("keepOnFailure");
110
+ });
111
+
82
112
  test("bad configuration fails when the Op is built", () => {
113
+ expect(() => ClickHouseRebuildOp({ ...BASE, gates: "none" as never })).toThrow(/gates must be/);
114
+ expect(() => ClickHouseRebuildOp({ ...BASE, onFailure: "retry" as never })).toThrow(/onFailure must be/);
83
115
  expect(() => ClickHouseRebuildOp({ ...BASE, table: "shop.events; DROP" })).toThrow(/table must be/);
84
116
  expect(() => ClickHouseRebuildOp({ ...BASE, dualWrite: { mode: "materialized-view", cutoverColumn: "" } })).toThrow(/cutoverColumn/);
85
117
  expect(() => ClickHouseRebuildOp({ ...BASE, dualWrite: { mode: "both" } as never })).toThrow(/dualWrite\.mode/);
@@ -89,19 +121,23 @@ describe("ClickHouseRebuildOp", () => {
89
121
  const contracts = new Map<string, ActivityContract>();
90
122
  collectActivityContracts(contractsModule as unknown as Record<string, unknown>, contracts);
91
123
  const merged = mergeActivityContracts(await loadActivityContracts([]), contracts);
92
- for (const config of [BASE, { ...BASE, dualWrite: { mode: "app" as const }, retain: "3d", replicaTimeout: "30s", stack: "shop", ownershipEnv: "prod" }]) {
124
+ for (const config of [
125
+ BASE,
126
+ { ...BASE, dualWrite: { mode: "app" as const }, retain: "3d", replicaTimeout: "30s", stack: "shop", ownershipEnv: "prod" },
127
+ { ...BASE, gates: "outer" as const, onFailure: "keep" as const },
128
+ ]) {
93
129
  const props = propsOf(config);
94
130
  expect(validateActivitySteps(props, merged)).toEqual([]);
95
131
  expect(validateStepOutputRefs(props, merged)).toEqual([]);
96
132
  }
97
133
  });
98
134
 
99
- test("every rebuild step is an activity the sql lexicon exports, and none is a receipt activity", () => {
135
+ test("every rebuild step is an activity the sql lexicon exports, and the receipt activities are only fallbacks", () => {
100
136
  const exported = new Set(Object.entries(activitiesModule).filter(([, v]) => typeof v === "function").map(([k]) => k));
101
137
  const steps = propsOf(BASE).phases.flatMap((p) => p.steps).filter((s) => s.kind === "activity" && (s as { fn: string }).fn.startsWith("clickhouseRebuild"));
102
138
  for (const s of steps) expect(exported.has((s as { fn: string }).fn)).toBe(true);
103
- expect(exported.has("receiptRead")).toBe(false);
104
- expect(exported.has("receiptWrite")).toBe(false);
139
+ // The receipt activities are exported for effect() steps only as fallbacks (#3657), so they never take over another lexicon's receipt row.
140
+ expect((activitiesModule as { ACTIVITY_FALLBACKS?: readonly string[] }).ACTIVITY_FALLBACKS).toEqual(["receiptRead", "receiptWrite", "receiptStaleness"]);
105
141
  });
106
142
  });
107
143
 
@@ -188,3 +224,19 @@ describe("the plan hands a rebuild to the Op", () => {
188
224
  expect(rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "<env>")[0]!.dualWrite).toEqual({ mode: "app" });
189
225
  });
190
226
  });
227
+
228
+ describe("a rebuild across the shards of a cluster (#3663)", () => {
229
+ const sharding = { cluster: "main", shards: [{ num: 1, macro: "s1", slot: 0 }, { num: 2, macro: "s2", slot: 1 }] };
230
+ const shard2 = sharding.shards[1]!;
231
+
232
+ test("each (shard, partition) is its own receipt; one shard keeps the address it always had", () => {
233
+ expect(partitionEffect("shop.events", "202602")).toBe("rebuild/shop.events/202602");
234
+ expect(partitionEffect("shop.events", "202602", shard2)).toBe("rebuild/shop.events/shard2/202602");
235
+ });
236
+
237
+ test("a shard is read through the cluster, written back with its own sharding key, and cleared by its own macro", () => {
238
+ expect(`SELECT * FROM ${shardTable(sharding, "shop", "events")} WHERE ${onShard(shard2)}`).toBe("SELECT * FROM cluster('main', `shop`, `events`) WHERE _shard_num = 2");
239
+ expect(intoShard(sharding, shard2, "shop", "events__chant_new")).toBe("FUNCTION cluster('main', `shop`, `events__chant_new`, 1)");
240
+ expect(onShardServers(shard2)).toBe("getMacro('shard') = 's2'");
241
+ });
242
+ });
@@ -21,12 +21,10 @@
21
21
  *
22
22
  * The store implements core's `ReceiptStore` seam
23
23
  * (`@intentius/chant/op/receipt-store`), as the sql lexicon's shared
24
- * `SqlReceiptStore` (`../../core/receipts.ts`). It is not exported as the
25
- * `receiptRead` / `receiptWrite` activities: those names are global to a
26
- * run, and a project that also configures aws or k8s would have its
27
- * `effect()` steps read ClickHouse instead of SSM or a ConfigMap. The backfill
28
- * activity reaches the store directly, the way the fly lexicon's release
29
- * component reaches its own.
24
+ * `SqlReceiptStore` (`../../core/receipts.ts`). The backfill activity
25
+ * reaches the store directly, the way the fly lexicon's release component
26
+ * reaches its own. The same table holds the receipts of a sql project's
27
+ * `effect()` steps (`../../receipts.ts`), under their own addresses.
30
28
  *
31
29
  * Write discipline: the backfill writes a partition's receipt after the
32
30
  * partition's copy succeeded, last, and nothing else writes one.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A rebuild across the shards of a cluster (#3663).
3
+ *
4
+ * On a `cluster:<name>` topology the rebuild's DDL runs `ON CLUSTER`, so
5
+ * every server gets the new table, the dual-write view and the swap. The
6
+ * rows do not move that way: each shard holds its own, and a copy run on
7
+ * the profile's server alone copies that server's shard and no other. So
8
+ * with more than one shard, the backfill and the verification work shard by
9
+ * shard, all through the profile's server:
10
+ *
11
+ * - a shard's rows are read with `cluster('<name>', db, t)` and
12
+ * `_shard_num = <n>`, one replica per shard, as the cluster's own
13
+ * configuration reaches it;
14
+ * - they are written back to the same shard with
15
+ * `INSERT INTO FUNCTION cluster('<name>', db, t__chant_new, <slot>)`, a
16
+ * constant sharding key that lands on that shard, inserted in the
17
+ * foreground so the statement returns once the shard has the rows;
18
+ * - a shard's half-copied rows are deleted with an `ALTER ... ON CLUSTER`
19
+ * whose condition holds on that shard's servers only
20
+ * (`getMacro('shard')`, the macro the cluster's Keeper paths already use).
21
+ *
22
+ * Each (shard, partition) is one copy with its own receipt, so a rerun after
23
+ * a failure on one shard skips what every shard already copied.
24
+ *
25
+ * With one shard nothing changes: the replicas of a shard replicate what is
26
+ * written on any of them, which is what the rebuild has always relied on.
27
+ */
28
+
29
+ import { clickhouseQuery } from "../http";
30
+ import type { ClickHouseTarget } from "../live/bind";
31
+ import { ident, sqlString } from "../apply/statements";
32
+ import { RebuildRefusal } from "./observe";
33
+
34
+ export interface Shard {
35
+ /** `_shard_num` / `system.clusters.shard_num`, from 1. */
36
+ num: number;
37
+ /** The shard's `{shard}` macro, the same on each of its replicas. */
38
+ macro: string;
39
+ /** The sharding key that lands on this shard: the sum of the weights of the shards before it. */
40
+ slot: number;
41
+ }
42
+
43
+ export interface Sharding {
44
+ cluster: string;
45
+ shards: Shard[];
46
+ }
47
+
48
+ /** Settings for a copy into one shard: in the foreground, and read from the cluster rather than pushed down to each shard. */
49
+ export const SHARD_INSERT_SETTINGS: Record<string, string> = { distributed_foreground_insert: "1", parallel_distributed_insert_select: "0" };
50
+
51
+ /** Settings for a delete on one shard: its condition is the server's own macro, the same on every replica of the shard. */
52
+ export const SHARD_DELETE_SETTINGS: Record<string, string> = { allow_nondeterministic_mutations: "1", mutations_sync: "2" };
53
+
54
+ /**
55
+ * The shards of the target's cluster, when its topology is `cluster:<name>`
56
+ * and the cluster has more than one. Undefined otherwise: the rebuild works
57
+ * on the profile's server as it always has.
58
+ */
59
+ export async function clusterSharding(target: ClickHouseTarget): Promise<Sharding | undefined> {
60
+ if (target.topology?.kind !== "cluster") return undefined;
61
+ let cluster = target.topology.cluster;
62
+ const macro = /^\{(\w+)\}$/.exec(cluster);
63
+ if (macro) {
64
+ const [row] = await clickhouseQuery<{ c: string }>(target.endpoint, `SELECT getMacro(${sqlString(macro[1]!)}) AS c`);
65
+ cluster = row?.c ?? cluster;
66
+ }
67
+ const weights = await clickhouseQuery<{ shard: number | string; weight: number | string }>(
68
+ target.endpoint,
69
+ `SELECT shard_num AS shard, any(shard_weight) AS weight FROM system.clusters WHERE cluster = ${sqlString(cluster)} GROUP BY shard_num ORDER BY shard_num`,
70
+ );
71
+ if (weights.length <= 1) return undefined;
72
+ const macros = new Map(
73
+ (await clickhouseQuery<{ shard: number | string; macro: string }>(target.endpoint, `SELECT _shard_num AS shard, getMacro('shard') AS macro FROM cluster(${sqlString(cluster)}, system.one)`)).map(
74
+ (r) => [Number(r.shard), r.macro],
75
+ ),
76
+ );
77
+ let slot = 0;
78
+ const shards: Shard[] = [];
79
+ for (const w of weights) {
80
+ const num = Number(w.shard);
81
+ const m = macros.get(num);
82
+ if (m === undefined) throw new RebuildRefusal(`cluster ${cluster}: shard ${num} has no {shard} macro, which a rebuild across shards needs to delete a half-copied partition on that shard alone`);
83
+ shards.push({ num, macro: m, slot });
84
+ slot += Number(w.weight) || 1;
85
+ }
86
+ const distinct = new Set(shards.map((s) => s.macro));
87
+ if (distinct.size !== shards.length) {
88
+ throw new RebuildRefusal(`cluster ${cluster}: two shards share a {shard} macro (${shards.map((s) => `${s.num}=${s.macro}`).join(", ")}); each shard needs its own`);
89
+ }
90
+ return { cluster, shards };
91
+ }
92
+
93
+ /** One shard's rows of `database.table`, as a table expression; filter with {@link onShard}. */
94
+ export const shardTable = (sharding: Sharding, database: string, table: string): string => `cluster(${sqlString(sharding.cluster)}, ${ident(database)}, ${ident(table)})`;
95
+
96
+ /** The condition that keeps one shard's rows of a {@link shardTable}. */
97
+ export const onShard = (shard: Shard): string => `_shard_num = ${shard.num}`;
98
+
99
+ /** Where a copy into one shard of `database.table` writes. */
100
+ export const intoShard = (sharding: Sharding, shard: Shard, database: string, table: string): string =>
101
+ `FUNCTION cluster(${sqlString(sharding.cluster)}, ${ident(database)}, ${ident(table)}, ${shard.slot})`;
102
+
103
+ /** The condition, in an `ALTER ... ON CLUSTER` mutation, that holds on that shard's servers only. */
104
+ export const onShardServers = (shard: Shard): string => `getMacro('shard') = ${sqlString(shard.macro)}`;
@@ -17,6 +17,7 @@ import { waitForMutations } from "../apply/mutations";
17
17
  import { renderFor } from "../topology";
18
18
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
19
19
  import { assertSameRows } from "./verify";
20
+ import type { Sharding } from "./shards";
20
21
  import {
21
22
  changesBetween,
22
23
  observeRebuild,
@@ -45,6 +46,8 @@ export interface RebuildRun {
45
46
  signal?: AbortSignal;
46
47
  /** The run's id, recorded on the receipts it writes. */
47
48
  runId?: string;
49
+ /** On a cluster of more than one shard, its shards: the backfill and the verification work shard by shard (`./shards.ts`). */
50
+ sharding?: Sharding;
48
51
  }
49
52
 
50
53
  /** The server's clock, in milliseconds: the cut-over and the retention are the server's times, not this machine's. */
@@ -76,6 +79,7 @@ const q = (run: RebuildRun, statement: string) => {
76
79
 
77
80
  export async function waitOn(run: RebuildRun, database: string, table: string): Promise<void> {
78
81
  const ids = await waitForMutations(run.target.endpoint, database, table, {
82
+ ...(run.sharding ? { source: `clusterAllReplicas(${sqlString(run.sharding.cluster)}, system.mutations)` } : {}),
79
83
  ...(run.mutationTimeoutMs !== undefined ? { timeoutMs: run.mutationTimeoutMs } : {}),
80
84
  ...(run.signal ? { signal: run.signal } : {}),
81
85
  });
@@ -24,6 +24,10 @@
24
24
  * later than the cut-over delay, with a time before the cut-over) stop the
25
25
  * swap too, rather than stay behind in the old table.
26
26
  *
27
+ * On a cluster of more than one shard (#3663) every shard is read, through
28
+ * the profile's server, and each partition is compared per shard
29
+ * (`shard<n>/<partition>`), so a shard the copy missed is a difference.
30
+ *
27
31
  * Any difference fails the run, naming the partitions; the run's onFailure
28
32
  * then drops the new table. On a match the result carries the digest the
29
33
  * swap gate binds: the rebuild's plan and the counts and checksums of the
@@ -39,6 +43,7 @@ import { ident, sqlString } from "../apply/statements";
39
43
  import { rebuildPlanSubject, RebuildRefusal, type RebuildObservation } from "./observe";
40
44
  import { sourcePartitionExpression } from "./partitions";
41
45
  import { cutoverOf, observe, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
46
+ import { shardTable } from "./shards";
42
47
 
43
48
  export interface PartitionCheck {
44
49
  partition: string;
@@ -120,11 +125,15 @@ async function compareTables(
120
125
  const newBefore = cut && cutover !== undefined ? `${ident(cut.name)} < ${utcLiteral(cutover)}` : "1";
121
126
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
122
127
 
123
- const read = async (table: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
128
+ // Across the shards of a cluster (#3663), every shard's rows, each partition counted per shard as `shard<n>/<partition>`.
129
+ const sharding = run.sharding;
130
+ const read = async (table: string, name: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
131
+ const from = sharding ? shardTable(sharding, n.database, name) : table;
132
+ const key = sharding ? `concat('shard', toString(_shard_num), '/', ${partition})` : partition;
124
133
  const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
125
134
  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`,
135
+ `SELECT ${key} AS p, count() AS rows, toString(sum(${hash})) AS checksum, countIf(${before}) AS brows, toString(sumIf(${hash}, ${before})) AS bchecksum ` +
136
+ `FROM ${from} GROUP BY p`,
128
137
  );
129
138
  const side: SideCounts = { all: new Map(), before: new Map() };
130
139
  for (const r of rows) {
@@ -141,8 +150,8 @@ async function compareTables(
141
150
  await syncReplica(run, o, n.database, n.name);
142
151
  await syncReplica(run, o, n.database, n.newName);
143
152
  // 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);
153
+ const before = await read(n.table, n.name, "_partition_id", oldHash, oldBefore);
154
+ const after = await read(n.newTable, n.newName, fromOld, newHash, newBefore);
146
155
  const mismatches = differences(before.all, after.all);
147
156
  if (mismatches.length === 0 || attempt >= attempts) return { old: before, new: after, mismatches };
148
157
  run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
package/src/config.ts CHANGED
@@ -115,6 +115,13 @@ export const sqlProfileSchema = z.strictObject({
115
115
  schemas: z.array(z.string()).optional(),
116
116
  /** Postgres: the schema an unqualified declaration is created in. `public` when omitted. */
117
117
  defaultSchema: z.string().optional(),
118
+ /**
119
+ * Postgres: the schema `effect()` receipts are kept in, as
120
+ * `<schema>.__chant_receipts`. When omitted they are in
121
+ * `chant_receipts.receipts`, a schema chant creates; name an existing schema
122
+ * on a server where the role cannot `CREATE SCHEMA`.
123
+ */
124
+ receiptsSchema: z.string().optional(),
118
125
  /**
119
126
  * The managed service this environment's Postgres runs on, when it differs
120
127
  * from `sql.provider`. Import reads the provider's own roles, schemas and
@@ -10,9 +10,11 @@
10
10
  *
11
11
  * A dialect's store implements this interface over its own client and keeps
12
12
  * the receipts in a table whose comment carries the ownership trailer with
13
- * the `receipts` key (`./ownership.ts`), so schema reads leave it out. It is
14
- * not registered as the global `receiptRead` / `receiptWrite` activities,
15
- * which would take over every other lexicon's `effect()` steps in the run.
13
+ * the `receipts` key (`./ownership.ts`), so schema reads leave it out. A
14
+ * migration's store is reached by its backfill directly. The store `effect()`
15
+ * steps use (`../receipts.ts`) is registered as the `receiptRead` /
16
+ * `receiptWrite` activities only as a fallback, so it never takes over
17
+ * another configured lexicon's receipt row.
16
18
  */
17
19
 
18
20
  import type { ReceiptStore } from "@intentius/chant/op/receipt-store";