@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
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
* | Drop | the old table, once its date has passed |
|
|
26
26
|
* | onFailure | drop the new table and the dual-write view |
|
|
27
27
|
*
|
|
28
|
+
* `gates: "outer"` (#3658) leaves out both approval gates and the Drop phase,
|
|
29
|
+
* for a caller that runs the Op under an approval of its own: the run goes
|
|
30
|
+
* from Plan to Retain, verification and the swap's own comparison included.
|
|
31
|
+
* `onFailure: "keep"` leaves out the onFailure, so a failed run's next run
|
|
32
|
+
* resumes the backfill from its receipts instead of starting again.
|
|
33
|
+
*
|
|
28
34
|
* Run it until it is done: each run goes as far as the next gate. Every step
|
|
29
35
|
* re-reads the server, so a run after a gate, a crash or an approval picks up
|
|
30
36
|
* where the last stopped. A run killed during the backfill (Ctrl-C, a lost
|
|
@@ -55,6 +61,27 @@ export interface ClickHouseRebuildOpConfig {
|
|
|
55
61
|
build?: boolean;
|
|
56
62
|
/** How long the old table is kept after the swap, as a duration (`7d`, `36h`). Default: `7d`. */
|
|
57
63
|
retain?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Whose approval the swap and the drop wait for. `"own"` (the default): the
|
|
66
|
+
* Op's two gates. `"outer"`: an approval the caller already holds for a
|
|
67
|
+
* larger change that runs this Op as one of its steps. The swap gate, the
|
|
68
|
+
* drop gate and the Drop phase are left out, so the Op ends at Retain with
|
|
69
|
+
* the old table kept as `<table>__chant_old` with its retention date. Drop
|
|
70
|
+
* it after that date, or run the Op with `"own"`: its swap gate has
|
|
71
|
+
* nothing left to swap, and its drop gate binds that old table.
|
|
72
|
+
* The verification still runs, and the swap compares the tables again
|
|
73
|
+
* before the `EXCHANGE`: any difference fails the run with nothing swapped.
|
|
74
|
+
* App mode's writes-stopped gate is not an approval and stays.
|
|
75
|
+
*/
|
|
76
|
+
gates?: "own" | "outer";
|
|
77
|
+
/**
|
|
78
|
+
* What a failed run does with the new table and the dual-write view.
|
|
79
|
+
* `"drop"` (the default): onFailure drops them, and the next run starts
|
|
80
|
+
* again from a new table. `"keep"`: there is no onFailure, and the next run
|
|
81
|
+
* resumes the backfill from its per-partition receipts. To start again
|
|
82
|
+
* instead, drop `<table>__chant_new` and `<table>__chant_dual`.
|
|
83
|
+
*/
|
|
84
|
+
onFailure?: "drop" | "keep";
|
|
58
85
|
/** The gate before the swap. Default name `approve-<name>`. */
|
|
59
86
|
gate?: {
|
|
60
87
|
gate?: string;
|
|
@@ -100,6 +127,8 @@ export interface ClickHouseRebuildArgs {
|
|
|
100
127
|
stack?: string;
|
|
101
128
|
ownershipEnv?: string;
|
|
102
129
|
cwd?: string;
|
|
130
|
+
/** A failed run keeps the new table (`onFailure: "keep"`): a refusal says to drop it by hand. */
|
|
131
|
+
keepOnFailure?: boolean;
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHouseRebuildOpResources {
|
|
@@ -112,6 +141,14 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
|
|
|
112
141
|
if (config.dualWrite.mode === "materialized-view" && !config.dualWrite.cutoverColumn) {
|
|
113
142
|
throw new Error(`ClickHouseRebuildOp "${config.name}": materialized-view mode needs cutoverColumn, the time column rows are cut over on`);
|
|
114
143
|
}
|
|
144
|
+
if (config.gates !== undefined && config.gates !== "own" && config.gates !== "outer") {
|
|
145
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": gates must be "own" or "outer"`);
|
|
146
|
+
}
|
|
147
|
+
if (config.onFailure !== undefined && config.onFailure !== "drop" && config.onFailure !== "keep") {
|
|
148
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": onFailure must be "drop" or "keep"`);
|
|
149
|
+
}
|
|
150
|
+
const outer = config.gates === "outer";
|
|
151
|
+
const keep = config.onFailure === "keep";
|
|
115
152
|
|
|
116
153
|
const args: ClickHouseRebuildArgs = {
|
|
117
154
|
table: config.table,
|
|
@@ -124,6 +161,7 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
|
|
|
124
161
|
...(config.stack ? { stack: config.stack } : {}),
|
|
125
162
|
...(config.ownershipEnv ? { ownershipEnv: config.ownershipEnv } : {}),
|
|
126
163
|
...(config.path && config.path !== "." ? { cwd: config.path } : {}),
|
|
164
|
+
...(keep ? { keepOnFailure: true } : {}),
|
|
127
165
|
};
|
|
128
166
|
const a = args as unknown as Record<string, unknown>;
|
|
129
167
|
const swapGate = config.gate?.gate ?? `approve-${config.name}`;
|
|
@@ -133,6 +171,33 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
|
|
|
133
171
|
const step = (fn: string, extra: Partial<StepDefinition & { id: string; outcomeAttribute: unknown; profile: string; timeout: string }> = {}): StepDefinition =>
|
|
134
172
|
({ ...activity(fn, a), ...extra }) as StepDefinition;
|
|
135
173
|
|
|
174
|
+
// The swap waits for this gate, unless the caller holds the approval (`gates: "outer"`).
|
|
175
|
+
const approve = phase("Approve", [
|
|
176
|
+
gate(swapGate, {
|
|
177
|
+
...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
|
|
178
|
+
plan: stepOutput("verify", "planDigest"),
|
|
179
|
+
description:
|
|
180
|
+
config.gate?.description ??
|
|
181
|
+
`Approve swapping the rebuilt ${config.table} in on ${config.env}: the run record's Verification line has the counts this approval binds`,
|
|
182
|
+
...(config.gate?.approval
|
|
183
|
+
? {
|
|
184
|
+
approval: {
|
|
185
|
+
...config.gate.approval,
|
|
186
|
+
...(policy
|
|
187
|
+
? {
|
|
188
|
+
context: {
|
|
189
|
+
verifiedPartitions: stepOutput("verify", "partitions"),
|
|
190
|
+
verifiedRows: stepOutput("verify", "rows"),
|
|
191
|
+
...config.gate.approval.context,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
: {}),
|
|
198
|
+
}),
|
|
199
|
+
]);
|
|
200
|
+
|
|
136
201
|
const phases = [
|
|
137
202
|
...(config.build === false ? [] : [phase("Build", [activity("chantBuild", { path: config.path ?? "." })])]),
|
|
138
203
|
phase("Plan", [step("clickhouseRebuildPlan", { id: "plan", outcomeAttribute: { name: "RebuildState", from: "state" } })]),
|
|
@@ -158,49 +223,29 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
|
|
|
158
223
|
],
|
|
159
224
|
}),
|
|
160
225
|
]),
|
|
161
|
-
|
|
162
|
-
gate(swapGate, {
|
|
163
|
-
...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
|
|
164
|
-
plan: stepOutput("verify", "planDigest"),
|
|
165
|
-
description:
|
|
166
|
-
config.gate?.description ??
|
|
167
|
-
`Approve swapping the rebuilt ${config.table} in on ${config.env}: the run record's Verification line has the counts this approval binds`,
|
|
168
|
-
...(config.gate?.approval
|
|
169
|
-
? {
|
|
170
|
-
approval: {
|
|
171
|
-
...config.gate.approval,
|
|
172
|
-
...(policy
|
|
173
|
-
? {
|
|
174
|
-
context: {
|
|
175
|
-
verifiedPartitions: stepOutput("verify", "partitions"),
|
|
176
|
-
verifiedRows: stepOutput("verify", "rows"),
|
|
177
|
-
...config.gate.approval.context,
|
|
178
|
-
},
|
|
179
|
-
}
|
|
180
|
-
: {}),
|
|
181
|
-
},
|
|
182
|
-
}
|
|
183
|
-
: {}),
|
|
184
|
-
}),
|
|
185
|
-
]),
|
|
226
|
+
...(outer ? [] : [approve]),
|
|
186
227
|
phase("Swap", [step("clickhouseRebuildSwap", { profile: "longInfra", outcomeAttribute: { name: "Dependents", from: "dependents" } })]),
|
|
187
228
|
phase("Retain", [step("clickhouseRebuildRetain", { id: "retain", outcomeAttribute: { name: "RetainUntil", from: "retainUntil" } })]),
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
...(outer
|
|
230
|
+
? []
|
|
231
|
+
: [
|
|
232
|
+
phase("Approve drop", [
|
|
233
|
+
gate(dropGate, {
|
|
234
|
+
...(config.dropGate?.timeout ? { timeout: config.dropGate.timeout } : {}),
|
|
235
|
+
plan: stepOutput("retain", "dropDigest"),
|
|
236
|
+
description: config.dropGate?.description ?? `Approve dropping the old ${config.table} once its retention has passed`,
|
|
237
|
+
}),
|
|
238
|
+
]),
|
|
239
|
+
phase("Drop", [step("clickhouseRebuildDrop", { outcomeAttribute: { name: "Dropped", from: "dropped" } })]),
|
|
240
|
+
]),
|
|
196
241
|
];
|
|
197
242
|
|
|
198
243
|
const op = Op({
|
|
199
244
|
name: config.name,
|
|
200
|
-
overview: `Rebuild ${config.table} on ${config.env}: new table, ${config.dualWrite.mode === "app" ? "writes stopped" : "dual write"}, backfill, verify, gated swap`,
|
|
245
|
+
overview: `Rebuild ${config.table} on ${config.env}: new table, ${config.dualWrite.mode === "app" ? "writes stopped" : "dual write"}, backfill, verify, ${outer ? "swap under the caller's approval" : "gated swap"}`,
|
|
201
246
|
labels: { Rebuild: "true", Env: config.env, Table: config.table },
|
|
202
247
|
phases,
|
|
203
|
-
onFailure: [phase("Compensate", [step("clickhouseRebuildCompensate")])],
|
|
248
|
+
...(keep ? {} : { onFailure: [phase("Compensate", [step("clickhouseRebuildCompensate")])] }),
|
|
204
249
|
});
|
|
205
250
|
return { op };
|
|
206
251
|
}
|
|
@@ -15,6 +15,7 @@ import { clickhouseQuery } from "../http";
|
|
|
15
15
|
import type { ClickHouseTarget } from "../live/bind";
|
|
16
16
|
import { ident, sqlString } from "../apply/statements";
|
|
17
17
|
import type { CopiedColumn, RebuildNames } from "./observe";
|
|
18
|
+
import type { Sharding } from "./shards";
|
|
18
19
|
|
|
19
20
|
/** The old table's active partitions, by id. */
|
|
20
21
|
export async function sourcePartitions(target: ClickHouseTarget, names: RebuildNames): Promise<string[]> {
|
|
@@ -26,6 +27,16 @@ export async function sourcePartitions(target: ClickHouseTarget, names: RebuildN
|
|
|
26
27
|
return rows.map((r) => r.partition_id);
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/** The old table's active partitions on each shard of a cluster (#3663), through this server. */
|
|
31
|
+
export async function shardPartitions(run: { target: ClickHouseTarget }, sharding: Sharding, database: string, table: string): Promise<Array<{ shard: number; partition: string }>> {
|
|
32
|
+
const rows = await clickhouseQuery<{ shard: number | string; partition_id: string }>(
|
|
33
|
+
run.target.endpoint,
|
|
34
|
+
`SELECT _shard_num AS shard, partition_id FROM cluster(${sqlString(sharding.cluster)}, system.parts) ` +
|
|
35
|
+
`WHERE database = ${sqlString(database)} AND table = ${sqlString(table)} AND active GROUP BY shard, partition_id ORDER BY shard, partition_id`,
|
|
36
|
+
);
|
|
37
|
+
return rows.map((r) => ({ shard: Number(r.shard), partition: r.partition_id }));
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/** Rename the columns an expression names, old name to new, leaving function names and qualified names alone. */
|
|
30
41
|
export function renameColumns(expr: string, copied: readonly CopiedColumn[]): string {
|
|
31
42
|
let out = expr;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rebuild migration on a cluster of two shards (#3663), against a
|
|
3
|
+
* scratch cluster of the pinned image: shard 1 of two replicas, shard 2 of
|
|
4
|
+
* one server, Keeper, and a cluster `chant` over all three. The profile
|
|
5
|
+
* names shard 1's first replica with topology `cluster:chant`.
|
|
6
|
+
*
|
|
7
|
+
* Each shard holds its own rows of `shop.events`, with a `Distributed`
|
|
8
|
+
* table over them. An `ORDER BY` change runs as a `ClickHouseRebuildOp`
|
|
9
|
+
* under an outer approval, keeping its new table on failure:
|
|
10
|
+
*
|
|
11
|
+
* - the first run copies shard 1's partitions and one of shard 2's, then
|
|
12
|
+
* fails: each (shard, partition) copied has its own receipt;
|
|
13
|
+
* - the rerun skips those, copies the rest of shard 2, verifies every row of
|
|
14
|
+
* both shards, and swaps: every shard's rows are in its live table, on
|
|
15
|
+
* every replica, with the new sorting key.
|
|
16
|
+
*
|
|
17
|
+
* Runs the Op through core's local executor. Needs Docker; skips cleanly
|
|
18
|
+
* without it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
25
|
+
import { normalizeApply } from "@intentius/chant/apply";
|
|
26
|
+
import { memoryGateLedgerPort, runOpLocally, loadProfiles, OpRunFailure, type ActivityFn, type OpConfig, type OpRunResult } from "@intentius/chant/op";
|
|
27
|
+
import { dockerAvailable, startScratchCluster, type ScratchCluster } from "../container";
|
|
28
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
29
|
+
import { clickhouseImage } from "../../spec/pin";
|
|
30
|
+
import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
|
|
31
|
+
import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
|
|
32
|
+
import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
|
|
33
|
+
import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
|
|
34
|
+
|
|
35
|
+
const enabled = await dockerAvailable();
|
|
36
|
+
let cluster: ScratchCluster | undefined;
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-rebuild-sharded-"));
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
if (!enabled) return;
|
|
41
|
+
cluster = await startScratchCluster(clickhouseImage(), { shards: [2, 1], cluster: "chant", namePrefix: "chant-sql-rebuild-shards" });
|
|
42
|
+
}, 600_000);
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await cluster?.stop();
|
|
46
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const MARKER = { stack: "e2e", env: "test" };
|
|
50
|
+
const config = () => ({ ownership: MARKER, sql: { profiles: { e2e: { url: cluster!.replicas[0]!.url, topology: "cluster:chant" } } } });
|
|
51
|
+
const deps = (extra: Partial<ClickHouseRebuildDeps> = {}): ClickHouseRebuildDeps => ({ config: config(), env: {}, log: () => undefined, ...extra });
|
|
52
|
+
const q = <T = Record<string, unknown>>(endpoint: ClickHouseEndpoint, sql: string) => clickhouseQuery<T>(endpoint, sql);
|
|
53
|
+
const count = async (endpoint: ClickHouseEndpoint, table: string) => Number((await q<{ n: string }>(endpoint, `SELECT count() AS n FROM ${table}`))[0]!.n);
|
|
54
|
+
/** The servers of a shard, by its macro. */
|
|
55
|
+
const servers = (shard: string) => cluster!.layout.flatMap((l, i) => (l.shard === shard ? [cluster!.replicas[i]!] : []));
|
|
56
|
+
|
|
57
|
+
const eventsDdl = (orderBy: string) => `CREATE TABLE shop.events
|
|
58
|
+
(
|
|
59
|
+
ts DateTime,
|
|
60
|
+
user_id UInt64,
|
|
61
|
+
kind LowCardinality(String)
|
|
62
|
+
)
|
|
63
|
+
ENGINE = MergeTree
|
|
64
|
+
PARTITION BY toDate(ts)
|
|
65
|
+
ORDER BY ${orderBy}`;
|
|
66
|
+
const DISTRIBUTED = "CREATE TABLE shop.events_all (ts DateTime, user_id UInt64, kind LowCardinality(String)) ENGINE = Distributed(chant, shop, events, user_id)";
|
|
67
|
+
|
|
68
|
+
function writeBuild(file: string, orderBy: string): string {
|
|
69
|
+
const objects = [
|
|
70
|
+
{ export: "shop", type: "ClickHouse::Database", ddl: "CREATE DATABASE shop", dependsOn: [] },
|
|
71
|
+
{ export: "events", type: "ClickHouse::Table", ddl: eventsDdl(orderBy), dependsOn: ["shop"] },
|
|
72
|
+
{ export: "eventsAll", type: "ClickHouse::Table", ddl: DISTRIBUTED, dependsOn: ["events"] },
|
|
73
|
+
];
|
|
74
|
+
writeFileSync(join(dir, file), JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
|
|
75
|
+
return file;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const opConfig = (): ClickHouseRebuildOpConfig => ({
|
|
79
|
+
name: "rebuild-events",
|
|
80
|
+
env: "e2e",
|
|
81
|
+
table: "shop.events",
|
|
82
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "ts", cutoverDelay: "2s" },
|
|
83
|
+
build: false,
|
|
84
|
+
path: dir,
|
|
85
|
+
output: "events-v2.json",
|
|
86
|
+
stack: MARKER.stack,
|
|
87
|
+
ownershipEnv: MARKER.env,
|
|
88
|
+
gates: "outer",
|
|
89
|
+
onFailure: "keep",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function runOp(extra: Partial<ClickHouseRebuildDeps> = {}): Promise<OpRunResult> {
|
|
93
|
+
const props = (ClickHouseRebuildOp(opConfig()).op as unknown as { props: OpConfig }).props;
|
|
94
|
+
const activities = new Map<string, ActivityFn>();
|
|
95
|
+
for (const [name, fn] of Object.entries(rebuildActivities)) {
|
|
96
|
+
if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
|
|
97
|
+
activities.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) => (fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps(extra))) as ActivityFn);
|
|
98
|
+
}
|
|
99
|
+
// One attempt per step, so the failure below fails the run where it happens.
|
|
100
|
+
const profiles = Object.fromEntries(Object.entries(await loadProfiles()).map(([k, v]) => [k, { ...v, retry: { maximumAttempts: 1 } }]));
|
|
101
|
+
return runOpLocally(props, activities, profiles, undefined, { gates: memoryGateLedgerPort(), now: new Date().toISOString() });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const outcome = (r: OpRunResult, name: string) => r.records.flatMap((x) => x.outcomes ?? []).find((o) => o.name === name)?.value;
|
|
105
|
+
|
|
106
|
+
describe.skipIf(!enabled)("a rebuild on a cluster of two shards (#3663)", () => {
|
|
107
|
+
test("every shard's rows survive the rebuild, and a rerun after a failure on shard 2 skips what was copied", async () => {
|
|
108
|
+
const applied = normalizeApply(toApplyResult(await clickhouseApply({ buildPath: join(dir, writeBuild("events-v1.json", "(ts, user_id)")), environment: "e2e" }, undefined, deps())));
|
|
109
|
+
expect(applied.notAttempted).toEqual([]);
|
|
110
|
+
// Each shard's own rows, three days of them, written on that shard.
|
|
111
|
+
const [s1] = servers("s1");
|
|
112
|
+
const [s2] = servers("s2");
|
|
113
|
+
await q(s1!, "INSERT INTO shop.events SELECT toDateTime('2026-02-01 00:00:00') + (number % 3) * 86400 + number, number, 'view' FROM numbers(30)");
|
|
114
|
+
await q(s2!, "INSERT INTO shop.events SELECT toDateTime('2026-02-01 00:00:00') + (number % 3) * 86400 + number, 1000 + number, 'click' FROM numbers(24)");
|
|
115
|
+
expect(await count(s1!, "shop.events_all")).toBe(54);
|
|
116
|
+
writeBuild("events-v2.json", "(user_id, ts)");
|
|
117
|
+
|
|
118
|
+
// Shard 2's first partition is copied and its receipt written, then the backfill fails.
|
|
119
|
+
const failure = await runOp({
|
|
120
|
+
backfill: {
|
|
121
|
+
afterPartition: (_p, shard) => {
|
|
122
|
+
if (shard === 2) throw new Error("the backfill failed on shard 2");
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}).then(
|
|
126
|
+
() => undefined,
|
|
127
|
+
(e: unknown) => e,
|
|
128
|
+
);
|
|
129
|
+
expect(failure).toBeInstanceOf(OpRunFailure);
|
|
130
|
+
expect((failure as OpRunFailure).result.records.find((x) => x.fn === "clickhouseRebuildBackfill")?.error).toMatch(/failed on shard 2/);
|
|
131
|
+
const receipts = async () =>
|
|
132
|
+
(await q<{ address: string }>(s1!, "SELECT DISTINCT address FROM chant_receipts.receipts WHERE address LIKE 'e2e/test/rebuild/shop.events/%' ORDER BY address")).map((r) => r.address);
|
|
133
|
+
expect(await receipts()).toEqual([
|
|
134
|
+
"e2e/test/rebuild/shop.events/shard1/20260201",
|
|
135
|
+
"e2e/test/rebuild/shop.events/shard1/20260202",
|
|
136
|
+
"e2e/test/rebuild/shop.events/shard1/20260203",
|
|
137
|
+
"e2e/test/rebuild/shop.events/shard2/20260201",
|
|
138
|
+
]);
|
|
139
|
+
// Each shard's new table holds that shard's copied rows, and nothing of the other's.
|
|
140
|
+
expect(await count(s1!, "shop.events__chant_new")).toBe(30);
|
|
141
|
+
expect(await count(s2!, "shop.events__chant_new")).toBe(8);
|
|
142
|
+
|
|
143
|
+
// The rerun resumes: four (shard, partition) copies skipped, two copied, every row verified, swapped.
|
|
144
|
+
const r = await runOp();
|
|
145
|
+
expect(r.status).toBe("ok");
|
|
146
|
+
expect(outcome(r, "Skipped")).toBe(4);
|
|
147
|
+
expect(outcome(r, "Copied")).toBe(2);
|
|
148
|
+
expect(outcome(r, "VerifiedRows")).toBe(54);
|
|
149
|
+
expect(outcome(r, "VerifiedPartitions")).toBe(6);
|
|
150
|
+
|
|
151
|
+
for (const [shard, rows] of [["s1", 30], ["s2", 24]] as const) {
|
|
152
|
+
for (const server of servers(shard)) {
|
|
153
|
+
await q(server, "SYSTEM SYNC REPLICA shop.events");
|
|
154
|
+
expect(await count(server, "shop.events")).toBe(rows);
|
|
155
|
+
expect((await q<{ statement: string }>(server, "SHOW CREATE TABLE shop.events"))[0]!.statement).toMatch(/ORDER BY \(user_id, ts\)/);
|
|
156
|
+
expect(await count(server, "shop.events__chant_old")).toBe(rows);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
expect(await count(s1!, "shop.events_all")).toBe(54);
|
|
160
|
+
}, 600_000);
|
|
161
|
+
});
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* table, keeps the old table, and drops it at the second gate.
|
|
8
8
|
* - A failure: rows that land in the old table after the backfill fail the
|
|
9
9
|
* verification, and onFailure drops the new table.
|
|
10
|
+
* - Rows dated after the cut-over (sql-yodeler#45): the ones the table held
|
|
11
|
+
* before the view are copied by the backfill, the ones written during the
|
|
12
|
+
* rebuild arrive through the view, and the swap leaves none behind.
|
|
13
|
+
* - Under an outer approval with the new table kept on failure (#3658): a
|
|
14
|
+
* backfill that fails part way leaves the new table and its receipts, and
|
|
15
|
+
* the next run skips the partitions already copied, verifies and swaps
|
|
16
|
+
* with no gate, and keeps the old table.
|
|
10
17
|
* - An interrupted backfill: stopped after three partitions, then killed
|
|
11
18
|
* between a partition's INSERT and its receipt, then resumed: the receipts
|
|
12
19
|
* skip what was copied, the half-copied partition is cleared first, and the
|
|
@@ -28,6 +35,7 @@ import { clickhouseImage } from "../../spec/pin";
|
|
|
28
35
|
import { planAgainstServer } from "../plan/commands";
|
|
29
36
|
import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
|
|
30
37
|
import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
|
|
38
|
+
import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
|
|
31
39
|
import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
|
|
32
40
|
import { clickhouseReceiptStore, RECEIPTS_DATABASE, RECEIPTS_TABLE, type ClickHouseReceiptStore } from "./receipts";
|
|
33
41
|
|
|
@@ -65,20 +73,20 @@ const exists = async (database: string, name: string) => Number((await q<{ n: st
|
|
|
65
73
|
const showCreate = async (name: string) => (await q<{ statement: string }>(`SHOW CREATE TABLE ${name}`))[0]!.statement;
|
|
66
74
|
|
|
67
75
|
/** The rebuild activities, with the test's config, server and a quiet log, as the local executor resolves them. */
|
|
68
|
-
function activities(): Map<string, ActivityFn> {
|
|
76
|
+
function activities(extra: Partial<ClickHouseRebuildDeps> = {}): Map<string, ActivityFn> {
|
|
69
77
|
const map = new Map<string, ActivityFn>();
|
|
70
78
|
for (const [name, fn] of Object.entries(rebuildActivities)) {
|
|
71
79
|
if (typeof fn !== "function" || !name.startsWith("clickhouseRebuild")) continue;
|
|
72
80
|
map.set(name, ((args: Record<string, unknown>, signal?: AbortSignal) =>
|
|
73
|
-
(fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, deps())) as ActivityFn);
|
|
81
|
+
(fn as (a: unknown, s?: AbortSignal, d?: unknown) => Promise<unknown>)(args, signal, { ...deps(), ...extra })) as ActivityFn);
|
|
74
82
|
}
|
|
75
83
|
return map;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
async function runOp(config: ClickHouseRebuildOpConfig, gates: GateLedgerPort): Promise<OpRunResult> {
|
|
86
|
+
async function runOp(config: ClickHouseRebuildOpConfig, gates: GateLedgerPort, extra: Partial<ClickHouseRebuildDeps> = {}): Promise<OpRunResult> {
|
|
79
87
|
const { op } = ClickHouseRebuildOp(config);
|
|
80
88
|
const props = (op as unknown as { props: OpConfig }).props;
|
|
81
|
-
return runOpLocally(props, activities(), await loadProfiles(), undefined, { gates, now: new Date().toISOString() });
|
|
89
|
+
return runOpLocally(props, activities(extra), await loadProfiles(), undefined, { gates, now: new Date().toISOString() });
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
/** An in-memory gate ledger a person approves on: each approval answers the gate the last run stopped at, for the plan it recorded. */
|
|
@@ -174,7 +182,7 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
|
|
|
174
182
|
expect(outcome(r, "RebuildState")).toBe("rebuild");
|
|
175
183
|
expect(outcome(r, "VerifiedRows")).toBe(1600);
|
|
176
184
|
expect(outcome(r, "VerifiedPartitions")).toBe(4);
|
|
177
|
-
expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) before the cut-over/);
|
|
185
|
+
expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) \(1600 of them before the cut-over/);
|
|
178
186
|
expect(await count("shop.events__chant_new")).toBe(1600);
|
|
179
187
|
expect(await exists("shop", "events__chant_dual")).toBe(true);
|
|
180
188
|
// Nothing visible to a plan or an apply: the working objects are left out.
|
|
@@ -235,6 +243,108 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
|
|
|
235
243
|
}, 300_000);
|
|
236
244
|
});
|
|
237
245
|
|
|
246
|
+
describe.skipIf(!enabled)("rows dated after the cut-over that the table already held (sql-yodeler#45)", () => {
|
|
247
|
+
const bookingsDdl = (orderBy: string) => `CREATE TABLE shop.bookings
|
|
248
|
+
(
|
|
249
|
+
id UInt64,
|
|
250
|
+
kind LowCardinality(String),
|
|
251
|
+
at DateTime
|
|
252
|
+
)
|
|
253
|
+
ENGINE = MergeTree
|
|
254
|
+
PARTITION BY toYYYYMM(at)
|
|
255
|
+
ORDER BY ${orderBy}`;
|
|
256
|
+
const V1: Obj = { export: "bookings", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: bookingsDdl("(kind, at)") };
|
|
257
|
+
const V2: Obj = { ...V1, ddl: bookingsDdl("(kind, id)") };
|
|
258
|
+
const config = (): ClickHouseRebuildOpConfig => ({
|
|
259
|
+
name: "rebuild-bookings",
|
|
260
|
+
env: "e2e",
|
|
261
|
+
table: "shop.bookings",
|
|
262
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "at", cutoverDelay: "2s" },
|
|
263
|
+
build: false,
|
|
264
|
+
path: dir,
|
|
265
|
+
output: "bookings-v2.json",
|
|
266
|
+
retain: "0s",
|
|
267
|
+
stack: MARKER.stack,
|
|
268
|
+
ownershipEnv: MARKER.env,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("every row is copied and compared, those written during the rebuild too, and the swap leaves none behind", async () => {
|
|
272
|
+
await clickhouseApply({ buildPath: join(dir, writeBuild("bookings-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
|
|
273
|
+
// 600 rows seven hours apart, from four months ago to about two months
|
|
274
|
+
// ahead, with an identical pair among the future ones.
|
|
275
|
+
await q(
|
|
276
|
+
"INSERT INTO shop.bookings SELECT number, ['view','cart','buy'][number % 3 + 1], toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(number * 7) FROM numbers(600)",
|
|
277
|
+
);
|
|
278
|
+
await q("INSERT INTO shop.bookings VALUES (599, 'buy', toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(599 * 7))");
|
|
279
|
+
const future = Number((await q<{ n: string }>("SELECT count() AS n FROM shop.bookings WHERE at > now() + 60"))[0]!.n);
|
|
280
|
+
expect(future).toBeGreaterThan(150);
|
|
281
|
+
writeBuild("bookings-v2.json", [DB, V2]);
|
|
282
|
+
|
|
283
|
+
const ledger = new Ledger();
|
|
284
|
+
const first = await runOp(config(), ledger.port);
|
|
285
|
+
expect(first.status).toBe("gated");
|
|
286
|
+
expect(first.gate).toMatchObject({ gate: "approve-rebuild-bookings" });
|
|
287
|
+
expect(outcome(first, "VerifiedRows")).toBe(601);
|
|
288
|
+
expect(await count("shop.bookings__chant_new")).toBe(601);
|
|
289
|
+
|
|
290
|
+
// Written during the rebuild, after the cut-over: through the view.
|
|
291
|
+
await q("INSERT INTO shop.bookings VALUES (1000, 'buy', now() + toIntervalDay(40)), (1001, 'cart', now())");
|
|
292
|
+
const again = await runOp(config(), ledger.port);
|
|
293
|
+
expect(again.status).toBe("gated");
|
|
294
|
+
expect(outcome(again, "VerifiedRows")).toBe(603);
|
|
295
|
+
// The rows before the cut-over are what the approval binds; later rows do not change it.
|
|
296
|
+
expect(again.gate!.planDigest).toBe(first.gate!.planDigest);
|
|
297
|
+
|
|
298
|
+
ledger.approveLast();
|
|
299
|
+
await q("INSERT INTO shop.bookings VALUES (1002, 'view', now() + toIntervalDay(70))");
|
|
300
|
+
const swapped = await runOp(config(), ledger.port);
|
|
301
|
+
expect(swapped.status).toBe("gated");
|
|
302
|
+
expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-bookings-drop" });
|
|
303
|
+
expect(await showCreate("shop.bookings")).toMatch(/ORDER BY \(kind, id\)/);
|
|
304
|
+
expect(await count("shop.bookings")).toBe(604);
|
|
305
|
+
expect(await count("shop.bookings__chant_old")).toBe(604);
|
|
306
|
+
const diff = await q("SELECT * FROM shop.bookings__chant_old EXCEPT ALL SELECT * FROM shop.bookings");
|
|
307
|
+
expect(diff).toEqual([]);
|
|
308
|
+
}, 300_000);
|
|
309
|
+
|
|
310
|
+
test("a row that reaches the old table alone after the verification stops the swap", async () => {
|
|
311
|
+
const clicksDdl = (orderBy: string) => `CREATE TABLE shop.visits
|
|
312
|
+
(
|
|
313
|
+
id UInt64,
|
|
314
|
+
at DateTime
|
|
315
|
+
)
|
|
316
|
+
ENGINE = MergeTree
|
|
317
|
+
PARTITION BY toYYYYMM(at)
|
|
318
|
+
ORDER BY ${orderBy}`;
|
|
319
|
+
const W1: Obj = { export: "visits", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("at") };
|
|
320
|
+
const W2: Obj = { ...W1, ddl: clicksDdl("(id, at)") };
|
|
321
|
+
await clickhouseApply({ buildPath: join(dir, writeBuild("visits-v1.json", [DB, W1])), environment: "e2e" }, undefined, deps());
|
|
322
|
+
await q("INSERT INTO shop.visits SELECT number, now() - toIntervalDay(number % 90) FROM numbers(300)");
|
|
323
|
+
writeBuild("visits-v2.json", [DB, W2]);
|
|
324
|
+
const args = {
|
|
325
|
+
table: "shop.visits",
|
|
326
|
+
buildPath: "visits-v2.json",
|
|
327
|
+
environment: "e2e",
|
|
328
|
+
dualWrite: { mode: "materialized-view" as const, cutoverColumn: "at", cutoverDelay: "2s" },
|
|
329
|
+
stack: MARKER.stack,
|
|
330
|
+
ownershipEnv: MARKER.env,
|
|
331
|
+
cwd: dir,
|
|
332
|
+
};
|
|
333
|
+
await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
|
|
334
|
+
await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
|
|
335
|
+
await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
|
|
336
|
+
expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 300 });
|
|
337
|
+
|
|
338
|
+
// Late: a time well before the cut-over, written after the verification.
|
|
339
|
+
await q("INSERT INTO shop.visits VALUES (9999, now() - toIntervalDay(30))");
|
|
340
|
+
await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition/);
|
|
341
|
+
expect(await showCreate("shop.visits")).toMatch(/ORDER BY at/);
|
|
342
|
+
expect(await count("shop.visits")).toBe(301);
|
|
343
|
+
expect(await count("shop.visits__chant_new")).toBe(300);
|
|
344
|
+
await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
|
|
345
|
+
}, 300_000);
|
|
346
|
+
});
|
|
347
|
+
|
|
238
348
|
describe.skipIf(!enabled)("a failure runs onFailure, which drops the new table", () => {
|
|
239
349
|
const clicksDdl = (orderBy: string) => `CREATE TABLE shop.clicks (id UInt64, url String) ENGINE = MergeTree ORDER BY ${orderBy}`;
|
|
240
350
|
const V1: Obj = { export: "clicks", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("id") };
|
|
@@ -349,3 +459,104 @@ describe.skipIf(!enabled)("an interrupted backfill resumes from its receipts", (
|
|
|
349
459
|
expect(dropped.dropped).toEqual(["shop.page_views__chant_new"]);
|
|
350
460
|
}, 300_000);
|
|
351
461
|
});
|
|
462
|
+
|
|
463
|
+
describe.skipIf(!enabled)("under an outer approval, a failed rebuild keeps its new table and the rerun resumes (#3658)", () => {
|
|
464
|
+
const sessionsDdl = (orderBy: string) => `CREATE TABLE shop.sessions
|
|
465
|
+
(
|
|
466
|
+
day Date,
|
|
467
|
+
user_id UInt64,
|
|
468
|
+
ts DateTime
|
|
469
|
+
)
|
|
470
|
+
ENGINE = MergeTree
|
|
471
|
+
PARTITION BY day
|
|
472
|
+
ORDER BY ${orderBy}`;
|
|
473
|
+
const V1: Obj = { export: "sessions", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: sessionsDdl("(day, user_id)") };
|
|
474
|
+
const V2: Obj = { ...V1, ddl: sessionsDdl("(user_id, day)") };
|
|
475
|
+
const config = (): ClickHouseRebuildOpConfig => ({
|
|
476
|
+
name: "rebuild-sessions",
|
|
477
|
+
env: "e2e",
|
|
478
|
+
table: "shop.sessions",
|
|
479
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "ts", cutoverDelay: "2s" },
|
|
480
|
+
build: false,
|
|
481
|
+
path: dir,
|
|
482
|
+
output: "sessions-v2.json",
|
|
483
|
+
stack: MARKER.stack,
|
|
484
|
+
ownershipEnv: MARKER.env,
|
|
485
|
+
gates: "outer",
|
|
486
|
+
onFailure: "keep",
|
|
487
|
+
});
|
|
488
|
+
const receipts = async () =>
|
|
489
|
+
(await q<{ address: string }>(`SELECT DISTINCT address FROM ${RECEIPTS_DATABASE}.${RECEIPTS_TABLE} WHERE address LIKE 'e2e/test/rebuild/shop.sessions/%'`)).length;
|
|
490
|
+
|
|
491
|
+
test("the failed run keeps the new table and its receipts; the rerun skips what was copied, swaps without a gate and keeps the old table", async () => {
|
|
492
|
+
await clickhouseApply({ buildPath: join(dir, writeBuild("sessions-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
|
|
493
|
+
// Six days of rows, a partition each, all well before any cut-over.
|
|
494
|
+
await q("INSERT INTO shop.sessions SELECT toDate('2026-02-01') + number % 6, number, toDateTime('2026-02-01 00:00:00') + (number % 6) * 86400 FROM numbers(1200)");
|
|
495
|
+
writeBuild("sessions-v2.json", [DB, V2]);
|
|
496
|
+
|
|
497
|
+
// Every attempt of the backfill step fails after its partition: three attempts, three partitions copied, then the run fails.
|
|
498
|
+
let copied = 0;
|
|
499
|
+
const ledger = new Ledger();
|
|
500
|
+
const failure = await runOp(config(), ledger.port, {
|
|
501
|
+
backfill: {
|
|
502
|
+
afterPartition: () => {
|
|
503
|
+
copied++;
|
|
504
|
+
throw new Error(`the backfill failed after ${copied} partition(s)`);
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
}).then(
|
|
508
|
+
() => undefined,
|
|
509
|
+
(e: unknown) => e,
|
|
510
|
+
);
|
|
511
|
+
expect(failure).toBeInstanceOf(OpRunFailure);
|
|
512
|
+
const records = (failure as OpRunFailure).result.records;
|
|
513
|
+
expect(records.find((x) => x.fn === "clickhouseRebuildBackfill")?.error).toMatch(/the backfill failed after 3 partition/);
|
|
514
|
+
// No onFailure ran: the new table, the dual-write view and the receipts are there.
|
|
515
|
+
expect(records.find((x) => x.fn === "clickhouseRebuildCompensate")).toBeUndefined();
|
|
516
|
+
expect(await exists("shop", "sessions__chant_new")).toBe(true);
|
|
517
|
+
expect(await exists("shop", "sessions__chant_dual")).toBe(true);
|
|
518
|
+
expect(await count("shop.sessions__chant_new")).toBe(600);
|
|
519
|
+
expect(await receipts()).toBe(3);
|
|
520
|
+
expect(await showCreate("shop.sessions")).toMatch(/ORDER BY \(day, user_id\)/);
|
|
521
|
+
|
|
522
|
+
// The rerun resumes: three partitions skipped by receipt, three copied, verified, swapped, with no gate.
|
|
523
|
+
const r = await runOp(config(), ledger.port);
|
|
524
|
+
expect(r.status).toBe("ok");
|
|
525
|
+
expect(ledger.port.appended).toEqual([]);
|
|
526
|
+
expect(outcome(r, "Skipped")).toBe(3);
|
|
527
|
+
expect(outcome(r, "Copied")).toBe(3);
|
|
528
|
+
expect(outcome(r, "VerifiedRows")).toBe(1200);
|
|
529
|
+
expect(r.records.map((x) => x.fn)).not.toContain("clickhouseRebuildDrop");
|
|
530
|
+
expect(await showCreate("shop.sessions")).toMatch(/ORDER BY \(user_id, day\)/);
|
|
531
|
+
expect(await count("shop.sessions")).toBe(1200);
|
|
532
|
+
// The old table is kept: dropping it is a run with the Op's own gates.
|
|
533
|
+
expect(await count("shop.sessions__chant_old")).toBe(1200);
|
|
534
|
+
expect(await exists("shop", "sessions__chant_new")).toBe(false);
|
|
535
|
+
expect(await receipts()).toBe(6);
|
|
536
|
+
|
|
537
|
+
// Run with its own gates, the Op has nothing left to swap, and its drop gate binds that old table.
|
|
538
|
+
const ownLedger = new Ledger();
|
|
539
|
+
const own = await runOp({ ...config(), gates: "own" }, ownLedger.port);
|
|
540
|
+
expect(own.status).toBe("gated");
|
|
541
|
+
expect(own.gate).toMatchObject({ gate: "approve-rebuild-sessions" });
|
|
542
|
+
expect(outcome(own, "RebuildState")).toBe("swapped");
|
|
543
|
+
ownLedger.approveLast();
|
|
544
|
+
const drop = await runOp({ ...config(), gates: "own" }, ownLedger.port);
|
|
545
|
+
expect(drop.status).toBe("gated");
|
|
546
|
+
expect(drop.gate).toMatchObject({ gate: "approve-rebuild-sessions-drop" });
|
|
547
|
+
expect(await count("shop.sessions__chant_old")).toBe(1200);
|
|
548
|
+
}, 300_000);
|
|
549
|
+
|
|
550
|
+
test('a refusal says to drop the kept table by hand', async () => {
|
|
551
|
+
// A new table made from another declaration: refused, and nothing will drop it.
|
|
552
|
+
writeBuild("sessions-v3.json", [DB, { ...V1, ddl: sessionsDdl("(ts, user_id)") }]);
|
|
553
|
+
await q("CREATE TABLE shop.sessions__chant_new AS shop.sessions");
|
|
554
|
+
await q(`ALTER TABLE shop.sessions__chant_new MODIFY COMMENT 'x [chant managed-by=chant stack=e2e env=test rebuild=shop.sessions role=new]'`);
|
|
555
|
+
const args = { table: "shop.sessions", buildPath: "sessions-v3.json", environment: "e2e", dualWrite: { mode: "app" as const }, stack: MARKER.stack, ownershipEnv: MARKER.env, cwd: dir };
|
|
556
|
+
await expect(rebuildActivities.clickhouseRebuildCreate({ ...args, keepOnFailure: true }, undefined, deps())).rejects.toThrow(
|
|
557
|
+
/keeps its new table on failure \(onFailure: "keep"\), so nothing drops it: drop shop\.sessions__chant_new/,
|
|
558
|
+
);
|
|
559
|
+
await expect(rebuildActivities.clickhouseRebuildCreate(args, undefined, deps())).rejects.not.toThrow(/onFailure: "keep"/);
|
|
560
|
+
await q("DROP TABLE shop.sessions__chant_new SYNC");
|
|
561
|
+
}, 300_000);
|
|
562
|
+
});
|