@intentius/chant-lexicon-sql 0.114.0 → 0.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/clickhouse/apply/mutations.d.ts +7 -1
  2. package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
  3. package/dist/clickhouse/container.d.ts +22 -7
  4. package/dist/clickhouse/container.d.ts.map +1 -1
  5. package/dist/clickhouse/plan/deep.d.ts +5 -0
  6. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  8. package/dist/clickhouse/rebuild/backfill.d.ts +28 -5
  9. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  10. package/dist/clickhouse/rebuild/observe.d.ts +4 -3
  11. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  12. package/dist/clickhouse/rebuild/op.d.ts +29 -0
  13. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
  15. package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
  17. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  18. package/dist/clickhouse/rebuild/shards.d.ts +59 -0
  19. package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
  20. package/dist/clickhouse/rebuild/steps.d.ts +8 -3
  21. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/verify.d.ts +45 -18
  23. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  24. package/dist/config.d.ts +14 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/core/receipts.d.ts +5 -3
  27. package/dist/core/receipts.d.ts.map +1 -1
  28. package/dist/integrity.json +2 -2
  29. package/dist/manifest.json +1 -1
  30. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  31. package/dist/op/activities/index.d.ts +13 -1
  32. package/dist/op/activities/index.d.ts.map +1 -1
  33. package/dist/op/activities/sql-exec.d.ts +41 -0
  34. package/dist/op/activities/sql-exec.d.ts.map +1 -0
  35. package/dist/op/activity-contracts.d.ts +20 -0
  36. package/dist/op/activity-contracts.d.ts.map +1 -1
  37. package/dist/postgres/live/catalog.d.ts +1 -1
  38. package/dist/postgres/live/catalog.d.ts.map +1 -1
  39. package/dist/postgres/migrate/receipts.d.ts +8 -5
  40. package/dist/postgres/migrate/receipts.d.ts.map +1 -1
  41. package/dist/postgres/plan/deep.d.ts.map +1 -1
  42. package/dist/receipts.d.ts +102 -0
  43. package/dist/receipts.d.ts.map +1 -0
  44. package/package.json +2 -2
  45. package/src/clickhouse/apply/mutations.ts +9 -3
  46. package/src/clickhouse/container.ts +39 -11
  47. package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
  48. package/src/clickhouse/plan/deep.ts +102 -3
  49. package/src/clickhouse/plan/normalize.ts +10 -0
  50. package/src/clickhouse/plan/plan.test.ts +10 -0
  51. package/src/clickhouse/rebuild/backfill.ts +190 -26
  52. package/src/clickhouse/rebuild/observe.ts +4 -3
  53. package/src/clickhouse/rebuild/op.ts +80 -35
  54. package/src/clickhouse/rebuild/partitions.ts +11 -0
  55. package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
  56. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +216 -5
  57. package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
  58. package/src/clickhouse/rebuild/receipts.ts +4 -6
  59. package/src/clickhouse/rebuild/shards.ts +104 -0
  60. package/src/clickhouse/rebuild/steps.ts +18 -4
  61. package/src/clickhouse/rebuild/verify.ts +140 -36
  62. package/src/config.ts +7 -0
  63. package/src/core/receipts.ts +5 -3
  64. package/src/op/activities/clickhouse-rebuild.ts +32 -7
  65. package/src/op/activities/index.ts +21 -4
  66. package/src/op/activities/sql-exec.ts +84 -0
  67. package/src/op/activity-contracts.ts +15 -0
  68. package/src/op/effect-receipts.e2e.test.ts +277 -0
  69. package/src/postgres/live/catalog.ts +3 -1
  70. package/src/postgres/migrate/migrate.test.ts +3 -2
  71. package/src/postgres/migrate/receipts.ts +18 -10
  72. package/src/postgres/plan/deep.test.ts +76 -0
  73. package/src/postgres/plan/deep.ts +13 -2
  74. package/src/postgres/plan/plan.e2e.test.ts +26 -0
  75. package/src/receipts.test.ts +91 -0
  76. package/src/receipts.ts +259 -0
  77. package/src/skills/chant-sql-rebuild.md +5 -1
@@ -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;
@@ -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
+ });
@@ -7,6 +7,13 @@
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.
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.
10
17
  * - An interrupted backfill: stopped after three partitions, then killed
11
18
  * between a partition's INSERT and its receipt, then resumed: the receipts
12
19
  * skip what was copied, the half-copied partition is cleared first, and the
@@ -28,6 +35,7 @@ import { clickhouseImage } from "../../spec/pin";
28
35
  import { planAgainstServer } from "../plan/commands";
29
36
  import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
30
37
  import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
38
+ import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
31
39
  import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
32
40
  import { clickhouseReceiptStore, RECEIPTS_DATABASE, RECEIPTS_TABLE, type ClickHouseReceiptStore } from "./receipts";
33
41
 
@@ -65,20 +73,20 @@ const exists = async (database: string, name: string) => Number((await q<{ n: st
65
73
  const showCreate = async (name: string) => (await q<{ statement: string }>(`SHOW CREATE TABLE ${name}`))[0]!.statement;
66
74
 
67
75
  /** The rebuild activities, with the test's config, server and a quiet log, as the local executor resolves them. */
68
- function activities(): Map<string, ActivityFn> {
76
+ function activities(extra: Partial<ClickHouseRebuildDeps> = {}): Map<string, ActivityFn> {
69
77
  const map = new Map<string, ActivityFn>();
70
78
  for (const [name, fn] of Object.entries(rebuildActivities)) {
71
79
  if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
72
80
  map.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) =>
73
- (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);
74
82
  }
75
83
  return map;
76
84
  }
77
85
 
78
- async function runOp(config: ClickHouseRebuildOpConfig, gates: GateLedgerPort): Promise<OpRunResult> {
86
+ async function runOp(config: ClickHouseRebuildOpConfig, gates: GateLedgerPort, extra: Partial<ClickHouseRebuildDeps> = {}): Promise<OpRunResult> {
79
87
  const { op } = ClickHouseRebuildOp(config);
80
88
  const props = (op as unknown as { props: OpConfig }).props;
81
- 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() });
82
90
  }
83
91
 
84
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. */
@@ -174,7 +182,7 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
174
182
  expect(outcome(r, "RebuildState")).toBe("rebuild");
175
183
  expect(outcome(r, "VerifiedRows")).toBe(1600);
176
184
  expect(outcome(r, "VerifiedPartitions")).toBe(4);
177
- expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) before the cut-over/);
185
+ expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) \(1600 of them before the cut-over/);
178
186
  expect(await count("shop.events__chant_new")).toBe(1600);
179
187
  expect(await exists("shop", "events__chant_dual")).toBe(true);
180
188
  // Nothing visible to a plan or an apply: the working objects are left out.
@@ -235,6 +243,108 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
235
243
  }, 300_000);
236
244
  });
237
245
 
246
+ describe.skipIf(!enabled)("rows dated after the cut-over that the table already held (sql-yodeler#45)", () => {
247
+ const bookingsDdl = (orderBy: string) => `CREATE TABLE shop.bookings
248
+ (
249
+ id UInt64,
250
+ kind LowCardinality(String),
251
+ at DateTime
252
+ )
253
+ ENGINE = MergeTree
254
+ PARTITION BY toYYYYMM(at)
255
+ ORDER BY ${orderBy}`;
256
+ const V1: Obj = { export: "bookings", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: bookingsDdl("(kind, at)") };
257
+ const V2: Obj = { ...V1, ddl: bookingsDdl("(kind, id)") };
258
+ const config = (): ClickHouseRebuildOpConfig => ({
259
+ name: "rebuild-bookings",
260
+ env: "e2e",
261
+ table: "shop.bookings",
262
+ dualWrite: { mode: "materialized-view", cutoverColumn: "at", cutoverDelay: "2s" },
263
+ build: false,
264
+ path: dir,
265
+ output: "bookings-v2.json",
266
+ retain: "0s",
267
+ stack: MARKER.stack,
268
+ ownershipEnv: MARKER.env,
269
+ });
270
+
271
+ test("every row is copied and compared, those written during the rebuild too, and the swap leaves none behind", async () => {
272
+ await clickhouseApply({ buildPath: join(dir, writeBuild("bookings-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
273
+ // 600 rows seven hours apart, from four months ago to about two months
274
+ // ahead, with an identical pair among the future ones.
275
+ await q(
276
+ "INSERT INTO shop.bookings SELECT number, ['view','cart','buy'][number % 3 + 1], toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(number * 7) FROM numbers(600)",
277
+ );
278
+ await q("INSERT INTO shop.bookings VALUES (599, 'buy', toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(599 * 7))");
279
+ const future = Number((await q<{ n: string }>("SELECT count() AS n FROM shop.bookings WHERE at > now() + 60"))[0]!.n);
280
+ expect(future).toBeGreaterThan(150);
281
+ writeBuild("bookings-v2.json", [DB, V2]);
282
+
283
+ const ledger = new Ledger();
284
+ const first = await runOp(config(), ledger.port);
285
+ expect(first.status).toBe("gated");
286
+ expect(first.gate).toMatchObject({ gate: "approve-rebuild-bookings" });
287
+ expect(outcome(first, "VerifiedRows")).toBe(601);
288
+ expect(await count("shop.bookings__chant_new")).toBe(601);
289
+
290
+ // Written during the rebuild, after the cut-over: through the view.
291
+ await q("INSERT INTO shop.bookings VALUES (1000, 'buy', now() + toIntervalDay(40)), (1001, 'cart', now())");
292
+ const again = await runOp(config(), ledger.port);
293
+ expect(again.status).toBe("gated");
294
+ expect(outcome(again, "VerifiedRows")).toBe(603);
295
+ // The rows before the cut-over are what the approval binds; later rows do not change it.
296
+ expect(again.gate!.planDigest).toBe(first.gate!.planDigest);
297
+
298
+ ledger.approveLast();
299
+ await q("INSERT INTO shop.bookings VALUES (1002, 'view', now() + toIntervalDay(70))");
300
+ const swapped = await runOp(config(), ledger.port);
301
+ expect(swapped.status).toBe("gated");
302
+ expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-bookings-drop" });
303
+ expect(await showCreate("shop.bookings")).toMatch(/ORDER BY \(kind, id\)/);
304
+ expect(await count("shop.bookings")).toBe(604);
305
+ expect(await count("shop.bookings__chant_old")).toBe(604);
306
+ const diff = await q("SELECT * FROM shop.bookings__chant_old EXCEPT ALL SELECT * FROM shop.bookings");
307
+ expect(diff).toEqual([]);
308
+ }, 300_000);
309
+
310
+ test("a row that reaches the old table alone after the verification stops the swap", async () => {
311
+ const clicksDdl = (orderBy: string) => `CREATE TABLE shop.visits
312
+ (
313
+ id UInt64,
314
+ at DateTime
315
+ )
316
+ ENGINE = MergeTree
317
+ PARTITION BY toYYYYMM(at)
318
+ ORDER BY ${orderBy}`;
319
+ const W1: Obj = { export: "visits", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("at") };
320
+ const W2: Obj = { ...W1, ddl: clicksDdl("(id, at)") };
321
+ await clickhouseApply({ buildPath: join(dir, writeBuild("visits-v1.json", [DB, W1])), environment: "e2e" }, undefined, deps());
322
+ await q("INSERT INTO shop.visits SELECT number, now() - toIntervalDay(number % 90) FROM numbers(300)");
323
+ writeBuild("visits-v2.json", [DB, W2]);
324
+ const args = {
325
+ table: "shop.visits",
326
+ buildPath: "visits-v2.json",
327
+ environment: "e2e",
328
+ dualWrite: { mode: "materialized-view" as const, cutoverColumn: "at", cutoverDelay: "2s" },
329
+ stack: MARKER.stack,
330
+ ownershipEnv: MARKER.env,
331
+ cwd: dir,
332
+ };
333
+ await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
334
+ await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
335
+ await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
336
+ expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 300 });
337
+
338
+ // Late: a time well before the cut-over, written after the verification.
339
+ await q("INSERT INTO shop.visits VALUES (9999, now() - toIntervalDay(30))");
340
+ await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition/);
341
+ expect(await showCreate("shop.visits")).toMatch(/ORDER BY at/);
342
+ expect(await count("shop.visits")).toBe(301);
343
+ expect(await count("shop.visits__chant_new")).toBe(300);
344
+ await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
345
+ }, 300_000);
346
+ });
347
+
238
348
  describe.skipIf(!enabled)("a failure runs onFailure, which drops the new table", () => {
239
349
  const clicksDdl = (orderBy: string) => `CREATE TABLE shop.clicks (id UInt64, url String) ENGINE = MergeTree ORDER BY ${orderBy}`;
240
350
  const V1: Obj = { export: "clicks", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("id") };
@@ -349,3 +459,104 @@ describe.skipIf(!enabled)("an interrupted backfill resumes from its receipts", (
349
459
  expect(dropped.dropped).toEqual(["shop.page_views__chant_new"]);
350
460
  }, 300_000);
351
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
+ });