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