@intentius/chant-lexicon-sql 0.113.0 → 0.114.1
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/apply.d.ts +6 -1
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +8 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +11 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +7 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +4 -3
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +17 -0
- 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/receipts.d.ts +5 -0
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +5 -3
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +41 -18
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts +12 -0
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -0
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/statements.d.ts +2 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +8 -0
- package/dist/migration-statements.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +10 -3
- package/src/clickhouse/apply/statements.ts +19 -0
- package/src/clickhouse/live/bind.ts +13 -1
- package/src/clickhouse/live/live.test.ts +10 -0
- package/src/clickhouse/plan/commands.ts +42 -12
- package/src/clickhouse/plan/deep.ts +2 -1
- package/src/clickhouse/plan/diff.ts +3 -1
- package/src/clickhouse/plan/normalize.ts +11 -2
- package/src/clickhouse/plan/schema.ts +6 -5
- package/src/clickhouse/rebuild/backfill.ts +127 -5
- package/src/clickhouse/rebuild/observe.ts +4 -3
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
- package/src/clickhouse/rebuild/receipts.ts +14 -4
- package/src/clickhouse/rebuild/steps.ts +22 -9
- package/src/clickhouse/rebuild/verify.ts +131 -36
- package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
- package/src/clickhouse/topology.e2e.test.ts +84 -1
- package/src/clickhouse/topology.test.ts +33 -0
- package/src/clickhouse/topology.ts +26 -0
- package/src/clickhouse.ts +16 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/config.ts +33 -0
- package/src/core/statements.ts +2 -0
- package/src/migration-statements.test.ts +120 -1
- package/src/migration-statements.ts +13 -3
- package/src/op/activities/clickhouse-apply.ts +1 -1
- package/src/op/activities/clickhouse-rebuild.ts +1 -1
- package/src/op/apply-gate-create.e2e.test.ts +280 -0
- package/src/plugin.test.ts +9 -0
- 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
|
@@ -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.
|
|
@@ -7,6 +7,9 @@
|
|
|
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.
|
|
10
13
|
* - An interrupted backfill: stopped after three partitions, then killed
|
|
11
14
|
* between a partition's INSERT and its receipt, then resumed: the receipts
|
|
12
15
|
* skip what was copied, the half-copied partition is cleared first, and the
|
|
@@ -174,7 +177,7 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
|
|
|
174
177
|
expect(outcome(r, "RebuildState")).toBe("rebuild");
|
|
175
178
|
expect(outcome(r, "VerifiedRows")).toBe(1600);
|
|
176
179
|
expect(outcome(r, "VerifiedPartitions")).toBe(4);
|
|
177
|
-
expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) before the cut-over/);
|
|
180
|
+
expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) \(1600 of them before the cut-over/);
|
|
178
181
|
expect(await count("shop.events__chant_new")).toBe(1600);
|
|
179
182
|
expect(await exists("shop", "events__chant_dual")).toBe(true);
|
|
180
183
|
// Nothing visible to a plan or an apply: the working objects are left out.
|
|
@@ -235,6 +238,108 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
|
|
|
235
238
|
}, 300_000);
|
|
236
239
|
});
|
|
237
240
|
|
|
241
|
+
describe.skipIf(!enabled)("rows dated after the cut-over that the table already held (sql-yodeler#45)", () => {
|
|
242
|
+
const bookingsDdl = (orderBy: string) => `CREATE TABLE shop.bookings
|
|
243
|
+
(
|
|
244
|
+
id UInt64,
|
|
245
|
+
kind LowCardinality(String),
|
|
246
|
+
at DateTime
|
|
247
|
+
)
|
|
248
|
+
ENGINE = MergeTree
|
|
249
|
+
PARTITION BY toYYYYMM(at)
|
|
250
|
+
ORDER BY ${orderBy}`;
|
|
251
|
+
const V1: Obj = { export: "bookings", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: bookingsDdl("(kind, at)") };
|
|
252
|
+
const V2: Obj = { ...V1, ddl: bookingsDdl("(kind, id)") };
|
|
253
|
+
const config = (): ClickHouseRebuildOpConfig => ({
|
|
254
|
+
name: "rebuild-bookings",
|
|
255
|
+
env: "e2e",
|
|
256
|
+
table: "shop.bookings",
|
|
257
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "at", cutoverDelay: "2s" },
|
|
258
|
+
build: false,
|
|
259
|
+
path: dir,
|
|
260
|
+
output: "bookings-v2.json",
|
|
261
|
+
retain: "0s",
|
|
262
|
+
stack: MARKER.stack,
|
|
263
|
+
ownershipEnv: MARKER.env,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("every row is copied and compared, those written during the rebuild too, and the swap leaves none behind", async () => {
|
|
267
|
+
await clickhouseApply({ buildPath: join(dir, writeBuild("bookings-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
|
|
268
|
+
// 600 rows seven hours apart, from four months ago to about two months
|
|
269
|
+
// ahead, with an identical pair among the future ones.
|
|
270
|
+
await q(
|
|
271
|
+
"INSERT INTO shop.bookings SELECT number, ['view','cart','buy'][number % 3 + 1], toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(number * 7) FROM numbers(600)",
|
|
272
|
+
);
|
|
273
|
+
await q("INSERT INTO shop.bookings VALUES (599, 'buy', toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(599 * 7))");
|
|
274
|
+
const future = Number((await q<{ n: string }>("SELECT count() AS n FROM shop.bookings WHERE at > now() + 60"))[0]!.n);
|
|
275
|
+
expect(future).toBeGreaterThan(150);
|
|
276
|
+
writeBuild("bookings-v2.json", [DB, V2]);
|
|
277
|
+
|
|
278
|
+
const ledger = new Ledger();
|
|
279
|
+
const first = await runOp(config(), ledger.port);
|
|
280
|
+
expect(first.status).toBe("gated");
|
|
281
|
+
expect(first.gate).toMatchObject({ gate: "approve-rebuild-bookings" });
|
|
282
|
+
expect(outcome(first, "VerifiedRows")).toBe(601);
|
|
283
|
+
expect(await count("shop.bookings__chant_new")).toBe(601);
|
|
284
|
+
|
|
285
|
+
// Written during the rebuild, after the cut-over: through the view.
|
|
286
|
+
await q("INSERT INTO shop.bookings VALUES (1000, 'buy', now() + toIntervalDay(40)), (1001, 'cart', now())");
|
|
287
|
+
const again = await runOp(config(), ledger.port);
|
|
288
|
+
expect(again.status).toBe("gated");
|
|
289
|
+
expect(outcome(again, "VerifiedRows")).toBe(603);
|
|
290
|
+
// The rows before the cut-over are what the approval binds; later rows do not change it.
|
|
291
|
+
expect(again.gate!.planDigest).toBe(first.gate!.planDigest);
|
|
292
|
+
|
|
293
|
+
ledger.approveLast();
|
|
294
|
+
await q("INSERT INTO shop.bookings VALUES (1002, 'view', now() + toIntervalDay(70))");
|
|
295
|
+
const swapped = await runOp(config(), ledger.port);
|
|
296
|
+
expect(swapped.status).toBe("gated");
|
|
297
|
+
expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-bookings-drop" });
|
|
298
|
+
expect(await showCreate("shop.bookings")).toMatch(/ORDER BY \(kind, id\)/);
|
|
299
|
+
expect(await count("shop.bookings")).toBe(604);
|
|
300
|
+
expect(await count("shop.bookings__chant_old")).toBe(604);
|
|
301
|
+
const diff = await q("SELECT * FROM shop.bookings__chant_old EXCEPT ALL SELECT * FROM shop.bookings");
|
|
302
|
+
expect(diff).toEqual([]);
|
|
303
|
+
}, 300_000);
|
|
304
|
+
|
|
305
|
+
test("a row that reaches the old table alone after the verification stops the swap", async () => {
|
|
306
|
+
const clicksDdl = (orderBy: string) => `CREATE TABLE shop.visits
|
|
307
|
+
(
|
|
308
|
+
id UInt64,
|
|
309
|
+
at DateTime
|
|
310
|
+
)
|
|
311
|
+
ENGINE = MergeTree
|
|
312
|
+
PARTITION BY toYYYYMM(at)
|
|
313
|
+
ORDER BY ${orderBy}`;
|
|
314
|
+
const W1: Obj = { export: "visits", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("at") };
|
|
315
|
+
const W2: Obj = { ...W1, ddl: clicksDdl("(id, at)") };
|
|
316
|
+
await clickhouseApply({ buildPath: join(dir, writeBuild("visits-v1.json", [DB, W1])), environment: "e2e" }, undefined, deps());
|
|
317
|
+
await q("INSERT INTO shop.visits SELECT number, now() - toIntervalDay(number % 90) FROM numbers(300)");
|
|
318
|
+
writeBuild("visits-v2.json", [DB, W2]);
|
|
319
|
+
const args = {
|
|
320
|
+
table: "shop.visits",
|
|
321
|
+
buildPath: "visits-v2.json",
|
|
322
|
+
environment: "e2e",
|
|
323
|
+
dualWrite: { mode: "materialized-view" as const, cutoverColumn: "at", cutoverDelay: "2s" },
|
|
324
|
+
stack: MARKER.stack,
|
|
325
|
+
ownershipEnv: MARKER.env,
|
|
326
|
+
cwd: dir,
|
|
327
|
+
};
|
|
328
|
+
await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
|
|
329
|
+
await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
|
|
330
|
+
await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
|
|
331
|
+
expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 300 });
|
|
332
|
+
|
|
333
|
+
// Late: a time well before the cut-over, written after the verification.
|
|
334
|
+
await q("INSERT INTO shop.visits VALUES (9999, now() - toIntervalDay(30))");
|
|
335
|
+
await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition/);
|
|
336
|
+
expect(await showCreate("shop.visits")).toMatch(/ORDER BY at/);
|
|
337
|
+
expect(await count("shop.visits")).toBe(301);
|
|
338
|
+
expect(await count("shop.visits__chant_new")).toBe(300);
|
|
339
|
+
await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
|
|
340
|
+
}, 300_000);
|
|
341
|
+
});
|
|
342
|
+
|
|
238
343
|
describe.skipIf(!enabled)("a failure runs onFailure, which drops the new table", () => {
|
|
239
344
|
const clicksDdl = (orderBy: string) => `CREATE TABLE shop.clicks (id UInt64, url String) ENGINE = MergeTree ORDER BY ${orderBy}`;
|
|
240
345
|
const V1: Obj = { export: "clicks", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("id") };
|
|
@@ -50,6 +50,7 @@ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
|
50
50
|
import { ident, sqlString } from "../apply/statements";
|
|
51
51
|
import { CLICKHOUSE_COMMENT_OWNERSHIP_KEYS, RECEIPTS_TRAILER_KEY } from "../ownership";
|
|
52
52
|
import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
|
|
53
|
+
import { renderFor, type Topology } from "../topology";
|
|
53
54
|
|
|
54
55
|
/** The database the receipts live in. Its comment marks it as chant's, so schema reads leave it out. */
|
|
55
56
|
export const RECEIPTS_DATABASE = "chant_receipts";
|
|
@@ -79,22 +80,31 @@ export type ClickHouseReceiptStore = SqlReceiptStore;
|
|
|
79
80
|
* project's ownership stack and env, which every address starts with.
|
|
80
81
|
* `replicatedIn` names a Replicated database to keep them in instead, and
|
|
81
82
|
* `replicaTimeoutMs` how long a read waits for this replica to catch up there.
|
|
83
|
+
* `topology` is the environment's (`../topology.ts`): the database and table
|
|
84
|
+
* are created for it, `ON CLUSTER` and a `Replicated*` engine on a cluster.
|
|
85
|
+
* In a Replicated database they are created for that database whatever it says.
|
|
82
86
|
*/
|
|
83
87
|
export function clickhouseReceiptStore(
|
|
84
88
|
endpoint: ClickHouseEndpoint,
|
|
85
89
|
identity: { stack?: string; env?: string },
|
|
86
|
-
opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number } = {},
|
|
90
|
+
opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number; topology?: Topology } = {},
|
|
87
91
|
): ClickHouseReceiptStore {
|
|
88
92
|
const where = receiptsTable(opts.replicatedIn);
|
|
93
|
+
const topology: Topology | undefined = opts.replicatedIn ? { kind: "replicated" } : opts.topology;
|
|
89
94
|
const TABLE = `${ident(where.database)}.${ident(where.name)}`;
|
|
90
95
|
let ensured = false;
|
|
91
96
|
const ensure = async () => {
|
|
92
97
|
if (ensured) return;
|
|
93
|
-
if (!opts.replicatedIn)
|
|
98
|
+
if (!opts.replicatedIn) {
|
|
99
|
+
await clickhouseQuery(endpoint, renderFor(`CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`, topology), { settings: DDL_SETTINGS });
|
|
100
|
+
}
|
|
94
101
|
await clickhouseQuery(
|
|
95
102
|
endpoint,
|
|
96
|
-
|
|
97
|
-
`
|
|
103
|
+
renderFor(
|
|
104
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
|
|
105
|
+
`ENGINE = ReplacingMergeTree(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`,
|
|
106
|
+
topology,
|
|
107
|
+
),
|
|
98
108
|
{ settings: DDL_SETTINGS },
|
|
99
109
|
);
|
|
100
110
|
ensured = true;
|
|
@@ -14,7 +14,9 @@ import type { ClickHouseTarget } from "../live/bind";
|
|
|
14
14
|
import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stampedComment } from "../ownership";
|
|
15
15
|
import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString, type DeclaredObject } from "../apply/statements";
|
|
16
16
|
import { waitForMutations } from "../apply/mutations";
|
|
17
|
+
import { renderFor } from "../topology";
|
|
17
18
|
import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
|
|
19
|
+
import { assertSameRows } from "./verify";
|
|
18
20
|
import {
|
|
19
21
|
changesBetween,
|
|
20
22
|
observeRebuild,
|
|
@@ -60,12 +62,14 @@ export const utcLiteral = (ms: number): string => `toDateTime64(${sqlString(sqlU
|
|
|
60
62
|
export const observe = (run: RebuildRun): Promise<RebuildObservation> => observeRebuild(run.target, run.declared, run.marker);
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
|
-
* One DDL statement
|
|
64
|
-
*
|
|
65
|
-
*
|
|
65
|
+
* One DDL statement, rendered for the environment's topology when it names
|
|
66
|
+
* one (`../topology.ts`: `ON CLUSTER` and the engine). In a Replicated
|
|
67
|
+
* database it goes on without the replicas that are down, which run it from
|
|
68
|
+
* the database's log when they come back (`./replicas.ts`).
|
|
66
69
|
*/
|
|
67
|
-
const q = (run: RebuildRun,
|
|
70
|
+
const q = (run: RebuildRun, statement: string) => {
|
|
68
71
|
run.signal?.throwIfAborted();
|
|
72
|
+
const sql = renderFor(statement, run.target.topology);
|
|
69
73
|
run.log(sql);
|
|
70
74
|
return clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS, ...(run.signal ? { signal: run.signal } : {}) });
|
|
71
75
|
};
|
|
@@ -152,7 +156,8 @@ export interface DualWriteResult {
|
|
|
152
156
|
* old table that writes every row at or after the cut-over into the new
|
|
153
157
|
* table. The cut-over is the server's time now plus the delay, kept in the
|
|
154
158
|
* view's own comment (`cutover=`), so a later run and the backfill read it
|
|
155
|
-
* back from the server.
|
|
159
|
+
* back from the server. The rows at or after the cut-over that the old table
|
|
160
|
+
* already held are the backfill's (`./backfill.ts`). App mode creates nothing; its gate is the Op's.
|
|
156
161
|
*/
|
|
157
162
|
export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult> {
|
|
158
163
|
const o = await observe(run);
|
|
@@ -169,7 +174,10 @@ export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult>
|
|
|
169
174
|
`(copied: ${o.copied.map((c) => c.name).join(", ")})`,
|
|
170
175
|
);
|
|
171
176
|
}
|
|
172
|
-
|
|
177
|
+
// On a whole second: a DateTime column holds whole seconds, and a row written
|
|
178
|
+
// during the cut-over's second would otherwise carry a time before it and
|
|
179
|
+
// reach the old table after the backfill has started.
|
|
180
|
+
const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m")) / 1000) * 1000;
|
|
173
181
|
const iso = new Date(cutover).toISOString();
|
|
174
182
|
const select = o.copied.map((c) => (c.source === c.name ? ident(c.name) : `${ident(c.source)} AS ${ident(c.name)}`)).join(", ");
|
|
175
183
|
const comment = stampedComment(`chant rebuild of ${o.names.key}: rows at or after the cut-over`, run.marker, working(o.names.key, "dual", { cutover: iso }));
|
|
@@ -202,8 +210,9 @@ export interface SwapResult {
|
|
|
202
210
|
}
|
|
203
211
|
|
|
204
212
|
/**
|
|
205
|
-
* Swap the new table in:
|
|
206
|
-
*
|
|
213
|
+
* Swap the new table in: compare every row of the two tables once more, as
|
|
214
|
+
* the verification does, and stop on any difference; then `EXCHANGE TABLES`,
|
|
215
|
+
* drop the dual-write view, rename the old table (now under the new table's name) to `t__chant_old`
|
|
207
216
|
* with its retention date, recreate the materialized views that read the
|
|
208
217
|
* table, and stamp the table's own comment as declared.
|
|
209
218
|
*
|
|
@@ -235,6 +244,10 @@ export async function swapTables(run: RebuildRun): Promise<SwapResult> {
|
|
|
235
244
|
}
|
|
236
245
|
if (!o.exchanged) {
|
|
237
246
|
if (!o.newTable) throw new RebuildRefusal(`${n.key}: there is no new table to swap in`);
|
|
247
|
+
// The new table holds every row the old one does, or nothing is swapped
|
|
248
|
+
// (sql-yodeler#45): rows that reached the old table alone since the
|
|
249
|
+
// verification would stay behind in it.
|
|
250
|
+
await assertSameRows(run, o);
|
|
238
251
|
await q(run, `EXCHANGE TABLES ${n.table} AND ${n.newTable}`);
|
|
239
252
|
}
|
|
240
253
|
// The dual-write view now reads the new table by name; drop it before
|
|
@@ -373,7 +386,7 @@ export async function compensate(run: Pick<RebuildRun, "target" | "declared" | "
|
|
|
373
386
|
const row = rows.find((r) => r.name === name);
|
|
374
387
|
const pairs = readTrailerPairs(row?.comment);
|
|
375
388
|
if (!row || pairs?.get(REBUILD_TRAILER_KEY) !== n.key || pairs.get("role") !== role || !carriesMarker(row.comment, run.marker)) continue;
|
|
376
|
-
const sql = `DROP ${what} ${qualifiedIdent(n.database, name)} SYNC
|
|
389
|
+
const sql = renderFor(`DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`, run.target.topology);
|
|
377
390
|
run.log(sql);
|
|
378
391
|
await clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS });
|
|
379
392
|
dropped.push(`${n.database}.${name}`);
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The verification (#3198): row counts and
|
|
3
|
-
* table against the new one, before anyone
|
|
2
|
+
* The verification (#3198, INTENTIUS/sql-yodeler#45): row counts and
|
|
3
|
+
* checksums per partition, the old table against the new one, before anyone
|
|
4
|
+
* is asked to approve the swap.
|
|
4
5
|
*
|
|
5
6
|
* Per partition of the old table: the number of rows and the sum of a
|
|
6
7
|
* 64-bit hash of each row's copied columns, read in one grouped query on
|
|
7
8
|
* each side. The old side casts each column to its type in the new table
|
|
8
9
|
* first, so a column whose type the rebuild changes hashes the same when
|
|
9
|
-
* its values survived the change.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* its values survived the change.
|
|
11
|
+
*
|
|
12
|
+
* Every row is compared, in both modes. In materialized-view mode the rows
|
|
13
|
+
* at or after the cut-over are in the new table twice over: those the old
|
|
14
|
+
* table held before the view existed, which the backfill copies, and those
|
|
15
|
+
* written since, which the view writes. Comparing only the rows before the
|
|
16
|
+
* cut-over let a swap go ahead without the first kind (sql-yodeler#45).
|
|
17
|
+
* Writes go on while the tables are read, and a row the old table has
|
|
18
|
+
* committed may be a moment from committing in the new one through the view,
|
|
19
|
+
* so the old table is read first and a difference is read again a few times
|
|
20
|
+
* before it counts.
|
|
21
|
+
*
|
|
22
|
+
* The same comparison runs again in the Swap step just before the
|
|
23
|
+
* `EXCHANGE`, so rows that reach the old table alone after this step (a row
|
|
24
|
+
* later than the cut-over delay, with a time before the cut-over) stop the
|
|
25
|
+
* swap too, rather than stay behind in the old table.
|
|
12
26
|
*
|
|
13
27
|
* Any difference fails the run, naming the partitions; the run's onFailure
|
|
14
28
|
* then drops the new table. On a match the result carries the digest the
|
|
15
|
-
* swap gate binds: the rebuild's plan and the
|
|
16
|
-
*
|
|
17
|
-
* new
|
|
29
|
+
* swap gate binds: the rebuild's plan and the counts and checksums of the
|
|
30
|
+
* rows before the cut-over, which no later write changes, so an approval
|
|
31
|
+
* is for this plan with these counts, and a changed count needs a new
|
|
32
|
+
* approval. The rows at or after the cut-over keep arriving through the view
|
|
33
|
+
* and are compared, not bound.
|
|
18
34
|
*/
|
|
19
35
|
|
|
20
36
|
import { computePlanDigest } from "@intentius/chant/op";
|
|
@@ -34,10 +50,13 @@ export interface VerifyResult {
|
|
|
34
50
|
state: RebuildObservation["state"];
|
|
35
51
|
/** The digest the swap gate binds: plan plus verification. Absent once the swap has run: there is nothing left for that gate to approve. */
|
|
36
52
|
planDigest?: string;
|
|
53
|
+
/** Partitions of the old table compared. */
|
|
37
54
|
partitions: number;
|
|
55
|
+
/** Rows compared: every row of the old table. */
|
|
38
56
|
rows: number;
|
|
39
57
|
/** One line for the run record and the gate. */
|
|
40
58
|
summary: string;
|
|
59
|
+
/** Per partition, the rows before the cut-over (every row in app mode): what the swap gate binds. */
|
|
41
60
|
verification: PartitionCheck[];
|
|
42
61
|
}
|
|
43
62
|
|
|
@@ -45,7 +64,7 @@ export interface VerifyResult {
|
|
|
45
64
|
export class RebuildVerificationError extends Error {
|
|
46
65
|
constructor(
|
|
47
66
|
readonly table: string,
|
|
48
|
-
readonly mismatches:
|
|
67
|
+
readonly mismatches: Mismatch[],
|
|
49
68
|
) {
|
|
50
69
|
const shown = mismatches
|
|
51
70
|
.slice(0, 10)
|
|
@@ -53,23 +72,38 @@ export class RebuildVerificationError extends Error {
|
|
|
53
72
|
.join("; ");
|
|
54
73
|
super(
|
|
55
74
|
`${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. ` +
|
|
56
|
-
`Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause
|
|
75
|
+
`Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause; ` +
|
|
76
|
+
`in app mode, a write the application did not stop.`,
|
|
57
77
|
);
|
|
58
78
|
this.name = "RebuildVerificationError";
|
|
59
79
|
}
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
/** How often a difference is read again before it counts, and how long apart. */
|
|
83
|
+
const COMPARE_ATTEMPTS = 5;
|
|
84
|
+
const COMPARE_INTERVAL_MS = 1000;
|
|
85
|
+
|
|
86
|
+
/** One side's counts per old partition: every row, and the rows before the cut-over. */
|
|
87
|
+
interface SideCounts {
|
|
88
|
+
all: Map<string, PartitionCheck>;
|
|
89
|
+
before: Map<string, PartitionCheck>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read both tables' counts and checksums per old partition, the old table
|
|
94
|
+
* first, until they agree or `attempts` reads have disagreed. Returns the
|
|
95
|
+
* last read and the partitions that differ in it.
|
|
96
|
+
*/
|
|
97
|
+
async function compareTables(
|
|
98
|
+
run: RebuildRun,
|
|
99
|
+
o: RebuildObservation,
|
|
100
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
101
|
+
): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[] }> {
|
|
65
102
|
if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
|
|
66
103
|
const n = o.names;
|
|
67
104
|
const cutover = cutoverOf(run, o);
|
|
68
105
|
await waitOn(run, n.database, n.name);
|
|
69
106
|
await waitOn(run, n.database, n.newName);
|
|
70
|
-
// In a Replicated database: count what every replica wrote, not what this one has fetched so far.
|
|
71
|
-
await syncReplica(run, o, n.database, n.name);
|
|
72
|
-
await syncReplica(run, o, n.database, n.newName);
|
|
73
107
|
|
|
74
108
|
const types = new Map(
|
|
75
109
|
(
|
|
@@ -79,35 +113,81 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
|
79
113
|
)
|
|
80
114
|
).map((c) => [c.name, c.type]),
|
|
81
115
|
);
|
|
82
|
-
const oldHash =
|
|
83
|
-
const newHash =
|
|
116
|
+
const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
|
|
117
|
+
const newHash = rowHash(o.copied.map((c) => ident(c.name)));
|
|
84
118
|
const cut = run.dualWrite.mode === "materialized-view" ? o.copied.find((c) => c.name === (run.dualWrite as { cutoverColumn: string }).cutoverColumn) : undefined;
|
|
85
|
-
const
|
|
86
|
-
const
|
|
119
|
+
const oldBefore = cut && cutover !== undefined ? `${ident(cut.source)} < ${utcLiteral(cutover)}` : "1";
|
|
120
|
+
const newBefore = cut && cutover !== undefined ? `${ident(cut.name)} < ${utcLiteral(cutover)}` : "1";
|
|
87
121
|
const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
|
|
88
122
|
|
|
89
|
-
const read = async (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
]),
|
|
123
|
+
const read = async (table: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
|
|
124
|
+
const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
|
|
125
|
+
run.target.endpoint,
|
|
126
|
+
`SELECT ${partition} AS p, count() AS rows, toString(sum(${hash})) AS checksum, countIf(${before}) AS brows, toString(sumIf(${hash}, ${before})) AS bchecksum ` +
|
|
127
|
+
`FROM ${table} GROUP BY p`,
|
|
95
128
|
);
|
|
96
|
-
|
|
97
|
-
|
|
129
|
+
const side: SideCounts = { all: new Map(), before: new Map() };
|
|
130
|
+
for (const r of rows) {
|
|
131
|
+
side.all.set(r.p, { partition: r.p, rows: Number(r.rows), checksum: r.checksum });
|
|
132
|
+
// A partition with no row before the cut-over is left out, as a WHERE would leave it out.
|
|
133
|
+
if (Number(r.brows) > 0) side.before.set(r.p, { partition: r.p, rows: Number(r.brows), checksum: r.bchecksum });
|
|
134
|
+
}
|
|
135
|
+
return side;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const attempts = Math.max(1, opts.attempts ?? COMPARE_ATTEMPTS);
|
|
139
|
+
for (let attempt = 1; ; attempt++) {
|
|
140
|
+
// In a Replicated database: count what every replica wrote, not what this one has fetched so far.
|
|
141
|
+
await syncReplica(run, o, n.database, n.name);
|
|
142
|
+
await syncReplica(run, o, n.database, n.newName);
|
|
143
|
+
// The old table first: a row the view has yet to commit in the new one is read as missing there, never the other way round.
|
|
144
|
+
const before = await read(n.table, "_partition_id", oldHash, oldBefore);
|
|
145
|
+
const after = await read(n.newTable, fromOld, newHash, newBefore);
|
|
146
|
+
const mismatches = differences(before.all, after.all);
|
|
147
|
+
if (mismatches.length === 0 || attempt >= attempts) return { old: before, new: after, mismatches };
|
|
148
|
+
run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
|
|
149
|
+
await sleep(opts.intervalMs ?? COMPARE_INTERVAL_MS, run.signal);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type Mismatch = { partition: string; old?: PartitionCheck; new?: PartitionCheck };
|
|
98
154
|
|
|
99
|
-
|
|
155
|
+
function differences(before: Map<string, PartitionCheck>, after: Map<string, PartitionCheck>): Mismatch[] {
|
|
156
|
+
const out: Mismatch[] = [];
|
|
100
157
|
for (const p of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
101
158
|
const a = before.get(p);
|
|
102
159
|
const b = after.get(p);
|
|
103
|
-
if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum)
|
|
160
|
+
if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum) out.push({ partition: p, ...(a ? { old: a } : {}), ...(b ? { new: b } : {}) });
|
|
104
161
|
}
|
|
105
|
-
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The 64-bit hash of a row's copied columns, as the backfill and the verification compute it. */
|
|
166
|
+
export const rowHash = (columns: readonly string[]): string => `cityHash64(toString(tuple(${columns.join(", ")})))`;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Before the swap: the new table holds every row the old one does. Throws a
|
|
170
|
+
* {@link RebuildVerificationError} when it does not, so nothing is swapped.
|
|
171
|
+
*/
|
|
172
|
+
export async function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void> {
|
|
173
|
+
const { mismatches } = await compareTables(run, o);
|
|
174
|
+
if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
178
|
+
const o = await observe(run);
|
|
179
|
+
if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
|
|
180
|
+
const cutover = cutoverOf(run, o);
|
|
181
|
+
const compared = await compareTables(run, o);
|
|
182
|
+
if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches);
|
|
106
183
|
|
|
107
|
-
const
|
|
108
|
-
const rows =
|
|
109
|
-
|
|
110
|
-
const
|
|
184
|
+
const all = [...compared.old.all.values()];
|
|
185
|
+
const rows = all.reduce((s, v) => s + v.rows, 0);
|
|
186
|
+
// What the approval binds: the rows before the cut-over (every row in app mode), which later writes do not change.
|
|
187
|
+
const verification = [...compared.old.before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
|
|
188
|
+
const bound = verification.reduce((s, v) => s + v.rows, 0);
|
|
189
|
+
const range = cutover !== undefined ? ` (${bound} of them before the cut-over at ${new Date(cutover).toISOString()})` : "";
|
|
190
|
+
const summary = `${o.names.key}: ${all.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
|
|
111
191
|
run.log(`-- ${summary}`);
|
|
112
192
|
return {
|
|
113
193
|
state: o.state,
|
|
@@ -116,9 +196,24 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
|
116
196
|
cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
|
|
117
197
|
verification,
|
|
118
198
|
}),
|
|
119
|
-
partitions:
|
|
199
|
+
partitions: all.length,
|
|
120
200
|
rows,
|
|
121
201
|
summary,
|
|
122
202
|
verification,
|
|
123
203
|
};
|
|
124
204
|
}
|
|
205
|
+
|
|
206
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
signal?.throwIfAborted();
|
|
209
|
+
const t = setTimeout(resolve, ms);
|
|
210
|
+
signal?.addEventListener(
|
|
211
|
+
"abort",
|
|
212
|
+
() => {
|
|
213
|
+
clearTimeout(t);
|
|
214
|
+
reject(signal.reason);
|
|
215
|
+
},
|
|
216
|
+
{ once: true },
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replicated topology against a Replicated database (#3645): two
|
|
3
|
+
* replicas of the pinned server, Keeper embedded in the first (the scratch
|
|
4
|
+
* cluster the Replicated rebuild runs on).
|
|
5
|
+
*
|
|
6
|
+
* The source is written for a single node: a plain database, MergeTree
|
|
7
|
+
* tables. Applied with `CLICKHOUSE_TOPOLOGY=replicated`, the database is a
|
|
8
|
+
* `Replicated` one and its tables `Replicated*MergeTree` with no path, so
|
|
9
|
+
* the second replica gets them from the database's log and rows written on
|
|
10
|
+
* one reach the other. Applied again on the other replica, the server's
|
|
11
|
+
* printed engines (with the default Keeper path filled in) compare equal to
|
|
12
|
+
* the declarations, and nothing is sent; a change applies there and reaches
|
|
13
|
+
* the first.
|
|
14
|
+
*
|
|
15
|
+
* The scratch cluster names no cluster in `remote_servers`, so the second
|
|
16
|
+
* replica joins the database with the `CREATE DATABASE` the applier sent to
|
|
17
|
+
* the first, as an operator would (`replicated:<cluster>` sends it `ON
|
|
18
|
+
* CLUSTER` instead). Needs Docker; skips cleanly 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 { dockerAvailable, startScratchCluster, type ScratchCluster } from "./container";
|
|
26
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
|
|
27
|
+
import { clickhouseImage } from "../spec/pin";
|
|
28
|
+
import { clickhouseApply } from "../op/activities/clickhouse-apply";
|
|
29
|
+
import { renderStatement } from "./topology";
|
|
30
|
+
|
|
31
|
+
const DB = "yodel_3645";
|
|
32
|
+
const enabled = await dockerAvailable();
|
|
33
|
+
let cluster: ScratchCluster | undefined;
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-repl-"));
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
if (!enabled) return;
|
|
38
|
+
cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-topology-repl" });
|
|
39
|
+
}, 600_000);
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await cluster?.stop();
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
interface Obj {
|
|
47
|
+
export: string;
|
|
48
|
+
type: string;
|
|
49
|
+
ddl: string;
|
|
50
|
+
dependsOn?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const writeBuild = (file: string, objects: Obj[]): string => {
|
|
54
|
+
const path = join(dir, file);
|
|
55
|
+
writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
56
|
+
return path;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const objects = (extraColumn = ""): Obj[] => [
|
|
60
|
+
{ export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB}` },
|
|
61
|
+
{
|
|
62
|
+
export: "events",
|
|
63
|
+
type: "ClickHouse::Table",
|
|
64
|
+
dependsOn: ["db"],
|
|
65
|
+
ddl: `CREATE TABLE ${DB}.events (id UInt64, ts DateTime, kind String${extraColumn}) ENGINE = ReplacingMergeTree(ts) ORDER BY id`,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
export: "byKind",
|
|
69
|
+
type: "ClickHouse::MaterializedView",
|
|
70
|
+
dependsOn: ["events"],
|
|
71
|
+
ddl: `CREATE MATERIALIZED VIEW ${DB}.by_kind ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const q = <T = Record<string, unknown>>(at: ClickHouseEndpoint, sql: string) => clickhouseQuery<T>(at, sql);
|
|
76
|
+
|
|
77
|
+
async function apply(at: ClickHouseEndpoint, buildPath: string) {
|
|
78
|
+
const sent: string[] = [];
|
|
79
|
+
const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
|
|
80
|
+
config: { ownership: { stack: "yodel", env: "e2e" } },
|
|
81
|
+
env: { CLICKHOUSE_URL: at.url, CLICKHOUSE_TOPOLOGY: "replicated" },
|
|
82
|
+
log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
|
|
83
|
+
});
|
|
84
|
+
return { outcome, sent };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe.skipIf(!enabled)("the replicated topology on a Replicated database (#3645)", () => {
|
|
88
|
+
test("a single node's source becomes a Replicated database that both replicas hold, and converges on either", async () => {
|
|
89
|
+
const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
|
|
90
|
+
expect(renderStatement(`CREATE DATABASE ${DB}`, { kind: "replicated" })).toBe(`CREATE DATABASE ${DB} ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}')`);
|
|
91
|
+
|
|
92
|
+
const v1 = writeBuild("v1.json", objects());
|
|
93
|
+
const first = await apply(r1, v1);
|
|
94
|
+
expect(first.outcome.failed).toEqual([]);
|
|
95
|
+
// The second replica joins the database with the statement the applier sent to the first, and replays its tables from the log.
|
|
96
|
+
expect(first.sent[0]).toMatch(new RegExp(`^CREATE DATABASE ${DB} ENGINE = Replicated\\('/clickhouse/databases/${DB}', '\\{shard\\}', '\\{replica\\}'\\) COMMENT `));
|
|
97
|
+
await q(r2, first.sent[0]!);
|
|
98
|
+
await q(r2, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
|
|
99
|
+
expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
|
|
100
|
+
expect(first.sent[1]).toMatch(/ENGINE = ReplicatedReplacingMergeTree\(ts\) ORDER BY id/);
|
|
101
|
+
|
|
102
|
+
await q(r1, `INSERT INTO ${DB}.events (id, ts, kind) VALUES (1, now(), 'click'), (2, now(), 'view')`);
|
|
103
|
+
await q(r2, `SYSTEM SYNC REPLICA ${DB}.events`);
|
|
104
|
+
expect(Number((await q<{ n: string | number }>(r2, `SELECT count() AS n FROM ${DB}.events`))[0]!.n)).toBe(2);
|
|
105
|
+
const engines = await q<{ name: string; engine: string }>(r2, `SELECT name, engine FROM system.tables WHERE database = '${DB}' ORDER BY name`);
|
|
106
|
+
expect(engines.map((t) => [t.name.startsWith(".inner") ? ".inner" : t.name, t.engine])).toEqual([
|
|
107
|
+
[".inner", "ReplicatedSummingMergeTree"],
|
|
108
|
+
["by_kind", "MaterializedView"],
|
|
109
|
+
["events", "ReplicatedReplacingMergeTree"],
|
|
110
|
+
]);
|
|
111
|
+
expect((await q<{ engine: string }>(r2, `SELECT engine FROM system.databases WHERE name = '${DB}'`))[0]!.engine).toBe("Replicated");
|
|
112
|
+
|
|
113
|
+
// On the other replica: the printed engines (default Keeper path filled in) are the declarations; nothing to send.
|
|
114
|
+
const again = await apply(r2, v1);
|
|
115
|
+
expect(again.sent).toEqual([]);
|
|
116
|
+
expect(again.outcome.notAttempted).toEqual([]);
|
|
117
|
+
|
|
118
|
+
// A change there reaches the first replica through the database's log.
|
|
119
|
+
const change = await apply(r2, writeBuild("v2.json", objects(", source String DEFAULT 'web'")));
|
|
120
|
+
expect(change.sent).toEqual([`ALTER TABLE \`${DB}\`.\`events\` ADD COLUMN source String DEFAULT 'web' AFTER \`kind\``]);
|
|
121
|
+
await q(r1, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
|
|
122
|
+
const cols = await q<{ name: string }>(r1, `SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
|
|
123
|
+
expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
|
|
124
|
+
}, 300_000);
|
|
125
|
+
});
|