@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
|
@@ -12,12 +12,20 @@
|
|
|
12
12
|
* `deepNormalizationHooks` prunes what is not a property of the object (the
|
|
13
13
|
* DDL text, the template source, lineage, a view's reads and `TO` target).
|
|
14
14
|
* `chant sql plan` covers the target, and classifies every change.
|
|
15
|
+
*
|
|
16
|
+
* On a `cluster:<name>` topology every replica of every shard is read
|
|
17
|
+
* (#3664). When a server's copy of a table differs from the profile
|
|
18
|
+
* server's, that copy is what is reported, with the server in `observedOn`,
|
|
19
|
+
* which the drift report prints as `seen on`.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { deepObservation, type DeepObservationResult, type DeepResourceObservation, type DeepNormalizationHooks } from "@intentius/chant/deep-observation";
|
|
18
23
|
import type { UnobservedEntity } from "@intentius/chant/lexicon";
|
|
19
|
-
import { bindClickHouse, classifyClickHouseFailure, type BindOptions } from "../live/bind";
|
|
20
|
-
import { readLiveSchema, type LiveObject } from "../live/catalog";
|
|
24
|
+
import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "../live/bind";
|
|
25
|
+
import { readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
|
|
26
|
+
import { clickhouseQuery } from "../http";
|
|
27
|
+
import { sqlString } from "../apply/statements";
|
|
28
|
+
import { stripMarkerFromStatement } from "../ownership";
|
|
21
29
|
import { database, table, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
22
30
|
import { canonicalObject, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
23
31
|
import { renderFor } from "../topology";
|
|
@@ -76,6 +84,73 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
|
|
|
76
84
|
return out;
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
/** Every server's copy of each table and view on a cluster (#3664), as `<database>.<name>` → host → statement. */
|
|
88
|
+
interface ClusterCopies {
|
|
89
|
+
/** The host the profile names, whose copy the rest are compared with. */
|
|
90
|
+
self: string;
|
|
91
|
+
byKey: Map<string, Map<string, string>>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* On a `cluster:<name>` topology, read `system.tables` on every replica of
|
|
96
|
+
* every shard through the profile's server (`clusterAllReplicas`). A change
|
|
97
|
+
* made on one server without `ON CLUSTER` (a TTL altered on shard 2 alone)
|
|
98
|
+
* is then seen, which the profile's server alone would not show. Undefined
|
|
99
|
+
* for any other topology: there is one copy.
|
|
100
|
+
*/
|
|
101
|
+
async function clusterCopies(target: ClickHouseTarget): Promise<ClusterCopies | undefined> {
|
|
102
|
+
if (target.topology?.kind !== "cluster") return undefined;
|
|
103
|
+
const scope = target.databases
|
|
104
|
+
? `IN (${target.databases.map(sqlString).join(", ")})`
|
|
105
|
+
: `NOT IN (${[...SYSTEM_DATABASES].map(sqlString).join(", ")})`;
|
|
106
|
+
const [self] = await clickhouseQuery<{ host: string }>(target.endpoint, "SELECT hostName() AS host");
|
|
107
|
+
const rows = await clickhouseQuery<{ host: string; database: string; name: string; statement: string }>(
|
|
108
|
+
target.endpoint,
|
|
109
|
+
`SELECT hostName() AS host, database, name, create_table_query AS statement ` +
|
|
110
|
+
`FROM clusterAllReplicas(${sqlString(target.topology.cluster)}, system.tables) ` +
|
|
111
|
+
`WHERE database ${scope} AND NOT is_temporary AND name NOT LIKE '.inner%' AND engine != 'Dictionary' ORDER BY host, database, name`,
|
|
112
|
+
);
|
|
113
|
+
const byKey = new Map<string, Map<string, string>>();
|
|
114
|
+
for (const r of rows) {
|
|
115
|
+
const key = `${r.database}.${r.name}`;
|
|
116
|
+
if (!byKey.has(key)) byKey.set(key, new Map());
|
|
117
|
+
byKey.get(key)!.set(r.host, stripMarkerFromStatement(r.statement));
|
|
118
|
+
}
|
|
119
|
+
return { self: self?.host ?? "", byKey };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* When the servers' copies of `key` do not all read the same, the copy to
|
|
124
|
+
* report and its server: whichever departs furthest from the declaration
|
|
125
|
+
* `d`, the profile server's own winning a tie. A copy that differs from the
|
|
126
|
+
* declaration on any server is then the one the diff sees. Undefined when
|
|
127
|
+
* every copy reads the same.
|
|
128
|
+
*/
|
|
129
|
+
function copyToReport(copies: ClusterCopies, key: string, d: CanonicalObject, defaultDatabase: string): { host: string; statement: string } | undefined {
|
|
130
|
+
const byHost = copies.byKey.get(key);
|
|
131
|
+
const mine = byHost?.get(copies.self);
|
|
132
|
+
if (!byHost || mine === undefined) return undefined;
|
|
133
|
+
const shape = (c: CanonicalObject) => {
|
|
134
|
+
const { columns, ...rest } = c;
|
|
135
|
+
return JSON.stringify({ ...rest, columns: columns.map(({ text: _t, position: _p, ...col }) => col) });
|
|
136
|
+
};
|
|
137
|
+
const reference = shape(canonicalObject(mine, defaultDatabase));
|
|
138
|
+
const declared = JSON.parse(shape(d)) as Record<string, unknown>;
|
|
139
|
+
/** How many of the declaration's fields this copy holds differently. */
|
|
140
|
+
const departures = (statement: string) => {
|
|
141
|
+
const live = JSON.parse(shape(canonicalObject(statement, defaultDatabase))) as Record<string, unknown>;
|
|
142
|
+
return Object.keys({ ...declared, ...live }).filter((k) => k !== "name" && k !== "database" && JSON.stringify(declared[k]) !== JSON.stringify(live[k])).length;
|
|
143
|
+
};
|
|
144
|
+
let pick: { host: string; statement: string; score: number } | undefined;
|
|
145
|
+
for (const [host, statement] of byHost) {
|
|
146
|
+
if (host === copies.self || shape(canonicalObject(statement, defaultDatabase)) === reference) continue;
|
|
147
|
+
const score = departures(statement);
|
|
148
|
+
if (!pick || score > pick.score) pick = { host, statement, score };
|
|
149
|
+
}
|
|
150
|
+
if (!pick) return undefined;
|
|
151
|
+
return pick.score > departures(mine) ? { host: pick.host, statement: pick.statement } : { host: copies.self, statement: mine };
|
|
152
|
+
}
|
|
153
|
+
|
|
79
154
|
export async function observeResourcesDeep(
|
|
80
155
|
options: {
|
|
81
156
|
environment: string;
|
|
@@ -98,6 +173,17 @@ export async function observeResourcesDeep(
|
|
|
98
173
|
return deepObservation({}, unobserved);
|
|
99
174
|
}
|
|
100
175
|
const byKey = new Map(live.map((o) => [`${o.database ?? ""}.${o.name}`, o]));
|
|
176
|
+
let copies: ClusterCopies | undefined;
|
|
177
|
+
try {
|
|
178
|
+
copies = await clusterCopies(target);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// A cluster whose servers cannot all be read is not reported as matching.
|
|
181
|
+
const why = classifyClickHouseFailure(err);
|
|
182
|
+
for (const name of options.entityNames) {
|
|
183
|
+
unobserved[name] = { type: options.entities.get(name)?.entityType ?? "", reason: why.reason, detail: `reading every server of the cluster: ${why.detail}` };
|
|
184
|
+
}
|
|
185
|
+
return deepObservation({}, unobserved);
|
|
186
|
+
}
|
|
101
187
|
for (const name of options.entityNames) {
|
|
102
188
|
const entity = options.entities.get(name);
|
|
103
189
|
if (!entity || !entity.entityType.startsWith("ClickHouse::")) {
|
|
@@ -111,10 +197,23 @@ export async function observeResourcesDeep(
|
|
|
111
197
|
if (!o) continue;
|
|
112
198
|
try {
|
|
113
199
|
const d = canonicalObject(renderFor(String(props.ddl), target.topology), target.defaultDatabase);
|
|
200
|
+
const physicalId = o.uuid ?? `${db ? `${db}.` : ""}${o.name}`;
|
|
201
|
+
const other = copies && !isDb ? copyToReport(copies, `${db ?? ""}.${o.name}`, d, target.defaultDatabase) : undefined;
|
|
202
|
+
if (other) {
|
|
203
|
+
// The servers disagree: report the copy that departs from the declaration, and where it was seen.
|
|
204
|
+
const copy: LiveObject = { ...o, statement: other.host === copies!.self ? o.statement : other.statement };
|
|
205
|
+
resources[name] = {
|
|
206
|
+
type: o.type,
|
|
207
|
+
physicalId,
|
|
208
|
+
properties: inDeclaredVocabulary(props, liveProps(copy), d, canonicalObject(copy.statement, target.defaultDatabase)),
|
|
209
|
+
observedOn: other.host,
|
|
210
|
+
};
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
114
213
|
const l = canonicalObject(o.statement, target.defaultDatabase);
|
|
115
214
|
resources[name] = {
|
|
116
215
|
type: o.type,
|
|
117
|
-
physicalId
|
|
216
|
+
physicalId,
|
|
118
217
|
properties: inDeclaredVocabulary(props, liveProps(o), d, l),
|
|
119
218
|
};
|
|
120
219
|
} catch (err) {
|
|
@@ -284,6 +284,10 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
284
284
|
name = shared[1]!;
|
|
285
285
|
if (/^'.*'$/s.test(args[0] ?? "") && /^'.*'$/s.test(args[1] ?? "")) args = args.slice(2);
|
|
286
286
|
}
|
|
287
|
+
// `Distributed(cluster, db, table, ...)` takes its first three arguments
|
|
288
|
+
// as identifiers or as strings, and the server prints them back quoted
|
|
289
|
+
// (#3664): an unquoted declaration says the same.
|
|
290
|
+
if (name === "Distributed") args = args.map((a, i) => (i < 3 ? quotedIdentifier(a) : a));
|
|
287
291
|
obj.engineName = name;
|
|
288
292
|
obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
|
|
289
293
|
}
|
|
@@ -359,3 +363,9 @@ function column(tokens: Token[], c: ColumnNode, position: number, database?: str
|
|
|
359
363
|
if (prev) out.previously = prev;
|
|
360
364
|
return out;
|
|
361
365
|
}
|
|
366
|
+
|
|
367
|
+
/** A bare or backquoted identifier as the string literal the server prints it as; anything else as it is. */
|
|
368
|
+
function quotedIdentifier(arg: string): string {
|
|
369
|
+
const bare = /^[A-Za-z_][A-Za-z0-9_]*$/.test(arg) ? arg : /^`((?:[^`]|``)+)`$/.exec(arg)?.[1]?.replace(/``/g, "`");
|
|
370
|
+
return bare === undefined ? arg : `'${bare.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
371
|
+
}
|
|
@@ -87,6 +87,16 @@ describe("normalization", () => {
|
|
|
87
87
|
expect(shown("ReplicatedMergeTree('/clickhouse/tables/{shard}/t', '{replica}')")).not.toBe(shown("ReplicatedMergeTree"));
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test("a Distributed engine's cluster, database and table read the same quoted or not (#3664)", () => {
|
|
91
|
+
const shown = (engine: string) => canonicalObject(`CREATE TABLE d.events_all (id UInt64) ENGINE = ${engine}`).engine;
|
|
92
|
+
expect(shown("Distributed(my_cluster, d, events, id)")).toBe(shown("Distributed('my_cluster', 'd', 'events', id)"));
|
|
93
|
+
expect(shown("Distributed(`my_cluster`, `d`, `events`)")).toBe(shown("Distributed('my_cluster', 'd', 'events')"));
|
|
94
|
+
expect(shown("Distributed('{cluster}', currentDatabase(), events, rand())")).toBe(shown("Distributed('{cluster}', currentDatabase(), 'events', rand())"));
|
|
95
|
+
// The sharding key is an expression, not a name: it stays as written.
|
|
96
|
+
expect(shown("Distributed(c, d, events, id)")).not.toBe(shown("Distributed(c, d, events, 'id')"));
|
|
97
|
+
expect(shown("Distributed(c, d, events)")).not.toBe(shown("Distributed(c, d, other)"));
|
|
98
|
+
});
|
|
99
|
+
|
|
90
100
|
test("a setting at its default is not part of the definition", () => {
|
|
91
101
|
expect(canonicalObject("CREATE TABLE t (a UInt8) ENGINE = MergeTree ORDER BY a SETTINGS index_granularity = 8192").settings).toEqual({});
|
|
92
102
|
expect(canonicalObject("CREATE TABLE t (a UInt8) ENGINE = MergeTree ORDER BY a SETTINGS min_bytes_for_wide_part = '0'").settings).toEqual({ min_bytes_for_wide_part: "0" });
|
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
* either finds nothing that replica alone has and returns, or stops the step
|
|
43
43
|
* naming it (`./replicas.ts`).
|
|
44
44
|
*
|
|
45
|
+
* On a cluster of more than one shard (#3663) the unit is a partition on one
|
|
46
|
+
* shard: read from that shard, written back to it, cleared on it alone, with
|
|
47
|
+
* its own receipt (`./shards.ts`), so a rerun after a failure on one shard
|
|
48
|
+
* skips what every shard already copied.
|
|
49
|
+
*
|
|
45
50
|
* In materialized-view mode a partition is copied in two parts
|
|
46
51
|
* (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
|
|
47
52
|
* above. The rows at or after it are the view's only when they were written
|
|
@@ -64,9 +69,11 @@ import { ident, sqlString } from "../apply/statements";
|
|
|
64
69
|
import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } from "./receipts";
|
|
65
70
|
import { RebuildRefusal, type RebuildObservation } from "./observe";
|
|
66
71
|
import { DDL_SETTINGS } from "./replicas";
|
|
67
|
-
import { sourcePartitionExpression, sourcePartitions } from "./partitions";
|
|
72
|
+
import { shardPartitions, sourcePartitionExpression, sourcePartitions } from "./partitions";
|
|
68
73
|
import { rowHash } from "./verify";
|
|
69
74
|
import { cutoverOf, observe, serverNow, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
|
|
75
|
+
import { intoShard, onShard, onShardServers, shardTable, SHARD_DELETE_SETTINGS, SHARD_INSERT_SETTINGS, type Shard } from "./shards";
|
|
76
|
+
import { renderFor } from "../topology";
|
|
70
77
|
|
|
71
78
|
export interface BackfillResult {
|
|
72
79
|
state: RebuildObservation["state"];
|
|
@@ -83,11 +90,11 @@ export interface BackfillResult {
|
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
/** The effect a partition's copy is, and its receipt's address suffix. */
|
|
86
|
-
export const partitionEffect = (key: string, partition: string): string => `rebuild/${key}/${partition}
|
|
93
|
+
export const partitionEffect = (key: string, partition: string, shard?: Shard): string => (shard ? `rebuild/${key}/shard${shard.num}/${partition}` : `rebuild/${key}/${partition}`);
|
|
87
94
|
|
|
88
|
-
/** The receipt's expected value for one partition's copy. */
|
|
89
|
-
export function partitionExpectation(o: RebuildObservation, partition: string, cutover: number | undefined): string {
|
|
90
|
-
const effect = partitionEffect(o.names.key, partition);
|
|
95
|
+
/** The receipt's expected value for one partition's copy, on one shard of a cluster when `shard` is given. */
|
|
96
|
+
export function partitionExpectation(o: RebuildObservation, partition: string, cutover: number | undefined, shard?: Shard): string {
|
|
97
|
+
const effect = partitionEffect(o.names.key, partition, shard);
|
|
91
98
|
return receiptExpectation(
|
|
92
99
|
EffectReceipt(effect, {
|
|
93
100
|
effect,
|
|
@@ -96,6 +103,7 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
|
|
|
96
103
|
table: o.names.key,
|
|
97
104
|
newTable: o.newTable!.uuid,
|
|
98
105
|
partition,
|
|
106
|
+
...(shard ? { shard: shard.num } : {}),
|
|
99
107
|
copied: o.copied,
|
|
100
108
|
cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
|
|
101
109
|
// The rows at or after the cut-over the old table held are copied too (sql-yodeler#45); a receipt from before that copied fewer.
|
|
@@ -108,8 +116,8 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
|
|
|
108
116
|
export interface BackfillDeps {
|
|
109
117
|
/** The receipt store. Default: the receipts table on the target server. */
|
|
110
118
|
receipts?: ClickHouseReceiptStore;
|
|
111
|
-
/** Called after each partition is copied and its receipt written; a test interrupts the backfill here. */
|
|
112
|
-
afterPartition?: (partition: string) => void | Promise<void>;
|
|
119
|
+
/** Called after each partition is copied and its receipt written, with its shard on a cluster of shards; a test interrupts the backfill here. */
|
|
120
|
+
afterPartition?: (partition: string, shard?: number) => void | Promise<void>;
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
|
|
@@ -140,6 +148,12 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
140
148
|
const columns = o.copied.map((c) => ident(c.name)).join(", ");
|
|
141
149
|
const select = o.copied.map((c) => ident(c.source)).join(", ");
|
|
142
150
|
const later = cutColumn && cutover !== undefined ? await laterRowsCopy(run, o, cutColumn, cutover, fromOld) : undefined;
|
|
151
|
+
const sharding = run.sharding;
|
|
152
|
+
// The old and new tables as one shard's rows (#3663, `./shards.ts`), or as this server's.
|
|
153
|
+
const oldFrom = (shard?: Shard) => (shard && sharding ? `${shardTable(sharding, n.database, n.name)} WHERE ${onShard(shard)} AND` : `${n.table} WHERE`);
|
|
154
|
+
const newFrom = (shard?: Shard) => (shard && sharding ? `${shardTable(sharding, n.database, n.newName)} WHERE ${onShard(shard)} AND` : `${n.newTable} WHERE`);
|
|
155
|
+
const into = (shard?: Shard) => (shard && sharding ? intoShard(sharding, shard, n.database, n.newName) : n.newTable);
|
|
156
|
+
const insertSettings = sharding ? SHARD_INSERT_SETTINGS : {};
|
|
143
157
|
|
|
144
158
|
const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
|
|
145
159
|
const receipts =
|
|
@@ -152,21 +166,31 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
152
166
|
});
|
|
153
167
|
const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
|
|
154
168
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
// The unit of work: a partition of the old table, on each shard of a cluster that has more than one.
|
|
170
|
+
const units: Array<{ shard?: Shard; partition: string }> = sharding
|
|
171
|
+
? (await shardPartitions(run, sharding, n.database, n.name)).map((u) => ({ shard: sharding.shards.find((x) => x.num === u.shard)!, partition: u.partition }))
|
|
172
|
+
: (await sourcePartitions(run.target, n)).map((partition) => ({ partition }));
|
|
173
|
+
result.partitions = units.length;
|
|
174
|
+
for (const { shard, partition: p } of units) {
|
|
158
175
|
run.signal?.throwIfAborted();
|
|
159
|
-
const effect = partitionEffect(n.key, p);
|
|
160
|
-
const expectation = partitionExpectation(o, p, cutover);
|
|
176
|
+
const effect = partitionEffect(n.key, p, shard);
|
|
177
|
+
const expectation = partitionExpectation(o, p, cutover, shard);
|
|
161
178
|
if (recorded.get(receiptAddress(identity, effect)) === expectation) {
|
|
162
179
|
result.skipped++;
|
|
163
180
|
continue;
|
|
164
181
|
}
|
|
165
182
|
|
|
166
|
-
const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
|
|
183
|
+
const queryId = `chant-rebuild-${o.newTable.uuid}-${shard ? `shard${shard.num}-` : ""}${p}`;
|
|
167
184
|
const laterQueryId = `${queryId}-after`;
|
|
168
185
|
const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
|
|
169
|
-
if (
|
|
186
|
+
if (sharding) {
|
|
187
|
+
// The copy reads and writes every shard from this server; what it started on the others goes with it.
|
|
188
|
+
await clickhouseQuery(
|
|
189
|
+
run.target.endpoint,
|
|
190
|
+
`KILL QUERY ON CLUSTER ${sqlString(sharding.cluster)} WHERE ${killWhere} OR initial_query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)}) SYNC`,
|
|
191
|
+
{ settings: DDL_SETTINGS },
|
|
192
|
+
);
|
|
193
|
+
} else if (o.replicated) {
|
|
170
194
|
// On every replica that answers: the copy may be running on another one
|
|
171
195
|
// than this run's. A replica that is down is skipped once Keeper sees it
|
|
172
196
|
// inactive; nothing of its can be running a copy that still writes,
|
|
@@ -179,24 +203,28 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
179
203
|
await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
|
|
180
204
|
}
|
|
181
205
|
const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
|
|
182
|
-
const
|
|
206
|
+
const where = shard ? `partition ${p} on shard ${shard.num}` : `partition ${p}`;
|
|
207
|
+
const [left] = await clickhouseQuery<{ n: string | number }>(run.target.endpoint, `SELECT count() AS n FROM ${newFrom(shard)} ${mine}`);
|
|
183
208
|
if (Number(left?.n ?? 0) > 0) {
|
|
184
|
-
run.log(`--
|
|
185
|
-
const sql =
|
|
209
|
+
run.log(`-- ${where}: ${left!.n} row(s) from a copy that did not finish; deleting them before copying again`);
|
|
210
|
+
const sql =
|
|
211
|
+
shard && sharding
|
|
212
|
+
? renderFor(`ALTER TABLE ${n.newTable} DELETE WHERE ${onShardServers(shard)} AND ${mine}`, run.target.topology)
|
|
213
|
+
: `ALTER TABLE ${n.newTable} DELETE WHERE ${mine}`;
|
|
186
214
|
run.log(sql);
|
|
187
|
-
await clickhouseQuery(run.target.endpoint, sql);
|
|
215
|
+
await clickhouseQuery(run.target.endpoint, sql, shard && sharding ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
|
|
188
216
|
await waitOn(run, n.database, n.newName);
|
|
189
217
|
result.cleared++;
|
|
190
218
|
}
|
|
191
219
|
|
|
192
|
-
const sql = `INSERT INTO ${
|
|
220
|
+
const sql = `INSERT INTO ${into(shard)} (${columns}) SELECT ${select} FROM ${oldFrom(shard)} _partition_id = ${sqlString(p)}${sourceRange}`;
|
|
193
221
|
run.log(sql);
|
|
194
|
-
await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
|
|
195
|
-
if (later) result.repaired += await copyAfterCutover(run, o, later, p, laterQueryId);
|
|
222
|
+
await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0", ...insertSettings }, ...(run.signal ? { signal: run.signal } : {}) });
|
|
223
|
+
if (later) result.repaired += await copyAfterCutover(run, o, later, p, laterQueryId, shard);
|
|
196
224
|
// The receipt, last, on success only.
|
|
197
225
|
await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
|
|
198
226
|
result.copied++;
|
|
199
|
-
await deps.afterPartition?.(p);
|
|
227
|
+
await deps.afterPartition?.(p, shard?.num);
|
|
200
228
|
}
|
|
201
229
|
run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first${result.repaired > 0 ? `, ${result.repaired} row(s) after the cut-over copied twice and repaired` : ""}`);
|
|
202
230
|
return result;
|
|
@@ -206,13 +234,14 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
206
234
|
interface LaterRows {
|
|
207
235
|
/**
|
|
208
236
|
* Per distinct row (by its hash), the old table's copies past the number
|
|
209
|
-
* the new table holds, into the new table.
|
|
237
|
+
* the new table holds, into the new table. On a cluster of shards, one
|
|
238
|
+
* shard's rows into the same shard.
|
|
210
239
|
*/
|
|
211
|
-
copy: (partition: string) => string;
|
|
240
|
+
copy: (partition: string, shard?: Shard) => string;
|
|
212
241
|
/** The hashes of the rows the new table holds more copies of than the old one. */
|
|
213
|
-
extras: (partition: string) => string;
|
|
242
|
+
extras: (partition: string, shard?: Shard) => string;
|
|
214
243
|
/** Delete every copy of those rows from the new table. */
|
|
215
|
-
drop: (partition: string, hashes: readonly string[]) => string;
|
|
244
|
+
drop: (partition: string, hashes: readonly string[], shard?: Shard) => string;
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
/** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
|
|
@@ -245,19 +274,28 @@ async function laterRowsCopy(
|
|
|
245
274
|
const columns = o.copied.map((c) => ident(c.name)).join(", ");
|
|
246
275
|
const aliases = o.copied.map((_, i) => `__chant_c${i}`);
|
|
247
276
|
const fromOldSide = o.copied.map((c, i) => `${ident(c.source)} AS ${aliases[i]}`).join(", ");
|
|
248
|
-
const
|
|
249
|
-
const
|
|
277
|
+
const sharding = run.sharding;
|
|
278
|
+
const on = (shard?: Shard) => (shard && sharding ? ` AND ${onShard(shard)}` : "");
|
|
279
|
+
const oldTable = (shard?: Shard) => (shard && sharding ? shardTable(sharding, n.database, n.name) : n.table);
|
|
280
|
+
const newTable = (shard?: Shard) => (shard && sharding ? shardTable(sharding, n.database, n.newName) : n.newTable);
|
|
281
|
+
const oldRows = (p: string, shard?: Shard) => `FROM ${oldTable(shard)} WHERE _partition_id = ${sqlString(p)} AND ${ident(cut.source)} >= ${utcLiteral(cutover)}${on(shard)}`;
|
|
282
|
+
const newRows = (p: string, shard?: Shard) => `FROM ${newTable(shard)} WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)}${on(shard)}`;
|
|
250
283
|
return {
|
|
251
|
-
copy: (p) =>
|
|
252
|
-
`INSERT INTO ${n.newTable} (${columns}) SELECT ${aliases.join(", ")} FROM (` +
|
|
253
|
-
`SELECT ${fromOldSide}, ${oldHash} AS __chant_h, row_number() OVER (PARTITION BY __chant_h) AS __chant_k ${oldRows(p)}` +
|
|
254
|
-
`) AS old_rows LEFT JOIN (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
|
|
284
|
+
copy: (p, shard) =>
|
|
285
|
+
`INSERT INTO ${shard && sharding ? intoShard(sharding, shard, n.database, n.newName) : n.newTable} (${columns}) SELECT ${aliases.join(", ")} FROM (` +
|
|
286
|
+
`SELECT ${fromOldSide}, ${oldHash} AS __chant_h, row_number() OVER (PARTITION BY __chant_h) AS __chant_k ${oldRows(p, shard)}` +
|
|
287
|
+
`) AS old_rows LEFT JOIN (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p, shard)} GROUP BY __chant_h) AS new_rows ` +
|
|
255
288
|
`USING (__chant_h) WHERE __chant_k > ifNull(__chant_n, 0)`,
|
|
256
|
-
extras: (p) =>
|
|
257
|
-
`SELECT toString(__chant_h) AS h FROM (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
|
|
258
|
-
`LEFT JOIN (SELECT ${oldHash} AS __chant_h, count() AS __chant_o ${oldRows(p)} GROUP BY __chant_h) AS old_rows ` +
|
|
289
|
+
extras: (p, shard) =>
|
|
290
|
+
`SELECT toString(__chant_h) AS h FROM (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p, shard)} GROUP BY __chant_h) AS new_rows ` +
|
|
291
|
+
`LEFT JOIN (SELECT ${oldHash} AS __chant_h, count() AS __chant_o ${oldRows(p, shard)} GROUP BY __chant_h) AS old_rows ` +
|
|
259
292
|
`USING (__chant_h) WHERE __chant_n > ifNull(__chant_o, 0) LIMIT ${REPAIR_LIMIT + 1}`,
|
|
260
|
-
drop: (p, hashes) =>
|
|
293
|
+
drop: (p, hashes, shard) => {
|
|
294
|
+
const where = `${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)} AND ${newHash} IN (${hashes.join(", ")})`;
|
|
295
|
+
return shard && sharding
|
|
296
|
+
? renderFor(`ALTER TABLE ${n.newTable} DELETE WHERE ${onShardServers(shard)} AND ${where}`, run.target.topology)
|
|
297
|
+
: `ALTER TABLE ${n.newTable} DELETE WHERE ${where}`;
|
|
298
|
+
},
|
|
261
299
|
};
|
|
262
300
|
}
|
|
263
301
|
|
|
@@ -269,23 +307,28 @@ async function laterRowsCopy(
|
|
|
269
307
|
* table and the difference copied again, a few rounds at most. What is left
|
|
270
308
|
* after that, the verification finds. Returns the rows repaired.
|
|
271
309
|
*/
|
|
272
|
-
async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string): Promise<number> {
|
|
310
|
+
async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string, shard?: Shard): Promise<number> {
|
|
273
311
|
const n = o.names;
|
|
312
|
+
const sharded = shard !== undefined && run.sharding !== undefined;
|
|
274
313
|
const insert = async () => {
|
|
275
|
-
const sql = later.copy(p);
|
|
314
|
+
const sql = later.copy(p, shard);
|
|
276
315
|
run.log(sql);
|
|
277
|
-
await clickhouseQuery(run.target.endpoint, sql, {
|
|
316
|
+
await clickhouseQuery(run.target.endpoint, sql, {
|
|
317
|
+
queryId,
|
|
318
|
+
settings: { async_insert: "0", insert_deduplicate: "0", ...(sharded ? SHARD_INSERT_SETTINGS : {}) },
|
|
319
|
+
...(run.signal ? { signal: run.signal } : {}),
|
|
320
|
+
});
|
|
278
321
|
};
|
|
279
322
|
await insert();
|
|
280
323
|
let repaired = 0;
|
|
281
324
|
for (let round = 0; round < REPAIR_ROUNDS; round++) {
|
|
282
325
|
await syncReplica(run, o, n.database, n.newName);
|
|
283
|
-
const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p))).map((r) => r.h);
|
|
326
|
+
const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p, shard))).map((r) => r.h);
|
|
284
327
|
if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
|
|
285
328
|
run.log(`-- partition ${p}: ${extras.length} row(s) after the cut-over in the new table more often than in the old one; deleting them and copying again`);
|
|
286
|
-
const sql = later.drop(p, extras);
|
|
329
|
+
const sql = later.drop(p, extras, shard);
|
|
287
330
|
run.log(sql);
|
|
288
|
-
await clickhouseQuery(run.target.endpoint, sql);
|
|
331
|
+
await clickhouseQuery(run.target.endpoint, sql, sharded ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
|
|
289
332
|
await waitOn(run, n.database, n.newName);
|
|
290
333
|
repaired += extras.length;
|
|
291
334
|
await insert();
|
|
@@ -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;
|