@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.
- package/dist/clickhouse/apply/mutations.d.ts +7 -1
- package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
- package/dist/clickhouse/container.d.ts +22 -7
- package/dist/clickhouse/container.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +5 -0
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +28 -5
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +4 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +29 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
- package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/shards.d.ts +59 -0
- package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/steps.d.ts +8 -3
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +45 -18
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/receipts.d.ts +5 -3
- package/dist/core/receipts.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +13 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/sql-exec.d.ts +41 -0
- package/dist/op/activities/sql-exec.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +20 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +1 -1
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/migrate/receipts.d.ts +8 -5
- package/dist/postgres/migrate/receipts.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/receipts.d.ts +102 -0
- package/dist/receipts.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/clickhouse/apply/mutations.ts +9 -3
- package/src/clickhouse/container.ts +39 -11
- package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
- package/src/clickhouse/plan/deep.ts +102 -3
- package/src/clickhouse/plan/normalize.ts +10 -0
- package/src/clickhouse/plan/plan.test.ts +10 -0
- package/src/clickhouse/rebuild/backfill.ts +190 -26
- package/src/clickhouse/rebuild/observe.ts +4 -3
- package/src/clickhouse/rebuild/op.ts +80 -35
- package/src/clickhouse/rebuild/partitions.ts +11 -0
- package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +216 -5
- package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
- package/src/clickhouse/rebuild/receipts.ts +4 -6
- package/src/clickhouse/rebuild/shards.ts +104 -0
- package/src/clickhouse/rebuild/steps.ts +18 -4
- package/src/clickhouse/rebuild/verify.ts +140 -36
- package/src/config.ts +7 -0
- package/src/core/receipts.ts +5 -3
- package/src/op/activities/clickhouse-rebuild.ts +32 -7
- package/src/op/activities/index.ts +21 -4
- package/src/op/activities/sql-exec.ts +84 -0
- package/src/op/activity-contracts.ts +15 -0
- package/src/op/effect-receipts.e2e.test.ts +277 -0
- package/src/postgres/live/catalog.ts +3 -1
- package/src/postgres/migrate/migrate.test.ts +3 -2
- package/src/postgres/migrate/receipts.ts +18 -10
- package/src/postgres/plan/deep.test.ts +76 -0
- package/src/postgres/plan/deep.ts +13 -2
- package/src/postgres/plan/plan.e2e.test.ts +26 -0
- package/src/receipts.test.ts +91 -0
- package/src/receipts.ts +259 -0
- 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 [
|
|
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
|
|
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
|
-
|
|
104
|
-
expect(
|
|
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`).
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `effect()` steps
|
|
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.
|
|
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
|
-
|
|
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:
|
|
209
|
-
*
|
|
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
|
|
3
|
-
* table against the new one, before anyone
|
|
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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* new
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
83
|
-
const newHash =
|
|
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
|
|
86
|
-
const
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const
|
|
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:
|
|
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
|
package/src/core/receipts.ts
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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";
|