@chkit/clickhouse 0.1.2-beta.7 → 0.2.0-beta.12
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/alter-column-statement.d.ts +32 -0
- package/dist/alter-column-statement.d.ts.map +1 -0
- package/dist/alter-column-statement.js +137 -0
- package/dist/alter-column-statement.js.map +1 -0
- package/dist/async-insert-guard.d.ts +31 -0
- package/dist/async-insert-guard.d.ts.map +1 -0
- package/dist/async-insert-guard.js +55 -0
- package/dist/async-insert-guard.js.map +1 -0
- package/dist/canonicalize.d.ts +21 -0
- package/dist/canonicalize.d.ts.map +1 -0
- package/dist/canonicalize.js +70 -0
- package/dist/canonicalize.js.map +1 -0
- package/dist/create-table-parser.d.ts +3 -0
- package/dist/create-table-parser.d.ts.map +1 -1
- package/dist/create-table-parser.js +54 -43
- package/dist/create-table-parser.js.map +1 -1
- package/dist/ddl-propagation.d.ts +62 -6
- package/dist/ddl-propagation.d.ts.map +1 -1
- package/dist/ddl-propagation.js +459 -49
- package/dist/ddl-propagation.js.map +1 -1
- package/dist/e2e-testkit.d.ts +26 -4
- package/dist/e2e-testkit.d.ts.map +1 -1
- package/dist/e2e-testkit.js +56 -14
- package/dist/e2e-testkit.js.map +1 -1
- package/dist/exact-json.d.ts +27 -0
- package/dist/exact-json.d.ts.map +1 -0
- package/dist/exact-json.js +128 -0
- package/dist/exact-json.js.map +1 -0
- package/dist/index.d.ts +52 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +220 -64
- package/dist/index.js.map +1 -1
- package/dist/insert-barrier.d.ts +32 -0
- package/dist/insert-barrier.d.ts.map +1 -0
- package/dist/insert-barrier.js +57 -0
- package/dist/insert-barrier.js.map +1 -0
- package/dist/replicas.d.ts +21 -0
- package/dist/replicas.d.ts.map +1 -0
- package/dist/replicas.js +39 -0
- package/dist/replicas.js.map +1 -0
- package/dist/view-details.d.ts +30 -0
- package/dist/view-details.d.ts.map +1 -0
- package/dist/view-details.js +73 -0
- package/dist/view-details.js.map +1 -0
- package/package.json +5 -2
|
@@ -1,18 +1,74 @@
|
|
|
1
1
|
import type { ClickHouseExecutor } from './index.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Where a propagation wait looks. Pass the configured `clickhouse.cluster`;
|
|
4
|
+
* without one the `default` cluster is probed (ObsessionDB). On a target with
|
|
5
|
+
* several replicas the wait holds until every replica shows the change: the
|
|
6
|
+
* next statement may land on any of them, and an ALTER that runs on a replica
|
|
7
|
+
* that has not applied the previous one can write back its stale schema.
|
|
8
|
+
*/
|
|
9
|
+
export interface PropagationOptions {
|
|
10
|
+
cluster?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The statement that ran. A MODIFY COLUMN wait reads what the statement sets
|
|
13
|
+
* from it, and a RENAME COLUMN wait reads the old and new names.
|
|
14
|
+
*/
|
|
15
|
+
statement?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function waitForTable(executor: ClickHouseExecutor, database: string, tableName: string, options?: PropagationOptions): Promise<void>;
|
|
18
|
+
export declare function waitForView(executor: ClickHouseExecutor, database: string, viewName: string, options?: PropagationOptions): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Waits until the dictionary's catalog row is on every replica.
|
|
21
|
+
*
|
|
22
|
+
* `system.dictionaries` is the wrong signal. It lists each replica's
|
|
23
|
+
* in-memory loader (`ExternalLoader` load results), not the replicated
|
|
24
|
+
* catalog. `CREATE OR REPLACE DICTIONARY` drops that loader entry while the
|
|
25
|
+
* replica reloads, so a replica can omit the dictionary for the whole retry
|
|
26
|
+
* budget after the DDL is already in `system.tables`. A following statement
|
|
27
|
+
* only needs the catalog object.
|
|
28
|
+
*
|
|
29
|
+
* On several replicas the wait holds until every replica stores the same
|
|
30
|
+
* non-nil `uuid`. A replace does not remove the row, and ClickHouse redacts
|
|
31
|
+
* a SOURCE password to `[HIDDEN]` in `create_table_query`, so a password-only
|
|
32
|
+
* replace compares equal to the previous definition. `CREATE OR REPLACE`
|
|
33
|
+
* still assigns one new UUID in the replicated DDL (an `ON CLUSTER` statement
|
|
34
|
+
* and a Replicated database), and every replica that has applied it reports
|
|
35
|
+
* that UUID. `metadata_modification_time` is each replica's metadata-file
|
|
36
|
+
* mtime at second precision: it can stay unchanged across a replace in the
|
|
37
|
+
* same second, and two replicas that have both applied the DDL can disagree.
|
|
38
|
+
*/
|
|
39
|
+
export declare function waitForDictionary(executor: ClickHouseExecutor, database: string, dictionaryName: string, options?: PropagationOptions): Promise<void>;
|
|
40
|
+
export declare function waitForColumn(executor: ClickHouseExecutor, database: string, tableName: string, columnName: string, options?: PropagationOptions): Promise<void>;
|
|
41
|
+
export declare function waitForColumnAbsent(executor: ClickHouseExecutor, database: string, tableName: string, columnName: string, options?: PropagationOptions): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Waits until every replica lists both tables of `EXCHANGE TABLES a AND b`
|
|
44
|
+
* with one uuid each, and the two uuids differ. The replica that ran the
|
|
45
|
+
* statement has swapped them, so once all replicas agree, all have. A
|
|
46
|
+
* statement that runs on a replica that has not applied the swap yet would
|
|
47
|
+
* see the old names: a following DROP of the pre-cutover table would drop the
|
|
48
|
+
* new one.
|
|
49
|
+
*
|
|
50
|
+
* With `clickhouse.cluster` the statement ran `ON CLUSTER`, which returns once
|
|
51
|
+
* every host executed it, and each host may hold its own uuids, so there is
|
|
52
|
+
* nothing to wait for. A single replica has nothing to wait for either.
|
|
53
|
+
*/
|
|
54
|
+
export declare function waitForExchange(executor: ClickHouseExecutor, statement: string, options?: PropagationOptions): Promise<void>;
|
|
55
|
+
interface QualifiedName {
|
|
56
|
+
database: string;
|
|
57
|
+
name: string;
|
|
58
|
+
}
|
|
59
|
+
/** The two tables of `EXCHANGE TABLES db.a AND db.b`, or undefined. */
|
|
60
|
+
export declare function parseExchangeTables(statement: string): [QualifiedName, QualifiedName] | undefined;
|
|
6
61
|
/**
|
|
7
62
|
* Polls a query until its rows satisfy `predicate`, then returns them. Use for
|
|
8
63
|
* reads that race DDL/DML propagation on managed ClickHouse (e.g. journal rows
|
|
9
64
|
* written by a just-finished migration that aren't yet visible via FINAL).
|
|
10
65
|
*/
|
|
11
66
|
export declare function waitForRows<T>(executor: ClickHouseExecutor, sql: string, predicate: (rows: T[]) => boolean, label?: string): Promise<T[]>;
|
|
12
|
-
export declare function waitForTableAbsent(executor: ClickHouseExecutor, database: string, tableName: string): Promise<void>;
|
|
67
|
+
export declare function waitForTableAbsent(executor: ClickHouseExecutor, database: string, tableName: string, options?: PropagationOptions): Promise<void>;
|
|
13
68
|
/**
|
|
14
69
|
* Waits for DDL propagation based on the operation type and key.
|
|
15
70
|
* Called after each DDL statement in the migration execution loop.
|
|
16
71
|
*/
|
|
17
|
-
export declare function waitForDDLPropagation(executor: ClickHouseExecutor, operationType: string, operationKey: string): Promise<void>;
|
|
72
|
+
export declare function waitForDDLPropagation(executor: ClickHouseExecutor, operationType: string, operationKey: string, options?: PropagationOptions): Promise<void>;
|
|
73
|
+
export {};
|
|
18
74
|
//# sourceMappingURL=ddl-propagation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ddl-propagation.d.ts","sourceRoot":"","sources":["../src/ddl-propagation.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ddl-propagation.d.ts","sourceRoot":"","sources":["../src/ddl-propagation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAwCpD;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAsB,YAAY,CAChC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,aAAa,CACjC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAgHD;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,kBAAkB,EAC5B,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAmBf;AAED,UAAU,aAAa;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAQD,uEAAuE;AACvE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,GAAG,SAAS,CASjG;AAOD;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,CAAC,EACjC,QAAQ,EAAE,kBAAkB,EAC5B,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,OAAO,EACjC,KAAK,SAAgB,GACpB,OAAO,CAAC,CAAC,EAAE,CAAC,CAQd;AAED,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AA4DD;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,kBAAkB,EAC5B,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAyFf"}
|
package/dist/ddl-propagation.js
CHANGED
|
@@ -1,36 +1,223 @@
|
|
|
1
1
|
import pRetry from 'p-retry';
|
|
2
|
+
import { parseModifyColumnIntent, parseRenameColumn } from './alter-column-statement.js';
|
|
3
|
+
import { allReplicas, resolveReplicaFanout, stringLiteral } from './replicas.js';
|
|
2
4
|
const RETRY_OPTIONS = { retries: 20, minTimeout: 500, factor: 1 };
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
// Upper bound on a column definition or rename wait, including slow queries.
|
|
6
|
+
const DEFINITION_WAIT_DEADLINE_MS = 30_000;
|
|
7
|
+
// The `system.columns` fields a column definition wait compares.
|
|
8
|
+
const COLUMN_FIELDS = ['type', 'default_kind', 'default_expression', 'comment', 'compression_codec'];
|
|
9
|
+
// What each ALTER or rename appends to the `<kind>:<database>.<name>` key of the
|
|
10
|
+
// object it changes. Creates and drops use the bare key.
|
|
11
|
+
const KEY_SUFFIXES = new Map([
|
|
12
|
+
['alter_materialized_view_modify_refresh', ':refresh'],
|
|
13
|
+
['alter_table_add_column', ':column:'],
|
|
14
|
+
['alter_table_modify_column', ':column:'],
|
|
15
|
+
['alter_table_drop_column', ':column:'],
|
|
16
|
+
['alter_table_rename_column', ':column_rename:'],
|
|
17
|
+
['alter_table_add_index', ':index:'],
|
|
18
|
+
['alter_table_drop_index', ':index:'],
|
|
19
|
+
['alter_table_add_projection', ':projection:'],
|
|
20
|
+
['alter_table_drop_projection', ':projection:'],
|
|
21
|
+
['alter_table_add_constraint', ':constraint:'],
|
|
22
|
+
['alter_table_drop_constraint', ':constraint:'],
|
|
23
|
+
['alter_table_modify_setting', ':setting:'],
|
|
24
|
+
['alter_table_reset_setting', ':setting:'],
|
|
25
|
+
['alter_table_modify_ttl', ':ttl'],
|
|
26
|
+
['alter_table_rename_table', ':rename_table'],
|
|
27
|
+
['rename_dictionary', ':rename_dictionary'],
|
|
28
|
+
]);
|
|
29
|
+
export async function waitForTable(executor, database, tableName, options) {
|
|
30
|
+
await waitForSystemRows(executor, {
|
|
31
|
+
source: 'system.tables',
|
|
32
|
+
where: `database = ${stringLiteral(database)} AND name = ${stringLiteral(tableName)}`,
|
|
33
|
+
want: 'present',
|
|
34
|
+
label: `waitForTable: ${database}.${tableName} not yet visible`,
|
|
35
|
+
}, options);
|
|
10
36
|
}
|
|
11
|
-
export async function waitForView(executor, database, viewName) {
|
|
12
|
-
await
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
},
|
|
37
|
+
export async function waitForView(executor, database, viewName, options) {
|
|
38
|
+
await waitForSystemRows(executor, {
|
|
39
|
+
source: 'system.tables',
|
|
40
|
+
where: `database = ${stringLiteral(database)} AND name = ${stringLiteral(viewName)} AND engine LIKE '%View%'`,
|
|
41
|
+
want: 'present',
|
|
42
|
+
label: `waitForView: ${database}.${viewName} not yet visible`,
|
|
43
|
+
}, options);
|
|
18
44
|
}
|
|
19
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Waits until the dictionary's catalog row is on every replica.
|
|
47
|
+
*
|
|
48
|
+
* `system.dictionaries` is the wrong signal. It lists each replica's
|
|
49
|
+
* in-memory loader (`ExternalLoader` load results), not the replicated
|
|
50
|
+
* catalog. `CREATE OR REPLACE DICTIONARY` drops that loader entry while the
|
|
51
|
+
* replica reloads, so a replica can omit the dictionary for the whole retry
|
|
52
|
+
* budget after the DDL is already in `system.tables`. A following statement
|
|
53
|
+
* only needs the catalog object.
|
|
54
|
+
*
|
|
55
|
+
* On several replicas the wait holds until every replica stores the same
|
|
56
|
+
* non-nil `uuid`. A replace does not remove the row, and ClickHouse redacts
|
|
57
|
+
* a SOURCE password to `[HIDDEN]` in `create_table_query`, so a password-only
|
|
58
|
+
* replace compares equal to the previous definition. `CREATE OR REPLACE`
|
|
59
|
+
* still assigns one new UUID in the replicated DDL (an `ON CLUSTER` statement
|
|
60
|
+
* and a Replicated database), and every replica that has applied it reports
|
|
61
|
+
* that UUID. `metadata_modification_time` is each replica's metadata-file
|
|
62
|
+
* mtime at second precision: it can stay unchanged across a replace in the
|
|
63
|
+
* same second, and two replicas that have both applied the DDL can disagree.
|
|
64
|
+
*/
|
|
65
|
+
export async function waitForDictionary(executor, database, dictionaryName, options) {
|
|
66
|
+
const where = `database = ${stringLiteral(database)} AND name = ${stringLiteral(dictionaryName)} AND engine = 'Dictionary'`;
|
|
67
|
+
const label = `waitForDictionary: ${database}.${dictionaryName} not yet visible`;
|
|
68
|
+
const fanout = await resolveReplicaFanout(executor, options?.cluster);
|
|
20
69
|
await pRetry(async () => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
throw new Error(`waitForDictionary: ${database}.${dictionaryName} not yet visible`);
|
|
24
|
-
}
|
|
70
|
+
if (!(await dictionaryCatalogVisible(executor, where, fanout)))
|
|
71
|
+
throw new Error(label);
|
|
25
72
|
}, RETRY_OPTIONS);
|
|
26
73
|
}
|
|
27
|
-
export async function waitForColumn(executor, database, tableName, columnName) {
|
|
28
|
-
await
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
74
|
+
export async function waitForColumn(executor, database, tableName, columnName, options) {
|
|
75
|
+
await waitForSystemRows(executor, {
|
|
76
|
+
source: 'system.columns',
|
|
77
|
+
where: columnWhere(database, tableName, columnName),
|
|
78
|
+
want: 'present',
|
|
79
|
+
label: `waitForColumn: ${database}.${tableName}.${columnName} not yet visible`,
|
|
80
|
+
}, options);
|
|
81
|
+
}
|
|
82
|
+
export async function waitForColumnAbsent(executor, database, tableName, columnName, options) {
|
|
83
|
+
await waitForSystemRows(executor, {
|
|
84
|
+
source: 'system.columns',
|
|
85
|
+
where: columnWhere(database, tableName, columnName),
|
|
86
|
+
want: 'absent',
|
|
87
|
+
label: `waitForColumnAbsent: ${database}.${tableName}.${columnName} still present`,
|
|
88
|
+
}, options);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Waits until every replica reports the column with one definition, and with
|
|
92
|
+
* what `intent` says the statement set.
|
|
93
|
+
*
|
|
94
|
+
* Agreement: the replica that ran an ALTER has applied it when the statement
|
|
95
|
+
* returns, so once every replica reports the same `type`, `default_kind`,
|
|
96
|
+
* `default_expression`, `comment` and `compression_codec`, all of them have.
|
|
97
|
+
* Each replica formats these values itself, so the comparison is between
|
|
98
|
+
* server-formatted text and never trips over how chkit wrote an expression.
|
|
99
|
+
*
|
|
100
|
+
* Intent: agreement alone passes when every replica kept the old definition,
|
|
101
|
+
* e.g. ObsessionDB ignoring `MODIFY COLUMN ... EPHEMERAL <default>` (#285). So
|
|
102
|
+
* the fields the statement sets are compared too: the default kind and comment
|
|
103
|
+
* verbatim, a removed codec as empty, and the default expression after the
|
|
104
|
+
* server formats it with `formatQuerySingleLine`, the formatter that writes
|
|
105
|
+
* `system.columns.default_expression`. Types and codecs the statement sets are
|
|
106
|
+
* only compared between replicas: ClickHouse rewrites them (`Decimal(10,2)`
|
|
107
|
+
* becomes `Decimal(10, 2)`, `ZSTD` becomes `ZSTD(1)`).
|
|
108
|
+
*/
|
|
109
|
+
async function waitForColumnDefinition(executor, database, tableName, columnName, intent, options) {
|
|
110
|
+
const fanout = await resolveReplicaFanout(executor, options?.cluster);
|
|
111
|
+
const expected = await expectedColumnValues(executor, intent);
|
|
112
|
+
const source = fanout ? allReplicas(fanout, 'system.columns') : 'system.columns';
|
|
113
|
+
const sql = `SELECT hostName() AS replica, ${COLUMN_FIELDS.join(', ')} FROM ${source} WHERE ${columnWhere(database, tableName, columnName)}`;
|
|
114
|
+
await waitUntilApplied(`${database}.${tableName}.${columnName} definition`, async () => columnDefinitionMismatch(await executor.query(sql), fanout?.replicas ?? 1, expected));
|
|
115
|
+
}
|
|
116
|
+
/** Waits until no replica lists the old name and every replica lists the new one. */
|
|
117
|
+
async function waitForColumnRename(executor, database, tableName, rename, options) {
|
|
118
|
+
const fanout = await resolveReplicaFanout(executor, options?.cluster);
|
|
119
|
+
const replicas = fanout?.replicas ?? 1;
|
|
120
|
+
const source = fanout ? allReplicas(fanout, 'system.columns') : 'system.columns';
|
|
121
|
+
const sql = `SELECT hostName() AS replica, name FROM ${source} WHERE database = ${stringLiteral(database)} AND table = ${stringLiteral(tableName)} AND name IN (${stringLiteral(rename.from)}, ${stringLiteral(rename.to)})`;
|
|
122
|
+
await waitUntilApplied(`${database}.${tableName} rename of column ${rename.from} to ${rename.to}`, async () => {
|
|
123
|
+
const rows = await executor.query(sql);
|
|
124
|
+
const stale = rows.filter((row) => row.name === rename.from).map((row) => row.replica);
|
|
125
|
+
if (stale.length > 0)
|
|
126
|
+
return `replica ${stale.join(', ')} still lists column ${rename.from}`;
|
|
127
|
+
const renamed = new Set(rows.filter((row) => row.name === rename.to).map((row) => row.replica));
|
|
128
|
+
if (renamed.size < replicas)
|
|
129
|
+
return `column ${rename.to} is listed on ${renamed.size} of ${replicas} replicas`;
|
|
130
|
+
return undefined;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function waitForIndex(executor, database, tableName, indexName, want, options) {
|
|
134
|
+
await waitForSystemRows(executor, {
|
|
135
|
+
source: 'system.data_skipping_indices',
|
|
136
|
+
where: `database = ${stringLiteral(database)} AND table = ${stringLiteral(tableName)} AND name = ${stringLiteral(indexName)}`,
|
|
137
|
+
want,
|
|
138
|
+
label: `waitForIndex: ${database}.${tableName}.${indexName} ${want === 'present' ? 'not yet visible' : 'still present'}`,
|
|
139
|
+
}, options);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Waits until every replica's `create_table_query` lists the constraint. There
|
|
143
|
+
* is no system table of constraints. A replica that has not applied the ADD
|
|
144
|
+
* yet would let a following `DROP CONSTRAINT IF EXISTS` (a changed constraint)
|
|
145
|
+
* or `ADD CONSTRAINT IF NOT EXISTS` silently do nothing.
|
|
146
|
+
*/
|
|
147
|
+
async function waitForConstraint(executor, database, tableName, constraintName, options) {
|
|
148
|
+
await waitForSystemRows(executor, {
|
|
149
|
+
source: 'system.tables',
|
|
150
|
+
where: constraintWhere(database, tableName, constraintName),
|
|
151
|
+
want: 'present',
|
|
152
|
+
label: `waitForConstraint: ${database}.${tableName}.${constraintName} not yet visible`,
|
|
153
|
+
}, options);
|
|
154
|
+
}
|
|
155
|
+
async function waitForConstraintAbsent(executor, database, tableName, constraintName, options) {
|
|
156
|
+
await waitForSystemRows(executor, {
|
|
157
|
+
source: 'system.tables',
|
|
158
|
+
where: constraintWhere(database, tableName, constraintName),
|
|
159
|
+
want: 'absent',
|
|
160
|
+
label: `waitForConstraintAbsent: ${database}.${tableName}.${constraintName} still present`,
|
|
161
|
+
}, options);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Waits until every replica lists both tables of `EXCHANGE TABLES a AND b`
|
|
165
|
+
* with one uuid each, and the two uuids differ. The replica that ran the
|
|
166
|
+
* statement has swapped them, so once all replicas agree, all have. A
|
|
167
|
+
* statement that runs on a replica that has not applied the swap yet would
|
|
168
|
+
* see the old names: a following DROP of the pre-cutover table would drop the
|
|
169
|
+
* new one.
|
|
170
|
+
*
|
|
171
|
+
* With `clickhouse.cluster` the statement ran `ON CLUSTER`, which returns once
|
|
172
|
+
* every host executed it, and each host may hold its own uuids, so there is
|
|
173
|
+
* nothing to wait for. A single replica has nothing to wait for either.
|
|
174
|
+
*/
|
|
175
|
+
export async function waitForExchange(executor, statement, options) {
|
|
176
|
+
if (options?.cluster)
|
|
177
|
+
return;
|
|
178
|
+
const tables = parseExchangeTables(statement);
|
|
179
|
+
if (!tables)
|
|
180
|
+
return;
|
|
181
|
+
const fanout = await resolveReplicaFanout(executor);
|
|
182
|
+
if (!fanout)
|
|
183
|
+
return;
|
|
184
|
+
const [first, second] = tables;
|
|
185
|
+
const where = `(database = ${stringLiteral(first.database)} AND name = ${stringLiteral(first.name)}) OR (database = ${stringLiteral(second.database)} AND name = ${stringLiteral(second.name)})`;
|
|
186
|
+
const sql = `SELECT database, name, count(DISTINCT hostName()) AS replicas, uniqExact(uuid) AS uuids, any(toString(uuid)) AS sample_uuid FROM ${allReplicas(fanout, 'system.tables')} WHERE ${where} GROUP BY database, name`;
|
|
187
|
+
await waitUntilApplied(`EXCHANGE TABLES ${first.database}.${first.name} AND ${second.database}.${second.name}`, async () => {
|
|
188
|
+
const rows = await executor.query(sql);
|
|
189
|
+
if (rows.length < 2)
|
|
190
|
+
return `only ${rows.length} of the 2 tables are listed`;
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
if (Number(row.replicas) < fanout.replicas)
|
|
193
|
+
return `${row.database}.${row.name} is listed on ${row.replicas} of ${fanout.replicas} replicas`;
|
|
194
|
+
if (Number(row.uuids) !== 1)
|
|
195
|
+
return `replicas disagree on the uuid of ${row.database}.${row.name}`;
|
|
32
196
|
}
|
|
33
|
-
|
|
197
|
+
if (rows[0]?.sample_uuid === rows[1]?.sample_uuid)
|
|
198
|
+
return 'both names report the same uuid';
|
|
199
|
+
return undefined;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const QUOTED_OR_BARE = String.raw `(?:\`(?:[^\`\\]|\\.)*\`|[^\s.\`;]+)`;
|
|
203
|
+
const EXCHANGE_PATTERN = new RegExp(String.raw `^\s*EXCHANGE\s+TABLES\s+(${QUOTED_OR_BARE})\.(${QUOTED_OR_BARE})\s+AND\s+(${QUOTED_OR_BARE})\.(${QUOTED_OR_BARE})`, 'i');
|
|
204
|
+
/** The two tables of `EXCHANGE TABLES db.a AND db.b`, or undefined. */
|
|
205
|
+
export function parseExchangeTables(statement) {
|
|
206
|
+
const match = EXCHANGE_PATTERN.exec(statement);
|
|
207
|
+
if (!match)
|
|
208
|
+
return undefined;
|
|
209
|
+
const [, db1, t1, db2, t2] = match;
|
|
210
|
+
if (!db1 || !t1 || !db2 || !t2)
|
|
211
|
+
return undefined;
|
|
212
|
+
return [
|
|
213
|
+
{ database: unquote(db1), name: unquote(t1) },
|
|
214
|
+
{ database: unquote(db2), name: unquote(t2) },
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
function unquote(identifier) {
|
|
218
|
+
if (!identifier.startsWith('`'))
|
|
219
|
+
return identifier;
|
|
220
|
+
return identifier.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
34
221
|
}
|
|
35
222
|
/**
|
|
36
223
|
* Polls a query until its rows satisfy `predicate`, then returns them. Use for
|
|
@@ -46,60 +233,283 @@ export async function waitForRows(executor, sql, predicate, label = 'waitForRows
|
|
|
46
233
|
return rows;
|
|
47
234
|
}, RETRY_OPTIONS);
|
|
48
235
|
}
|
|
49
|
-
export async function waitForTableAbsent(executor, database, tableName) {
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
},
|
|
236
|
+
export async function waitForTableAbsent(executor, database, tableName, options) {
|
|
237
|
+
await waitForSystemRows(executor, {
|
|
238
|
+
source: 'system.tables',
|
|
239
|
+
where: `database = ${stringLiteral(database)} AND name = ${stringLiteral(tableName)}`,
|
|
240
|
+
want: 'absent',
|
|
241
|
+
label: `waitForTableAbsent: ${database}.${tableName} still present`,
|
|
242
|
+
}, options);
|
|
56
243
|
}
|
|
57
244
|
/**
|
|
58
245
|
* Parses an operation key like "table:app.users", "table:app.users:column:name",
|
|
59
|
-
*
|
|
246
|
+
* "dictionary:app.users_dict", "view:app.active_users" or
|
|
247
|
+
* "materialized_view:app.events_mv:refresh" into its components. `table` is the
|
|
248
|
+
* object's name in system.tables, which lists views and dictionaries too.
|
|
249
|
+
*
|
|
250
|
+
* Names may contain ':', so the object's name runs to the last occurrence of the
|
|
251
|
+
* suffix its operation type appends; without a known suffix the rest of the key
|
|
252
|
+
* is the name. The database runs to the first '.': the key cannot tell a '.'
|
|
253
|
+
* inside a database name from the separator, so objects in such a database are
|
|
254
|
+
* not found.
|
|
60
255
|
*/
|
|
61
|
-
function parseOperationKey(key) {
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
256
|
+
function parseOperationKey(operationType, key) {
|
|
257
|
+
const keyMatch = key.match(/^(?:table|dictionary|view|materialized_view):([^.]+)\.(.+)$/);
|
|
258
|
+
if (!keyMatch)
|
|
64
259
|
return undefined;
|
|
65
260
|
// biome-ignore lint/style/noNonNullAssertion: capture groups guaranteed by regex match
|
|
66
|
-
const database =
|
|
261
|
+
const database = keyMatch[1];
|
|
67
262
|
// biome-ignore lint/style/noNonNullAssertion: capture groups guaranteed by regex match
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
263
|
+
const rest = keyMatch[2];
|
|
264
|
+
const suffix = KEY_SUFFIXES.get(operationType);
|
|
265
|
+
const at = suffix === undefined ? -1 : rest.lastIndexOf(suffix);
|
|
266
|
+
if (suffix === undefined || at < 1)
|
|
267
|
+
return { database, table: rest, column: undefined };
|
|
268
|
+
const member = rest.slice(at + suffix.length);
|
|
269
|
+
return {
|
|
270
|
+
database,
|
|
271
|
+
table: rest.slice(0, at),
|
|
272
|
+
column: suffix === ':column:' ? member : undefined,
|
|
273
|
+
constraint: suffix === ':constraint:' ? member : undefined,
|
|
274
|
+
index: suffix === ':index:' ? member : undefined,
|
|
275
|
+
rename: suffix === ':column_rename:' ? member : undefined,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The old and new names of a column rename. The statement names them; without
|
|
280
|
+
* it the key's `<from>:<to>` is split only when it holds exactly one ':'.
|
|
281
|
+
*/
|
|
282
|
+
function renamedColumn(member, statement) {
|
|
283
|
+
const parsed = statement === undefined ? undefined : parseRenameColumn(statement);
|
|
284
|
+
if (parsed && `${parsed.from}:${parsed.to}` === member)
|
|
285
|
+
return parsed;
|
|
286
|
+
const parts = member.split(':');
|
|
287
|
+
return parts.length === 2 && parts[0] && parts[1] ? { from: parts[0], to: parts[1] } : undefined;
|
|
71
288
|
}
|
|
72
289
|
/**
|
|
73
290
|
* Waits for DDL propagation based on the operation type and key.
|
|
74
291
|
* Called after each DDL statement in the migration execution loop.
|
|
75
292
|
*/
|
|
76
|
-
export async function waitForDDLPropagation(executor, operationType, operationKey) {
|
|
77
|
-
|
|
293
|
+
export async function waitForDDLPropagation(executor, operationType, operationKey, options) {
|
|
294
|
+
// Backfill assertions execute SELECT guards. Their keys identify the guard,
|
|
295
|
+
// not an object changed by DDL, so there is no schema propagation to await.
|
|
296
|
+
if (operationType === 'assert_backfill')
|
|
297
|
+
return;
|
|
298
|
+
// Cutover guards, barriers, copies and the snapshot ALTER change no object
|
|
299
|
+
// in the catalog; the objects a cutover creates and drops use the regular
|
|
300
|
+
// create/drop operation types.
|
|
301
|
+
if (operationType.startsWith('cutover_'))
|
|
302
|
+
return;
|
|
303
|
+
if (operationType === 'exchange_tables') {
|
|
304
|
+
if (options?.statement === undefined)
|
|
305
|
+
return;
|
|
306
|
+
return waitForExchange(executor, options.statement, options);
|
|
307
|
+
}
|
|
308
|
+
const parsed = parseOperationKey(operationType, operationKey);
|
|
78
309
|
if (!parsed)
|
|
79
310
|
return; // database-level ops or unrecognized keys — no wait needed
|
|
80
311
|
switch (operationType) {
|
|
81
312
|
case 'create_table':
|
|
82
|
-
return waitForTable(executor, parsed.database, parsed.table);
|
|
313
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
83
314
|
case 'create_view':
|
|
84
315
|
case 'create_materialized_view':
|
|
85
|
-
return waitForView(executor, parsed.database, parsed.table);
|
|
316
|
+
return waitForView(executor, parsed.database, parsed.table, options);
|
|
86
317
|
case 'create_dictionary':
|
|
87
|
-
return waitForDictionary(executor, parsed.database, parsed.table);
|
|
318
|
+
return waitForDictionary(executor, parsed.database, parsed.table, options);
|
|
319
|
+
// ADD COLUMN IF NOT EXISTS leaves an existing column as it is, so only
|
|
320
|
+
// agreement between replicas is checked; MODIFY also checks what it sets.
|
|
88
321
|
case 'alter_table_add_column':
|
|
89
322
|
case 'alter_table_modify_column':
|
|
90
323
|
if (parsed.column) {
|
|
91
|
-
|
|
324
|
+
const intent = operationType === 'alter_table_modify_column' && options?.statement !== undefined
|
|
325
|
+
? parseModifyColumnIntent(options.statement, parsed.column)
|
|
326
|
+
: {};
|
|
327
|
+
return waitForColumnDefinition(executor, parsed.database, parsed.table, parsed.column, intent, options);
|
|
92
328
|
}
|
|
93
329
|
return;
|
|
330
|
+
case 'alter_table_rename_column': {
|
|
331
|
+
const rename = parsed.rename === undefined ? undefined : renamedColumn(parsed.rename, options?.statement);
|
|
332
|
+
if (rename)
|
|
333
|
+
return waitForColumnRename(executor, parsed.database, parsed.table, rename, options);
|
|
334
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
335
|
+
}
|
|
336
|
+
case 'alter_table_add_index':
|
|
337
|
+
case 'alter_table_drop_index':
|
|
338
|
+
if (parsed.index) {
|
|
339
|
+
const want = operationType === 'alter_table_add_index' ? 'present' : 'absent';
|
|
340
|
+
return waitForIndex(executor, parsed.database, parsed.table, parsed.index, want, options);
|
|
341
|
+
}
|
|
342
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
343
|
+
case 'alter_table_drop_column':
|
|
344
|
+
if (parsed.column) {
|
|
345
|
+
return waitForColumnAbsent(executor, parsed.database, parsed.table, parsed.column, options);
|
|
346
|
+
}
|
|
347
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
348
|
+
case 'alter_table_add_constraint':
|
|
349
|
+
if (parsed.constraint) {
|
|
350
|
+
return waitForConstraint(executor, parsed.database, parsed.table, parsed.constraint, options);
|
|
351
|
+
}
|
|
352
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
353
|
+
case 'alter_table_drop_constraint':
|
|
354
|
+
if (parsed.constraint) {
|
|
355
|
+
return waitForConstraintAbsent(executor, parsed.database, parsed.table, parsed.constraint, options);
|
|
356
|
+
}
|
|
357
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
94
358
|
case 'drop_table':
|
|
95
359
|
case 'drop_view':
|
|
96
360
|
case 'drop_materialized_view':
|
|
97
361
|
case 'drop_dictionary':
|
|
98
|
-
return waitForTableAbsent(executor, parsed.database, parsed.table);
|
|
362
|
+
return waitForTableAbsent(executor, parsed.database, parsed.table, options);
|
|
99
363
|
default:
|
|
100
|
-
//
|
|
364
|
+
// alter_table_add_projection, alter_table_modify_setting,
|
|
365
|
+
// alter_materialized_view_modify_refresh, etc.
|
|
101
366
|
// Wait for the table to exist as a basic sanity check.
|
|
102
|
-
return waitForTable(executor, parsed.database, parsed.table);
|
|
367
|
+
return waitForTable(executor, parsed.database, parsed.table, options);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async function waitForSystemRows(executor, wait, options) {
|
|
371
|
+
const fanout = await resolveReplicaFanout(executor, options?.cluster);
|
|
372
|
+
await pRetry(async () => {
|
|
373
|
+
if (!(await systemRowsMatch(executor, wait, fanout)))
|
|
374
|
+
throw new Error(wait.label);
|
|
375
|
+
}, RETRY_OPTIONS);
|
|
376
|
+
}
|
|
377
|
+
async function dictionaryCatalogVisible(executor, where, fanout) {
|
|
378
|
+
if (!fanout) {
|
|
379
|
+
const rows = await executor.query(`SELECT 1 AS x FROM system.tables WHERE ${where}`);
|
|
380
|
+
return rows.length > 0;
|
|
381
|
+
}
|
|
382
|
+
const rows = await executor.query(`SELECT count(DISTINCT hostName()) AS replicas, (min(uuid) = max(uuid) AND min(uuid) != toUUID('00000000-0000-0000-0000-000000000000') AND count(uuid) = count()) AS agreed FROM ${allReplicas(fanout, 'system.tables')} WHERE ${where}`);
|
|
383
|
+
const replicas = Number(rows[0]?.replicas ?? 0);
|
|
384
|
+
return replicas >= fanout.replicas && isAgreed(rows[0]?.agreed);
|
|
385
|
+
}
|
|
386
|
+
function isAgreed(value) {
|
|
387
|
+
return value === true || value === 1 || value === '1';
|
|
388
|
+
}
|
|
389
|
+
async function systemRowsMatch(executor, wait, fanout) {
|
|
390
|
+
if (!fanout) {
|
|
391
|
+
const rows = await executor.query(`SELECT 1 AS x FROM ${wait.source} WHERE ${wait.where}`);
|
|
392
|
+
return wait.want === 'present' ? rows.length > 0 : rows.length === 0;
|
|
393
|
+
}
|
|
394
|
+
const rows = await executor.query(`SELECT count(DISTINCT hostName()) AS replicas FROM ${allReplicas(fanout, wait.source)} WHERE ${wait.where}`);
|
|
395
|
+
const replicas = Number(rows[0]?.replicas ?? 0);
|
|
396
|
+
return wait.want === 'present' ? replicas >= fanout.replicas : replicas === 0;
|
|
397
|
+
}
|
|
398
|
+
function columnWhere(database, tableName, columnName) {
|
|
399
|
+
return `database = ${stringLiteral(database)} AND table = ${stringLiteral(tableName)} AND name = ${stringLiteral(columnName)}`;
|
|
400
|
+
}
|
|
401
|
+
// ClickHouse writes a constraint name bare when it is a plain identifier and
|
|
402
|
+
// backquoted otherwise (`backQuoteIfNeed`), so match either spelling.
|
|
403
|
+
function constraintWhere(database, tableName, constraintName) {
|
|
404
|
+
const quoted = `\`${constraintName.replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``;
|
|
405
|
+
const spellings = [`CONSTRAINT ${constraintName} `, `CONSTRAINT ${quoted} `];
|
|
406
|
+
const listed = spellings.map((spelling) => `position(create_table_query, ${stringLiteral(spelling)}) > 0`).join(' OR ');
|
|
407
|
+
return `database = ${stringLiteral(database)} AND name = ${stringLiteral(tableName)} AND (${listed})`;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Polls `check` until it reports no mismatch, with the retry budget of the
|
|
411
|
+
* other waits (21 checks, 500 ms apart) and a hard deadline: chkit's queries
|
|
412
|
+
* have no request timeout, so a check that does not return ends the wait at
|
|
413
|
+
* the deadline instead of holding the migration. On timeout the error names
|
|
414
|
+
* what was still different on the last check.
|
|
415
|
+
*/
|
|
416
|
+
async function waitUntilApplied(subject, check) {
|
|
417
|
+
const deadline = Date.now() + DEFINITION_WAIT_DEADLINE_MS;
|
|
418
|
+
let checks = 0;
|
|
419
|
+
let detail = '';
|
|
420
|
+
for (;;) {
|
|
421
|
+
checks += 1;
|
|
422
|
+
const outcome = await checkBefore(check, deadline);
|
|
423
|
+
if (outcome.mismatch === undefined && outcome.failure === undefined)
|
|
424
|
+
return;
|
|
425
|
+
detail = outcome.failure ?? outcome.mismatch ?? '';
|
|
426
|
+
if (checks > RETRY_OPTIONS.retries || Date.now() + RETRY_OPTIONS.minTimeout >= deadline)
|
|
427
|
+
break;
|
|
428
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_OPTIONS.minTimeout));
|
|
103
429
|
}
|
|
430
|
+
throw new Error(`waitForDDLPropagation: ${subject} not applied on every replica after ${checks} checks: ${detail}`);
|
|
431
|
+
}
|
|
432
|
+
async function checkBefore(check, deadline) {
|
|
433
|
+
let timer;
|
|
434
|
+
const expired = new Promise((resolve) => {
|
|
435
|
+
timer = setTimeout(() => resolve({ failure: `a check did not return within the ${DEFINITION_WAIT_DEADLINE_MS / 1000}s deadline` }), Math.max(deadline - Date.now(), 0));
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
return await Promise.race([
|
|
439
|
+
check().then((mismatch) => ({ mismatch }), (error) => ({ failure: error instanceof Error ? error.message : String(error) })),
|
|
440
|
+
expired,
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The `system.columns` values `intent` pins down. The default expression is
|
|
449
|
+
* formatted by the server; when it cannot be formatted (a server without
|
|
450
|
+
* `formatQuerySingleLine`), the expression is left to the agreement check.
|
|
451
|
+
*/
|
|
452
|
+
async function expectedColumnValues(executor, intent) {
|
|
453
|
+
const expected = [];
|
|
454
|
+
if (intent.defaultKind !== undefined)
|
|
455
|
+
expected.push({ field: 'default_kind', value: intent.defaultKind });
|
|
456
|
+
if (intent.defaultKind === '')
|
|
457
|
+
expected.push({ field: 'default_expression', value: '' });
|
|
458
|
+
if (intent.typeDefault)
|
|
459
|
+
expected.push({ field: 'default_expression', value: 'defaultValueOfTypeName(', prefix: true });
|
|
460
|
+
if (intent.comment !== undefined)
|
|
461
|
+
expected.push({ field: 'comment', value: intent.comment });
|
|
462
|
+
if (intent.codecRemoved)
|
|
463
|
+
expected.push({ field: 'compression_codec', value: '' });
|
|
464
|
+
if (intent.defaultExpression !== undefined) {
|
|
465
|
+
const formatted = await formatExpression(executor, intent.defaultExpression);
|
|
466
|
+
if (formatted !== undefined)
|
|
467
|
+
expected.push({ field: 'default_expression', value: formatted });
|
|
468
|
+
}
|
|
469
|
+
return expected;
|
|
470
|
+
}
|
|
471
|
+
async function formatExpression(executor, expression) {
|
|
472
|
+
const select = 'SELECT ';
|
|
473
|
+
try {
|
|
474
|
+
const rows = await executor.query(`SELECT formatQuerySingleLine(${stringLiteral(`${select}${expression}`)}) AS formatted`);
|
|
475
|
+
const formatted = rows[0]?.formatted;
|
|
476
|
+
return formatted?.startsWith(select) ? formatted.slice(select.length) : undefined;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Why the replicas' rows do not show the intended definition yet, or
|
|
484
|
+
* undefined when every replica reports it.
|
|
485
|
+
*/
|
|
486
|
+
function columnDefinitionMismatch(rows, replicas, expected) {
|
|
487
|
+
const listed = new Set(rows.map((row) => row.replica));
|
|
488
|
+
if (listed.size < replicas) {
|
|
489
|
+
const where = listed.size > 0 ? ` (${[...listed].join(', ')})` : '';
|
|
490
|
+
return `the column is listed on ${listed.size} of ${replicas} replicas${where}`;
|
|
491
|
+
}
|
|
492
|
+
for (const row of rows) {
|
|
493
|
+
const off = expected.find((want) => !matchesExpected(row[want.field], want));
|
|
494
|
+
if (!off)
|
|
495
|
+
continue;
|
|
496
|
+
const sets = off.prefix ? `${JSON.stringify(off.value)}...` : JSON.stringify(off.value);
|
|
497
|
+
const unanimous = rows.length > 1 && rows.every((other) => other[off.field] === row[off.field]);
|
|
498
|
+
return (`replica ${row.replica} reports ${off.field} ${JSON.stringify(row[off.field])}, the statement sets ${sets}` +
|
|
499
|
+
(unanimous ? '; every replica reports the same, so this is not replication lag: the server accepted the statement without applying it' : ''));
|
|
500
|
+
}
|
|
501
|
+
const [first, ...others] = rows;
|
|
502
|
+
if (!first)
|
|
503
|
+
return undefined;
|
|
504
|
+
for (const row of others) {
|
|
505
|
+
const field = COLUMN_FIELDS.find((name) => row[name] !== first[name]);
|
|
506
|
+
if (field) {
|
|
507
|
+
return `replica ${row.replica} reports ${field} ${JSON.stringify(row[field])}, replica ${first.replica} reports ${JSON.stringify(first[field])}`;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
function matchesExpected(actual, want) {
|
|
513
|
+
return want.prefix ? actual.startsWith(want.value) : actual === want.value;
|
|
104
514
|
}
|
|
105
515
|
//# sourceMappingURL=ddl-propagation.js.map
|