@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift on a multi-shard cluster (#3664), against a scratch cluster of the
|
|
3
|
+
* pinned image: two shards (two replicas and one server) with Keeper and a
|
|
4
|
+
* cluster `chant` over all three, the profile naming the first server with
|
|
5
|
+
* topology `cluster:chant`.
|
|
6
|
+
*
|
|
7
|
+
* - The schema applied `ON CLUSTER`, with a `Distributed` table declared with
|
|
8
|
+
* unquoted identifiers, reads as no drift: the server's quoted arguments
|
|
9
|
+
* are the same arguments.
|
|
10
|
+
* - A TTL changed on shard 2's server alone, without `ON CLUSTER`, is drift,
|
|
11
|
+
* seen on that server, though the profile's server still holds the
|
|
12
|
+
* declaration.
|
|
13
|
+
*
|
|
14
|
+
* Runs the sql lexicon's deep reader and core's deep diff, as
|
|
15
|
+
* `chant lifecycle diff --live` does. Needs Docker; skips cleanly without it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
22
|
+
import { diffDeepObservation } from "@intentius/chant/lifecycle/deep-observe";
|
|
23
|
+
import { normalizeDeepObservation } from "@intentius/chant/deep-observation";
|
|
24
|
+
import { dockerAvailable, startScratchCluster, type ScratchCluster } from "../container";
|
|
25
|
+
import { clickhouseQuery } from "../http";
|
|
26
|
+
import { clickhouseImage } from "../../spec/pin";
|
|
27
|
+
import { clickhouseApply, toApplyResult } from "../../op/activities/clickhouse-apply";
|
|
28
|
+
import { normalizeApply } from "@intentius/chant/apply";
|
|
29
|
+
import { database, table } from "../entities";
|
|
30
|
+
import { observeResourcesDeep, sqlDeepNormalizationHooks } from "./deep";
|
|
31
|
+
|
|
32
|
+
const enabled = await dockerAvailable();
|
|
33
|
+
const DB = "chant_e2e_3664";
|
|
34
|
+
let cluster: ScratchCluster | undefined;
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-cluster-drift-"));
|
|
36
|
+
|
|
37
|
+
const DB_DDL = `CREATE DATABASE ${DB}`;
|
|
38
|
+
const EVENTS_DDL = `CREATE TABLE ${DB}.events
|
|
39
|
+
(
|
|
40
|
+
id UInt64,
|
|
41
|
+
ts DateTime
|
|
42
|
+
)
|
|
43
|
+
ENGINE = MergeTree
|
|
44
|
+
ORDER BY id
|
|
45
|
+
TTL ts + INTERVAL 30 DAY`;
|
|
46
|
+
const EVENTS_ALL_DDL = `CREATE TABLE ${DB}.events_all
|
|
47
|
+
(
|
|
48
|
+
id UInt64,
|
|
49
|
+
ts DateTime
|
|
50
|
+
)
|
|
51
|
+
ENGINE = Distributed(chant, ${DB}, events, id)`;
|
|
52
|
+
|
|
53
|
+
const config = () => ({ ownership: { stack: "e2e", env: "test" }, sql: { profiles: { e2e: { url: cluster!.replicas[0]!.url, topology: "cluster:chant" } } } });
|
|
54
|
+
const tag = (fn: (strings: TemplateStringsArray) => unknown, ddl: string) => fn(Object.assign([ddl], { raw: [ddl] }) as unknown as TemplateStringsArray) as unknown as { entityType: string; props: Record<string, unknown> };
|
|
55
|
+
|
|
56
|
+
const ENTITIES = new Map([
|
|
57
|
+
["db", tag(database, DB_DDL)],
|
|
58
|
+
["events", tag(table, EVENTS_DDL)],
|
|
59
|
+
["eventsAll", tag(table, EVENTS_ALL_DDL)],
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
async function drift() {
|
|
63
|
+
const entities = new Map([...ENTITIES].map(([k, v]) => [k, { entityType: v.entityType, props: v.props }]));
|
|
64
|
+
const live = await observeResourcesDeep({ environment: "e2e", entityNames: [...entities.keys()], entities, config: config(), env: {} });
|
|
65
|
+
return diffDeepObservation(entities, normalizeDeepObservation(live), sqlDeepNormalizationHooks).drifted;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
if (!enabled) return;
|
|
70
|
+
cluster = await startScratchCluster(clickhouseImage(), { shards: [2, 1], cluster: "chant", namePrefix: "chant-sql-cluster-drift" });
|
|
71
|
+
}, 600_000);
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await cluster?.stop();
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe.skipIf(!enabled)("drift on a two-shard cluster (#3664)", () => {
|
|
79
|
+
test("applied ON CLUSTER, with unquoted Distributed arguments, it reads as no drift", async () => {
|
|
80
|
+
const build = join(dir, "schema.json");
|
|
81
|
+
writeFileSync(
|
|
82
|
+
build,
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
dialect: "clickhouse",
|
|
85
|
+
applyOrder: ["db", "events", "eventsAll"],
|
|
86
|
+
objects: [
|
|
87
|
+
{ export: "db", type: "ClickHouse::Database", ddl: DB_DDL, dependsOn: [] },
|
|
88
|
+
{ export: "events", type: "ClickHouse::Table", ddl: EVENTS_DDL, dependsOn: ["db"] },
|
|
89
|
+
{ export: "eventsAll", type: "ClickHouse::Table", ddl: EVENTS_ALL_DDL, dependsOn: ["events"] },
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const applied = normalizeApply(toApplyResult(await clickhouseApply({ buildPath: build, environment: "e2e" }, undefined, { config: config(), env: {}, log: () => undefined })));
|
|
94
|
+
expect(applied.notAttempted).toEqual([]);
|
|
95
|
+
// Every server has both tables.
|
|
96
|
+
for (const endpoint of cluster!.replicas) {
|
|
97
|
+
const rows = await clickhouseQuery<{ name: string }>(endpoint, `SELECT name FROM system.tables WHERE database = '${DB}' ORDER BY name`);
|
|
98
|
+
expect(rows.map((r) => r.name)).toEqual(["events", "events_all"]);
|
|
99
|
+
}
|
|
100
|
+
expect(await drift()).toEqual([]);
|
|
101
|
+
}, 300_000);
|
|
102
|
+
|
|
103
|
+
test("a TTL changed on shard 2 alone is drift, seen on that server", async () => {
|
|
104
|
+
const shard2 = cluster!.layout.findIndex((l) => l.shard === "s2");
|
|
105
|
+
await clickhouseQuery(cluster!.replicas[shard2]!, `ALTER TABLE ${DB}.events MODIFY TTL ts + INTERVAL 7 DAY`);
|
|
106
|
+
// The profile's server still holds the declaration.
|
|
107
|
+
const [mine] = await clickhouseQuery<{ q: string }>(cluster!.replicas[0]!, `SELECT create_table_query AS q FROM system.tables WHERE database = '${DB}' AND name = 'events'`);
|
|
108
|
+
expect(mine!.q).toContain("toIntervalDay(30)");
|
|
109
|
+
|
|
110
|
+
const drifted = await drift();
|
|
111
|
+
expect(drifted.map((d) => d.name)).toEqual(["events"]);
|
|
112
|
+
const ttl = drifted[0]!.changes.find((c) => c.path.startsWith("ttl"));
|
|
113
|
+
expect(ttl, JSON.stringify(drifted[0]!.changes)).toBeDefined();
|
|
114
|
+
expect(String(ttl!.live)).toMatch(/7/);
|
|
115
|
+
expect(ttl!.seenOn).toBe(cluster!.layout[shard2]!.host);
|
|
116
|
+
}, 300_000);
|
|
117
|
+
});
|
|
@@ -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" });
|
|
@@ -41,6 +41,26 @@
|
|
|
41
41
|
* With a replica down (#3270) the kill goes on without it, and each sync
|
|
42
42
|
* either finds nothing that replica alone has and returns, or stops the step
|
|
43
43
|
* naming it (`./replicas.ts`).
|
|
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
|
+
*
|
|
50
|
+
* In materialized-view mode a partition is copied in two parts
|
|
51
|
+
* (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
|
|
52
|
+
* above. The rows at or after it are the view's only when they were written
|
|
53
|
+
* after the view was made; the ones the old table already held (a booking
|
|
54
|
+
* next month, an expiry date) reach the new table only through the backfill.
|
|
55
|
+
* The two kinds cannot be told apart by their values, so the second part
|
|
56
|
+
* copies the difference: for each distinct row at or after the cut-over, as
|
|
57
|
+
* many copies as the old table has more of it than the new one. That copy
|
|
58
|
+
* converges on its own, so a run killed during it, or a partition copied
|
|
59
|
+
* again, copies only what is still missing. A row the view is a moment from
|
|
60
|
+
* committing can be copied by both: the copy is followed by a check for rows
|
|
61
|
+
* the new table holds more often than the old one, which deletes and copies
|
|
62
|
+
* those again. Whatever is left, the verification finds, and the swap does
|
|
63
|
+
* not go ahead.
|
|
44
64
|
*/
|
|
45
65
|
|
|
46
66
|
import { EffectReceipt, receiptExpectation } from "@intentius/chant/effect-receipt";
|
|
@@ -49,8 +69,11 @@ import { ident, sqlString } from "../apply/statements";
|
|
|
49
69
|
import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } from "./receipts";
|
|
50
70
|
import { RebuildRefusal, type RebuildObservation } from "./observe";
|
|
51
71
|
import { DDL_SETTINGS } from "./replicas";
|
|
52
|
-
import { sourcePartitionExpression, sourcePartitions } from "./partitions";
|
|
72
|
+
import { shardPartitions, sourcePartitionExpression, sourcePartitions } from "./partitions";
|
|
73
|
+
import { rowHash } from "./verify";
|
|
53
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";
|
|
54
77
|
|
|
55
78
|
export interface BackfillResult {
|
|
56
79
|
state: RebuildObservation["state"];
|
|
@@ -62,19 +85,30 @@ export interface BackfillResult {
|
|
|
62
85
|
skipped: number;
|
|
63
86
|
/** Partitions this run found partly copied and cleared before copying again. */
|
|
64
87
|
cleared: number;
|
|
88
|
+
/** Distinct rows at or after the cut-over found in the new table too often (copied while the view was writing them), deleted and copied again. */
|
|
89
|
+
repaired: number;
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
/** The effect a partition's copy is, and its receipt's address suffix. */
|
|
68
|
-
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}`);
|
|
69
94
|
|
|
70
|
-
/** The receipt's expected value for one partition's copy. */
|
|
71
|
-
export function partitionExpectation(o: RebuildObservation, partition: string, cutover: number | undefined): string {
|
|
72
|
-
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);
|
|
73
98
|
return receiptExpectation(
|
|
74
99
|
EffectReceipt(effect, {
|
|
75
100
|
effect,
|
|
76
101
|
flavor: "hash",
|
|
77
|
-
inputs: {
|
|
102
|
+
inputs: {
|
|
103
|
+
table: o.names.key,
|
|
104
|
+
newTable: o.newTable!.uuid,
|
|
105
|
+
partition,
|
|
106
|
+
...(shard ? { shard: shard.num } : {}),
|
|
107
|
+
copied: o.copied,
|
|
108
|
+
cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
|
|
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.
|
|
110
|
+
...(cutover === undefined ? {} : { after: "difference" }),
|
|
111
|
+
},
|
|
78
112
|
}),
|
|
79
113
|
);
|
|
80
114
|
}
|
|
@@ -82,13 +116,13 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
|
|
|
82
116
|
export interface BackfillDeps {
|
|
83
117
|
/** The receipt store. Default: the receipts table on the target server. */
|
|
84
118
|
receipts?: ClickHouseReceiptStore;
|
|
85
|
-
/** Called after each partition is copied and its receipt written; a test interrupts the backfill here. */
|
|
86
|
-
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>;
|
|
87
121
|
}
|
|
88
122
|
|
|
89
123
|
export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
|
|
90
124
|
const o = await observe(run);
|
|
91
|
-
const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0 };
|
|
125
|
+
const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0, repaired: 0 };
|
|
92
126
|
if (o.state !== "rebuild") return result;
|
|
93
127
|
if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
|
|
94
128
|
const n = o.names;
|
|
@@ -113,6 +147,13 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
113
147
|
const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
|
|
114
148
|
const columns = o.copied.map((c) => ident(c.name)).join(", ");
|
|
115
149
|
const select = o.copied.map((c) => ident(c.source)).join(", ");
|
|
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 : {};
|
|
116
157
|
|
|
117
158
|
const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
|
|
118
159
|
const receipts =
|
|
@@ -125,53 +166,176 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
125
166
|
});
|
|
126
167
|
const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
|
|
127
168
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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) {
|
|
131
175
|
run.signal?.throwIfAborted();
|
|
132
|
-
const effect = partitionEffect(n.key, p);
|
|
133
|
-
const expectation = partitionExpectation(o, p, cutover);
|
|
176
|
+
const effect = partitionEffect(n.key, p, shard);
|
|
177
|
+
const expectation = partitionExpectation(o, p, cutover, shard);
|
|
134
178
|
if (recorded.get(receiptAddress(identity, effect)) === expectation) {
|
|
135
179
|
result.skipped++;
|
|
136
180
|
continue;
|
|
137
181
|
}
|
|
138
182
|
|
|
139
|
-
const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
|
|
140
|
-
|
|
183
|
+
const queryId = `chant-rebuild-${o.newTable.uuid}-${shard ? `shard${shard.num}-` : ""}${p}`;
|
|
184
|
+
const laterQueryId = `${queryId}-after`;
|
|
185
|
+
const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
|
|
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) {
|
|
141
194
|
// On every replica that answers: the copy may be running on another one
|
|
142
195
|
// than this run's. A replica that is down is skipped once Keeper sees it
|
|
143
196
|
// inactive; nothing of its can be running a copy that still writes,
|
|
144
197
|
// and a part it wrote before going down is waited for by the sync below.
|
|
145
|
-
await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE
|
|
198
|
+
await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE ${killWhere} SYNC`, {
|
|
146
199
|
settings: DDL_SETTINGS,
|
|
147
200
|
});
|
|
148
201
|
await syncReplica(run, o, n.database, n.newName);
|
|
149
202
|
} else {
|
|
150
|
-
await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE
|
|
203
|
+
await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
|
|
151
204
|
}
|
|
152
205
|
const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
|
|
153
|
-
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}`);
|
|
154
208
|
if (Number(left?.n ?? 0) > 0) {
|
|
155
|
-
run.log(`--
|
|
156
|
-
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}`;
|
|
157
214
|
run.log(sql);
|
|
158
|
-
await clickhouseQuery(run.target.endpoint, sql);
|
|
215
|
+
await clickhouseQuery(run.target.endpoint, sql, shard && sharding ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
|
|
159
216
|
await waitOn(run, n.database, n.newName);
|
|
160
217
|
result.cleared++;
|
|
161
218
|
}
|
|
162
219
|
|
|
163
|
-
const sql = `INSERT INTO ${
|
|
220
|
+
const sql = `INSERT INTO ${into(shard)} (${columns}) SELECT ${select} FROM ${oldFrom(shard)} _partition_id = ${sqlString(p)}${sourceRange}`;
|
|
164
221
|
run.log(sql);
|
|
165
|
-
await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
|
|
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);
|
|
166
224
|
// The receipt, last, on success only.
|
|
167
225
|
await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
|
|
168
226
|
result.copied++;
|
|
169
|
-
await deps.afterPartition?.(p);
|
|
227
|
+
await deps.afterPartition?.(p, shard?.num);
|
|
170
228
|
}
|
|
171
|
-
run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first`);
|
|
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` : ""}`);
|
|
172
230
|
return result;
|
|
173
231
|
}
|
|
174
232
|
|
|
233
|
+
/** The statements that copy one old partition's rows at or after the cut-over. */
|
|
234
|
+
interface LaterRows {
|
|
235
|
+
/**
|
|
236
|
+
* Per distinct row (by its hash), the old table's copies past the number
|
|
237
|
+
* the new table holds, into the new table. On a cluster of shards, one
|
|
238
|
+
* shard's rows into the same shard.
|
|
239
|
+
*/
|
|
240
|
+
copy: (partition: string, shard?: Shard) => string;
|
|
241
|
+
/** The hashes of the rows the new table holds more copies of than the old one. */
|
|
242
|
+
extras: (partition: string, shard?: Shard) => string;
|
|
243
|
+
/** Delete every copy of those rows from the new table. */
|
|
244
|
+
drop: (partition: string, hashes: readonly string[], shard?: Shard) => string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
|
|
248
|
+
const REPAIR_LIMIT = 1000;
|
|
249
|
+
const REPAIR_ROUNDS = 3;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The statements for the rows at or after the cut-over. The old side casts
|
|
253
|
+
* each column to its type in the new table before hashing, as the
|
|
254
|
+
* verification does, so a row and its copy hash the same.
|
|
255
|
+
*/
|
|
256
|
+
async function laterRowsCopy(
|
|
257
|
+
run: RebuildRun,
|
|
258
|
+
o: RebuildObservation,
|
|
259
|
+
cut: RebuildObservation["copied"][number],
|
|
260
|
+
cutover: number,
|
|
261
|
+
fromOld: string,
|
|
262
|
+
): Promise<LaterRows> {
|
|
263
|
+
const n = o.names;
|
|
264
|
+
const types = new Map(
|
|
265
|
+
(
|
|
266
|
+
await clickhouseQuery<{ name: string; type: string }>(
|
|
267
|
+
run.target.endpoint,
|
|
268
|
+
`SELECT name, type FROM system.columns WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)}`,
|
|
269
|
+
)
|
|
270
|
+
).map((c) => [c.name, c.type]),
|
|
271
|
+
);
|
|
272
|
+
const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
|
|
273
|
+
const newHash = rowHash(o.copied.map((c) => ident(c.name)));
|
|
274
|
+
const columns = o.copied.map((c) => ident(c.name)).join(", ");
|
|
275
|
+
const aliases = o.copied.map((_, i) => `__chant_c${i}`);
|
|
276
|
+
const fromOldSide = o.copied.map((c, i) => `${ident(c.source)} AS ${aliases[i]}`).join(", ");
|
|
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)}`;
|
|
283
|
+
return {
|
|
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 ` +
|
|
288
|
+
`USING (__chant_h) WHERE __chant_k > ifNull(__chant_n, 0)`,
|
|
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 ` +
|
|
292
|
+
`USING (__chant_h) WHERE __chant_n > ifNull(__chant_o, 0) LIMIT ${REPAIR_LIMIT + 1}`,
|
|
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
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Copy one old partition's rows at or after the cut-over that the new table
|
|
304
|
+
* lacks. A row the view had written to the old table but not yet to the new
|
|
305
|
+
* one when the copy read them is copied by both, and is then in the new
|
|
306
|
+
* table once too often: every copy of such a row is deleted from the new
|
|
307
|
+
* table and the difference copied again, a few rounds at most. What is left
|
|
308
|
+
* after that, the verification finds. Returns the rows repaired.
|
|
309
|
+
*/
|
|
310
|
+
async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string, shard?: Shard): Promise<number> {
|
|
311
|
+
const n = o.names;
|
|
312
|
+
const sharded = shard !== undefined && run.sharding !== undefined;
|
|
313
|
+
const insert = async () => {
|
|
314
|
+
const sql = later.copy(p, shard);
|
|
315
|
+
run.log(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
|
+
});
|
|
321
|
+
};
|
|
322
|
+
await insert();
|
|
323
|
+
let repaired = 0;
|
|
324
|
+
for (let round = 0; round < REPAIR_ROUNDS; round++) {
|
|
325
|
+
await syncReplica(run, o, n.database, n.newName);
|
|
326
|
+
const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p, shard))).map((r) => r.h);
|
|
327
|
+
if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
|
|
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`);
|
|
329
|
+
const sql = later.drop(p, extras, shard);
|
|
330
|
+
run.log(sql);
|
|
331
|
+
await clickhouseQuery(run.target.endpoint, sql, sharded ? { settings: { ...DDL_SETTINGS, ...SHARD_DELETE_SETTINGS } } : {});
|
|
332
|
+
await waitOn(run, n.database, n.newName);
|
|
333
|
+
repaired += extras.length;
|
|
334
|
+
await insert();
|
|
335
|
+
}
|
|
336
|
+
return repaired;
|
|
337
|
+
}
|
|
338
|
+
|
|
175
339
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
176
340
|
return new Promise((resolve, reject) => {
|
|
177
341
|
const t = setTimeout(resolve, ms);
|
|
@@ -55,9 +55,10 @@ export type DualWrite =
|
|
|
55
55
|
/**
|
|
56
56
|
* A materialized view on the old table writes every row whose
|
|
57
57
|
* `cutoverColumn` is at or after the cut-over into the new table; the
|
|
58
|
-
* backfill copies the rows before it
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* backfill copies the rows before it, and the rows at or after it that
|
|
59
|
+
* the table held before the view was made. The cut-over is the server's
|
|
60
|
+
* time when the view is made plus `cutoverDelay` (default `1m`), rounded
|
|
61
|
+
* up to a whole second, and the backfill starts once it has passed. The column must be a time
|
|
61
62
|
* (`Date`, `DateTime`, `DateTime64`) that rows arrive in order of, give
|
|
62
63
|
* or take the delay; a row that arrives later than that is caught by
|
|
63
64
|
* the verification, which then fails the run.
|