@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
@@ -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)}`;
@@ -16,6 +16,8 @@ import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString,
16
16
  import { waitForMutations } from "../apply/mutations";
17
17
  import { renderFor } from "../topology";
18
18
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
19
+ import { assertSameRows } from "./verify";
20
+ import type { Sharding } from "./shards";
19
21
  import {
20
22
  changesBetween,
21
23
  observeRebuild,
@@ -44,6 +46,8 @@ export interface RebuildRun {
44
46
  signal?: AbortSignal;
45
47
  /** The run's id, recorded on the receipts it writes. */
46
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;
47
51
  }
48
52
 
49
53
  /** The server's clock, in milliseconds: the cut-over and the retention are the server's times, not this machine's. */
@@ -75,6 +79,7 @@ const q = (run: RebuildRun, statement: string) => {
75
79
 
76
80
  export async function waitOn(run: RebuildRun, database: string, table: string): Promise<void> {
77
81
  const ids = await waitForMutations(run.target.endpoint, database, table, {
82
+ ...(run.sharding ? { source: `clusterAllReplicas(${sqlString(run.sharding.cluster)}, system.mutations)` } : {}),
78
83
  ...(run.mutationTimeoutMs !== undefined ? { timeoutMs: run.mutationTimeoutMs } : {}),
79
84
  ...(run.signal ? { signal: run.signal } : {}),
80
85
  });
@@ -155,7 +160,8 @@ export interface DualWriteResult {
155
160
  * old table that writes every row at or after the cut-over into the new
156
161
  * table. The cut-over is the server's time now plus the delay, kept in the
157
162
  * view's own comment (`cutover=`), so a later run and the backfill read it
158
- * back from the server. App mode creates nothing; its gate is the Op's.
163
+ * back from the server. The rows at or after the cut-over that the old table
164
+ * already held are the backfill's (`./backfill.ts`). App mode creates nothing; its gate is the Op's.
159
165
  */
160
166
  export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult> {
161
167
  const o = await observe(run);
@@ -172,7 +178,10 @@ export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult>
172
178
  `(copied: ${o.copied.map((c) => c.name).join(", ")})`,
173
179
  );
174
180
  }
175
- const cutover = (await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m");
181
+ // On a whole second: a DateTime column holds whole seconds, and a row written
182
+ // during the cut-over's second would otherwise carry a time before it and
183
+ // reach the old table after the backfill has started.
184
+ const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m")) / 1000) * 1000;
176
185
  const iso = new Date(cutover).toISOString();
177
186
  const select = o.copied.map((c) => (c.source === c.name ? ident(c.name) : `${ident(c.source)} AS ${ident(c.name)}`)).join(", ");
178
187
  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 }));
@@ -205,8 +214,9 @@ export interface SwapResult {
205
214
  }
206
215
 
207
216
  /**
208
- * Swap the new table in: `EXCHANGE TABLES`, then drop the dual-write view,
209
- * rename the old table (now under the new table's name) to `t__chant_old`
217
+ * Swap the new table in: compare every row of the two tables once more, as
218
+ * the verification does, and stop on any difference; then `EXCHANGE TABLES`,
219
+ * drop the dual-write view, rename the old table (now under the new table's name) to `t__chant_old`
210
220
  * with its retention date, recreate the materialized views that read the
211
221
  * table, and stamp the table's own comment as declared.
212
222
  *
@@ -238,6 +248,10 @@ export async function swapTables(run: RebuildRun): Promise<SwapResult> {
238
248
  }
239
249
  if (!o.exchanged) {
240
250
  if (!o.newTable) throw new RebuildRefusal(`${n.key}: there is no new table to swap in`);
251
+ // The new table holds every row the old one does, or nothing is swapped
252
+ // (sql-yodeler#45): rows that reached the old table alone since the
253
+ // verification would stay behind in it.
254
+ await assertSameRows(run, o);
241
255
  await q(run, `EXCHANGE TABLES ${n.table} AND ${n.newTable}`);
242
256
  }
243
257
  // The dual-write view now reads the new table by name; drop it before
@@ -1,20 +1,40 @@
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.
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.
12
30
  *
13
31
  * Any difference fails the run, naming the partitions; the run's onFailure
14
32
  * 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.
33
+ * swap gate binds: the rebuild's plan and the counts and checksums of the
34
+ * rows before the cut-over, which no later write changes, so an approval
35
+ * is for this plan with these counts, and a changed count needs a new
36
+ * approval. The rows at or after the cut-over keep arriving through the view
37
+ * and are compared, not bound.
18
38
  */
19
39
 
20
40
  import { computePlanDigest } from "@intentius/chant/op";
@@ -23,6 +43,7 @@ import { ident, sqlString } from "../apply/statements";
23
43
  import { rebuildPlanSubject, RebuildRefusal, type RebuildObservation } from "./observe";
24
44
  import { sourcePartitionExpression } from "./partitions";
25
45
  import { cutoverOf, observe, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
46
+ import { shardTable } from "./shards";
26
47
 
27
48
  export interface PartitionCheck {
28
49
  partition: string;
@@ -34,10 +55,13 @@ export interface VerifyResult {
34
55
  state: RebuildObservation["state"];
35
56
  /** 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
57
  planDigest?: string;
58
+ /** Partitions of the old table compared. */
37
59
  partitions: number;
60
+ /** Rows compared: every row of the old table. */
38
61
  rows: number;
39
62
  /** One line for the run record and the gate. */
40
63
  summary: string;
64
+ /** Per partition, the rows before the cut-over (every row in app mode): what the swap gate binds. */
41
65
  verification: PartitionCheck[];
42
66
  }
43
67
 
@@ -45,7 +69,7 @@ export interface VerifyResult {
45
69
  export class RebuildVerificationError extends Error {
46
70
  constructor(
47
71
  readonly table: string,
48
- readonly mismatches: Array<{ partition: string; old?: PartitionCheck; new?: PartitionCheck }>,
72
+ readonly mismatches: Mismatch[],
49
73
  ) {
50
74
  const shown = mismatches
51
75
  .slice(0, 10)
@@ -53,23 +77,38 @@ export class RebuildVerificationError extends Error {
53
77
  .join("; ");
54
78
  super(
55
79
  `${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.`,
80
+ `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; ` +
81
+ `in app mode, a write the application did not stop.`,
57
82
  );
58
83
  this.name = "RebuildVerificationError";
59
84
  }
60
85
  }
61
86
 
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}` };
87
+ /** How often a difference is read again before it counts, and how long apart. */
88
+ const COMPARE_ATTEMPTS = 5;
89
+ const COMPARE_INTERVAL_MS = 1000;
90
+
91
+ /** One side's counts per old partition: every row, and the rows before the cut-over. */
92
+ interface SideCounts {
93
+ all: Map<string, PartitionCheck>;
94
+ before: Map<string, PartitionCheck>;
95
+ }
96
+
97
+ /**
98
+ * Read both tables' counts and checksums per old partition, the old table
99
+ * first, until they agree or `attempts` reads have disagreed. Returns the
100
+ * last read and the partitions that differ in it.
101
+ */
102
+ async function compareTables(
103
+ run: RebuildRun,
104
+ o: RebuildObservation,
105
+ opts: { attempts?: number; intervalMs?: number } = {},
106
+ ): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[] }> {
65
107
  if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
66
108
  const n = o.names;
67
109
  const cutover = cutoverOf(run, o);
68
110
  await waitOn(run, n.database, n.name);
69
111
  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
112
 
74
113
  const types = new Map(
75
114
  (
@@ -79,35 +118,85 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
79
118
  )
80
119
  ).map((c) => [c.name, c.type]),
81
120
  );
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(", ")}))))`;
121
+ const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
122
+ const newHash = rowHash(o.copied.map((c) => ident(c.name)));
84
123
  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)}` : "";
124
+ const oldBefore = cut && cutover !== undefined ? `${ident(cut.source)} < ${utcLiteral(cutover)}` : "1";
125
+ const newBefore = cut && cutover !== undefined ? `${ident(cut.name)} < ${utcLiteral(cutover)}` : "1";
87
126
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
88
127
 
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
- ]),
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;
133
+ const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
134
+ run.target.endpoint,
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`,
95
137
  );
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`);
138
+ const side: SideCounts = { all: new Map(), before: new Map() };
139
+ for (const r of rows) {
140
+ side.all.set(r.p, { partition: r.p, rows: Number(r.rows), checksum: r.checksum });
141
+ // A partition with no row before the cut-over is left out, as a WHERE would leave it out.
142
+ if (Number(r.brows) > 0) side.before.set(r.p, { partition: r.p, rows: Number(r.brows), checksum: r.bchecksum });
143
+ }
144
+ return side;
145
+ };
146
+
147
+ const attempts = Math.max(1, opts.attempts ?? COMPARE_ATTEMPTS);
148
+ for (let attempt = 1; ; attempt++) {
149
+ // In a Replicated database: count what every replica wrote, not what this one has fetched so far.
150
+ await syncReplica(run, o, n.database, n.name);
151
+ await syncReplica(run, o, n.database, n.newName);
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.
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);
155
+ const mismatches = differences(before.all, after.all);
156
+ if (mismatches.length === 0 || attempt >= attempts) return { old: before, new: after, mismatches };
157
+ run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
158
+ await sleep(opts.intervalMs ?? COMPARE_INTERVAL_MS, run.signal);
159
+ }
160
+ }
98
161
 
99
- const mismatches: Array<{ partition: string; old?: PartitionCheck; new?: PartitionCheck }> = [];
162
+ export type Mismatch = { partition: string; old?: PartitionCheck; new?: PartitionCheck };
163
+
164
+ function differences(before: Map<string, PartitionCheck>, after: Map<string, PartitionCheck>): Mismatch[] {
165
+ const out: Mismatch[] = [];
100
166
  for (const p of [...new Set([...before.keys(), ...after.keys()])].sort()) {
101
167
  const a = before.get(p);
102
168
  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 } : {}) });
169
+ if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum) out.push({ partition: p, ...(a ? { old: a } : {}), ...(b ? { new: b } : {}) });
104
170
  }
105
- if (mismatches.length > 0) throw new RebuildVerificationError(n.key, mismatches);
171
+ return out;
172
+ }
173
+
174
+ /** The 64-bit hash of a row's copied columns, as the backfill and the verification compute it. */
175
+ export const rowHash = (columns: readonly string[]): string => `cityHash64(toString(tuple(${columns.join(", ")})))`;
176
+
177
+ /**
178
+ * Before the swap: the new table holds every row the old one does. Throws a
179
+ * {@link RebuildVerificationError} when it does not, so nothing is swapped.
180
+ */
181
+ export async function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void> {
182
+ const { mismatches } = await compareTables(run, o);
183
+ if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches);
184
+ }
106
185
 
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`;
186
+ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
187
+ const o = await observe(run);
188
+ if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
189
+ const cutover = cutoverOf(run, o);
190
+ const compared = await compareTables(run, o);
191
+ if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches);
192
+
193
+ const all = [...compared.old.all.values()];
194
+ const rows = all.reduce((s, v) => s + v.rows, 0);
195
+ // What the approval binds: the rows before the cut-over (every row in app mode), which later writes do not change.
196
+ const verification = [...compared.old.before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
197
+ const bound = verification.reduce((s, v) => s + v.rows, 0);
198
+ const range = cutover !== undefined ? ` (${bound} of them before the cut-over at ${new Date(cutover).toISOString()})` : "";
199
+ const summary = `${o.names.key}: ${all.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
111
200
  run.log(`-- ${summary}`);
112
201
  return {
113
202
  state: o.state,
@@ -116,9 +205,24 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
116
205
  cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
117
206
  verification,
118
207
  }),
119
- partitions: verification.length,
208
+ partitions: all.length,
120
209
  rows,
121
210
  summary,
122
211
  verification,
123
212
  };
124
213
  }
214
+
215
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
216
+ return new Promise((resolve, reject) => {
217
+ signal?.throwIfAborted();
218
+ const t = setTimeout(resolve, ms);
219
+ signal?.addEventListener(
220
+ "abort",
221
+ () => {
222
+ clearTimeout(t);
223
+ reject(signal.reason);
224
+ },
225
+ { once: true },
226
+ );
227
+ });
228
+ }
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";