@intentius/chant-lexicon-sql 0.116.0 → 0.118.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/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -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 +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +70 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +50 -0
- package/dist/okf/index.md +11 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +71 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +37 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +10 -0
- package/src/config.ts +22 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +10 -0
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { isTrivia, tokenizeText, type Token } from "../tokens";
|
|
35
|
-
import { parseCreate, unquote, type ColumnNode, type CreateNode, type Span } from "../parser";
|
|
35
|
+
import { parseCreate, unquote, type ColumnNode, type CreateNode, type DictionaryAttributeNode, type DictionaryNode, type Span } from "../parser";
|
|
36
36
|
import { MERGE_TREE_SETTINGS, TYPE_FAMILIES } from "../../generated/clickhouse";
|
|
37
37
|
import { previouslyIn, splitTopLevel as splitTop } from "../../core/normalize";
|
|
38
|
+
import { accessCanonical, grantsCanonical, isAccessStatement } from "../access";
|
|
38
39
|
|
|
39
40
|
export { previouslyIn } from "../../core/normalize";
|
|
40
41
|
|
|
41
|
-
export type ObjectKind = "database" | "table" | "view" | "materializedView";
|
|
42
|
+
export type ObjectKind = "database" | "table" | "view" | "materializedView" | "dictionary" | "function" | "user" | "role" | "rowPolicy" | "grants";
|
|
42
43
|
|
|
43
44
|
export interface CanonicalColumn {
|
|
44
45
|
name: string;
|
|
@@ -50,6 +51,10 @@ export interface CanonicalColumn {
|
|
|
50
51
|
codec?: string;
|
|
51
52
|
ttl?: string;
|
|
52
53
|
comment?: string;
|
|
54
|
+
/** A dictionary attribute's `EXPRESSION`. */
|
|
55
|
+
expression?: string;
|
|
56
|
+
/** A dictionary attribute's flags, sorted: `HIERARCHICAL`, `INJECTIVE`. */
|
|
57
|
+
flags?: string;
|
|
53
58
|
/** `-- previously: <name>` on the column's line: the name it had before. */
|
|
54
59
|
previously?: string;
|
|
55
60
|
/** The column's name and type as written, for a message. Not compared. */
|
|
@@ -77,6 +82,20 @@ export interface CanonicalObject {
|
|
|
77
82
|
select?: string;
|
|
78
83
|
to?: string;
|
|
79
84
|
refresh?: string;
|
|
85
|
+
/** A dictionary's `SOURCE`, `LAYOUT`, `LIFETIME` and `RANGE`, each what is inside the parentheses. */
|
|
86
|
+
dataSource?: string;
|
|
87
|
+
layout?: string;
|
|
88
|
+
lifetime?: string;
|
|
89
|
+
range?: string;
|
|
90
|
+
/** A function's lambda, `( x , y ) -> expr`. */
|
|
91
|
+
lambda?: string;
|
|
92
|
+
/** A row policy's table. */
|
|
93
|
+
table?: string;
|
|
94
|
+
/**
|
|
95
|
+
* A user's, role's or row policy's fields (`../access.ts`), each absent at
|
|
96
|
+
* the server's default; a grantee's grants, by atom.
|
|
97
|
+
*/
|
|
98
|
+
access?: Record<string, string>;
|
|
80
99
|
/** `-- previously: <name>` before the statement: the object's previous name. */
|
|
81
100
|
previously?: string;
|
|
82
101
|
}
|
|
@@ -231,7 +250,7 @@ function lineComments(tokens: Token[], from: number): string[] {
|
|
|
231
250
|
}
|
|
232
251
|
|
|
233
252
|
const kindOf = (node: CreateNode): ObjectKind =>
|
|
234
|
-
node.statement === "
|
|
253
|
+
node.statement === "view" ? (node.materialized ? "materializedView" : "view") : node.statement;
|
|
235
254
|
|
|
236
255
|
const stringValue = (s: string | undefined) =>
|
|
237
256
|
s === undefined ? undefined : /^'.*'$/s.test(s) ? s.slice(1, -1).replace(/''/g, "'").replace(/\\(.)/g, "$1") : s;
|
|
@@ -241,6 +260,22 @@ const stringValue = (s: string | undefined) =>
|
|
|
241
260
|
* database an unqualified name is created in.
|
|
242
261
|
*/
|
|
243
262
|
export function canonicalObject(ddl: string, defaultDatabase = "default"): CanonicalObject {
|
|
263
|
+
if (isAccessStatement(ddl)) {
|
|
264
|
+
if (/^\s*(?:--[^\n]*\n\s*)*(GRANT|REVOKE)\b/i.test(ddl)) throw new Error("a grant is compared as its grantee's grants: grantsObject(ddl, grantee)");
|
|
265
|
+
const a = accessCanonical(ddl, defaultDatabase);
|
|
266
|
+
return {
|
|
267
|
+
kind: a.kind,
|
|
268
|
+
...(a.database !== undefined ? { database: a.database } : {}),
|
|
269
|
+
name: a.name,
|
|
270
|
+
...(a.table !== undefined ? { table: a.table } : {}),
|
|
271
|
+
access: a.fields,
|
|
272
|
+
columns: [],
|
|
273
|
+
settings: {},
|
|
274
|
+
indexes: {},
|
|
275
|
+
projections: {},
|
|
276
|
+
constraints: {},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
244
279
|
const tokens = tokenizeText(ddl, 0);
|
|
245
280
|
const node = parseCreate(tokens);
|
|
246
281
|
const nameParts = tokens
|
|
@@ -249,7 +284,7 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
249
284
|
.map((t) => unquote(t.text));
|
|
250
285
|
const kind = kindOf(node);
|
|
251
286
|
const name = nameParts[nameParts.length - 1]!;
|
|
252
|
-
const database = kind === "database" ? undefined : nameParts.length >= 2 ? nameParts[nameParts.length - 2] : defaultDatabase;
|
|
287
|
+
const database = kind === "database" || kind === "function" ? undefined : nameParts.length >= 2 ? nameParts[nameParts.length - 2] : defaultDatabase;
|
|
253
288
|
const expr = (span: Span | undefined) => {
|
|
254
289
|
const t = spanText(tokens, span);
|
|
255
290
|
return t === undefined ? undefined : canonicalExpression(t, database);
|
|
@@ -269,6 +304,16 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
269
304
|
const previously = previouslyIn(leading);
|
|
270
305
|
if (previously) obj.previously = previously;
|
|
271
306
|
|
|
307
|
+
const comment0 = stringValue(spanText(tokens, node.comment));
|
|
308
|
+
if (node.statement === "dictionary") {
|
|
309
|
+
if (comment0 !== undefined && comment0 !== "") obj.comment = comment0;
|
|
310
|
+
return dictionaryObject(obj, tokens, node, database);
|
|
311
|
+
}
|
|
312
|
+
if (node.statement === "function") {
|
|
313
|
+
obj.lambda = canonicalExpression(spanText(tokens, node.lambda) ?? "");
|
|
314
|
+
return obj;
|
|
315
|
+
}
|
|
316
|
+
|
|
272
317
|
const engine = node.engine;
|
|
273
318
|
if (engine) {
|
|
274
319
|
let args = engine.args?.map((a) => canonicalExpression(spanText(tokens, a) ?? "", database)) ?? [];
|
|
@@ -291,8 +336,7 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
291
336
|
obj.engineName = name;
|
|
292
337
|
obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
|
|
293
338
|
}
|
|
294
|
-
|
|
295
|
-
if (comment !== undefined && comment !== "") obj.comment = comment;
|
|
339
|
+
if (comment0 !== undefined && comment0 !== "") obj.comment = comment0;
|
|
296
340
|
|
|
297
341
|
if (node.statement === "database") {
|
|
298
342
|
for (const s of node.settings ?? []) obj.settings[s.key] = canonicalExpression(spanText(tokens, s.value) ?? "").replace(/^'(.*)'$/, "$1");
|
|
@@ -333,6 +377,111 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
333
377
|
return obj;
|
|
334
378
|
}
|
|
335
379
|
|
|
380
|
+
// ── dictionaries ──────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A dictionary's clause (`SOURCE`, `LAYOUT`) as the server prints it: each
|
|
384
|
+
* function name and each key upper case (`clickhouse(table 'r')` is
|
|
385
|
+
* `CLICKHOUSE(TABLE 'r')`), each value as written, and a `PASSWORD` value
|
|
386
|
+
* as `'[HIDDEN]'`, since `SHOW CREATE` never prints it.
|
|
387
|
+
*/
|
|
388
|
+
export function canonicalDictionaryClause(text: string): string {
|
|
389
|
+
let sig: Token[];
|
|
390
|
+
try {
|
|
391
|
+
sig = tokenizeText(text, 0).filter((t) => !isTrivia(t));
|
|
392
|
+
} catch {
|
|
393
|
+
return text.trim();
|
|
394
|
+
}
|
|
395
|
+
const out: string[] = [];
|
|
396
|
+
let key = true;
|
|
397
|
+
let hide = false;
|
|
398
|
+
for (let i = 0; i < sig.length; i++) {
|
|
399
|
+
const t = sig[i]!;
|
|
400
|
+
const next = sig[i + 1];
|
|
401
|
+
if (t.kind === "punct" && (t.text === "(" || t.text === "," || t.text === ")")) {
|
|
402
|
+
out.push(t.text);
|
|
403
|
+
key = true;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (key && t.kind === "ident") {
|
|
407
|
+
const word = t.text.toUpperCase();
|
|
408
|
+
out.push(word);
|
|
409
|
+
// A function name opens its own list of keys; a key is followed by its value.
|
|
410
|
+
key = next?.kind === "punct" && next.text === "(";
|
|
411
|
+
hide = word === "PASSWORD";
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
out.push(hide ? "'[HIDDEN]'" : t.kind === "qident" ? unquote(t.text) : t.text);
|
|
415
|
+
hide = false;
|
|
416
|
+
key = !(next?.kind === "punct" && next.text === "(");
|
|
417
|
+
}
|
|
418
|
+
return out.join(" ");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** `300` is `MIN 0 MAX 300`; `MAX b MIN a` is `MIN a MAX b`. */
|
|
422
|
+
export function canonicalLifetime(text: string): string {
|
|
423
|
+
const words = canonicalExpression(text).split(" ");
|
|
424
|
+
if (words.length === 1) return `MIN 0 MAX ${words[0]}`;
|
|
425
|
+
const at = (w: string) => words.findIndex((x) => x.toUpperCase() === w);
|
|
426
|
+
const min = at("MIN");
|
|
427
|
+
const max = at("MAX");
|
|
428
|
+
if (min < 0 || max < 0) return words.join(" ");
|
|
429
|
+
return `MIN ${words[min + 1] ?? ""} MAX ${words[max + 1] ?? ""}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A key list with any parentheses around the whole of it left out: `(a, b)` is `a , b`. */
|
|
433
|
+
function keyList(text: string, database?: string): string {
|
|
434
|
+
const e = canonicalExpression(text, database);
|
|
435
|
+
if (!e.startsWith("( ") || !e.endsWith(" )")) return e;
|
|
436
|
+
let depth = 0;
|
|
437
|
+
const words = e.split(" ");
|
|
438
|
+
for (let i = 0; i < words.length; i++) {
|
|
439
|
+
if (words[i] === "(") depth++;
|
|
440
|
+
else if (words[i] === ")") depth--;
|
|
441
|
+
if (depth === 0 && i < words.length - 1) return e;
|
|
442
|
+
}
|
|
443
|
+
return words.slice(1, -1).join(" ");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function dictionaryAttribute(tokens: Token[], a: DictionaryAttributeNode, position: number, database?: string): CanonicalColumn {
|
|
447
|
+
const text = (span: Span | undefined) => spanText(tokens, span);
|
|
448
|
+
const out: CanonicalColumn = { name: a.name, position, type: canonicalType(text(a.type)!), text: [a.name, text(a.type)].join(" ") };
|
|
449
|
+
const d = text(a.default);
|
|
450
|
+
if (d !== undefined) {
|
|
451
|
+
out.defaultKind = "DEFAULT";
|
|
452
|
+
out.defaultExpr = canonicalExpression(d, database);
|
|
453
|
+
}
|
|
454
|
+
const e = text(a.expression);
|
|
455
|
+
if (e !== undefined) out.expression = canonicalExpression(e, database);
|
|
456
|
+
if (a.flags.length > 0) out.flags = [...a.flags].sort().join(" ");
|
|
457
|
+
const prev = previouslyIn(lineComments(tokens, a.nameSpan.from));
|
|
458
|
+
if (prev) out.previously = prev;
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function dictionaryObject(obj: CanonicalObject, tokens: Token[], node: DictionaryNode, database?: string): CanonicalObject {
|
|
463
|
+
const text = (span: Span | undefined) => spanText(tokens, span);
|
|
464
|
+
node.attributes.forEach((a, i) => obj.columns.push(dictionaryAttribute(tokens, a, i, database)));
|
|
465
|
+
const pk = text(node.primaryKey);
|
|
466
|
+
if (pk !== undefined) obj.primaryKey = keyList(pk, database);
|
|
467
|
+
const source = text(node.source);
|
|
468
|
+
if (source !== undefined) obj.dataSource = canonicalDictionaryClause(source);
|
|
469
|
+
const layout = text(node.layout);
|
|
470
|
+
if (layout !== undefined) obj.layout = canonicalDictionaryClause(layout);
|
|
471
|
+
const lifetime = text(node.lifetime);
|
|
472
|
+
if (lifetime !== undefined) obj.lifetime = canonicalLifetime(lifetime);
|
|
473
|
+
const range = text(node.range);
|
|
474
|
+
if (range !== undefined) obj.range = canonicalExpression(range, database).replace(/\b(min|max)\b/gi, (w) => w.toUpperCase());
|
|
475
|
+
const settings = text(node.settings);
|
|
476
|
+
if (settings !== undefined) {
|
|
477
|
+
for (const item of splitTop(canonicalExpression(settings))) {
|
|
478
|
+
const m = /^(\S+) = (.*)$/.exec(item);
|
|
479
|
+
if (m) obj.settings[m[1]!] = m[2]!.replace(/^'(.*)'$/, "$1");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return obj;
|
|
483
|
+
}
|
|
484
|
+
|
|
336
485
|
function qualified(tokens: Token[], span: Span, defaultDatabase: string): string {
|
|
337
486
|
const parts = tokens
|
|
338
487
|
.slice(span.from, span.to)
|
|
@@ -369,3 +518,48 @@ function quotedIdentifier(arg: string): string {
|
|
|
369
518
|
const bare = /^[A-Za-z_][A-Za-z0-9_]*$/.test(arg) ? arg : /^`((?:[^`]|``)+)`$/.exec(arg)?.[1]?.replace(/``/g, "`");
|
|
370
519
|
return bare === undefined ? arg : `'${bare.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
371
520
|
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The key an object is compared under against a server: `database.name`,
|
|
524
|
+
* a database's name, and `function <name>` for a function, which belongs to
|
|
525
|
+
* no database and must not meet a database of the same name.
|
|
526
|
+
*/
|
|
527
|
+
export function objectKey(o: { kind?: ObjectKind; type?: string; database?: string; name: string; table?: string }): string {
|
|
528
|
+
const kind = o.kind ?? KIND_OF_TYPE[o.type ?? ""];
|
|
529
|
+
if (kind === "function") return `function ${o.name}`;
|
|
530
|
+
if (kind === "user") return `user ${o.name}`;
|
|
531
|
+
if (kind === "role") return `role ${o.name}`;
|
|
532
|
+
if (kind === "grants") return `grants ${o.name}`;
|
|
533
|
+
if (kind === "rowPolicy") return `row policy ${o.name} ON ${o.database}.${o.table}`;
|
|
534
|
+
return o.database !== undefined ? `${o.database}.${o.name}` : o.name;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const KIND_OF_TYPE: Record<string, ObjectKind> = {
|
|
538
|
+
"ClickHouse::Function": "function",
|
|
539
|
+
"ClickHouse::User": "user",
|
|
540
|
+
"ClickHouse::Role": "role",
|
|
541
|
+
"ClickHouse::RowPolicy": "rowPolicy",
|
|
542
|
+
"ClickHouse::Grant": "grants",
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
/** A grantee's grants, from grant statements (one or more, on their own lines), as a plan compares them. */
|
|
546
|
+
export function grantsObject(ddl: string, grantee: string, defaultDatabase = "default"): CanonicalObject {
|
|
547
|
+
return { kind: "grants", name: grantee, access: grantsCanonical(ddl, grantee, defaultDatabase), columns: [], settings: {}, indexes: {}, projections: {}, constraints: {} };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* What an object is scoped by when a plan reads a server: its database, a
|
|
552
|
+
* database's own name, and for a function its key. A plan reads the
|
|
553
|
+
* databases the declarations use and the functions they declare, nothing
|
|
554
|
+
* else.
|
|
555
|
+
*/
|
|
556
|
+
export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string; name: string; table?: string }): string {
|
|
557
|
+
const kind = o.kind ?? KIND_OF_TYPE[o.type ?? ""];
|
|
558
|
+
if (kind === "function" || kind === "user" || kind === "role" || kind === "grants") return objectKey(o);
|
|
559
|
+
return o.database ?? o.name;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** A live object's canonical form: a grantee's grants from its `SHOW GRANTS` lines, anything else from its `SHOW CREATE`. */
|
|
563
|
+
export function liveCanonical(o: { type: string; name: string; statement: string }, defaultDatabase = "default"): CanonicalObject {
|
|
564
|
+
return o.type === "ClickHouse::Grant" ? grantsObject(o.statement, o.name, defaultDatabase) : canonicalObject(o.statement, defaultDatabase);
|
|
565
|
+
}
|
|
@@ -6,14 +6,25 @@ import type { SchemaDiff } from "./diff";
|
|
|
6
6
|
import { renderRebuildOps } from "./rebuild-handoff";
|
|
7
7
|
|
|
8
8
|
export function renderDiff(diff: SchemaDiff, opts: { title?: string } = {}): string {
|
|
9
|
-
const
|
|
10
|
-
|
|
9
|
+
const unreadable = diff.unreadable ?? [];
|
|
10
|
+
const refusedRead =
|
|
11
|
+
unreadable.length > 0
|
|
12
|
+
? [
|
|
13
|
+
"",
|
|
14
|
+
`Refused: ${unreadable.length} object(s) in the declared databases could not be read, so this plan cannot say what changes for them:`,
|
|
15
|
+
...unreadable.map((u) => ` ${u.object} (${u.type}): ${u.reason}`),
|
|
16
|
+
]
|
|
17
|
+
: [];
|
|
18
|
+
const trailer = [
|
|
19
|
+
...refusedRead,
|
|
20
|
+
...(diff.rebuilds.length > 0
|
|
11
21
|
? [
|
|
12
22
|
"",
|
|
13
23
|
`Refused: ${diff.rebuilds.length} change(s) need a rebuild, which ClickHouse cannot make to the existing table. ` +
|
|
14
24
|
"A rebuild runs as its own migration (create the new table, backfill, verify, swap), not in place.",
|
|
15
25
|
...renderRebuildOps(diff.rebuildOps ?? []),
|
|
16
26
|
]
|
|
17
|
-
: []
|
|
27
|
+
: []),
|
|
28
|
+
];
|
|
18
29
|
return renderChangeSet(diff, { ...(opts.title ? { title: opts.title } : {}), rules: CLASSIFIER_RULES, classes: CHANGE_CLASSES, trailer });
|
|
19
30
|
}
|
|
@@ -35,7 +35,7 @@ const DOCS = "https://clickhouse.com/docs/sql-reference/statements";
|
|
|
35
35
|
const rule = (id: string, cls: ChangeClass, title: string, restriction: string, cite: string): ClassifierRule => classifierRule(id, cls, title, restriction, cite);
|
|
36
36
|
|
|
37
37
|
export const CLASSIFIER_RULES = {
|
|
38
|
-
SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table or
|
|
38
|
+
SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table, view, dictionary, function, user, role or row policy is created, or a grantee gets its first grants; nothing existing changes.", `${DOCS}/create`),
|
|
39
39
|
SQLCH201: rule(
|
|
40
40
|
"SQLCH201",
|
|
41
41
|
"metadata",
|
|
@@ -235,6 +235,49 @@ export const CLASSIFIER_RULES = {
|
|
|
235
235
|
"ALTER TABLE ... MODIFY REFRESH changes the schedule of a refreshable materialized view.",
|
|
236
236
|
`${DOCS}/alter/view`,
|
|
237
237
|
),
|
|
238
|
+
SQLCH245: rule(
|
|
239
|
+
"SQLCH245",
|
|
240
|
+
"metadata",
|
|
241
|
+
"Change a dictionary",
|
|
242
|
+
"A dictionary stores no data of its own: CREATE OR REPLACE DICTIONARY replaces its attributes, key, source, layout, lifetime or range, and it loads again from its source.",
|
|
243
|
+
`${DOCS}/create/dictionary`,
|
|
244
|
+
),
|
|
245
|
+
SQLCH260: rule(
|
|
246
|
+
"SQLCH260",
|
|
247
|
+
"metadata",
|
|
248
|
+
"Change a function",
|
|
249
|
+
"A SQL user-defined function stores no data: CREATE OR REPLACE FUNCTION replaces its parameters and expression, and queries that call it use the new one.",
|
|
250
|
+
`${DOCS}/create/function`,
|
|
251
|
+
),
|
|
252
|
+
SQLCH270: rule(
|
|
253
|
+
"SQLCH270",
|
|
254
|
+
"metadata",
|
|
255
|
+
"Change a role",
|
|
256
|
+
"ALTER ROLE ... SETTINGS replaces a role's settings; its grants are untouched.",
|
|
257
|
+
`${DOCS}/alter/role`,
|
|
258
|
+
),
|
|
259
|
+
SQLCH271: rule(
|
|
260
|
+
"SQLCH271",
|
|
261
|
+
"metadata",
|
|
262
|
+
"Change a user",
|
|
263
|
+
"ALTER USER sets one clause (HOST, DEFAULT ROLE, DEFAULT DATABASE, GRANTEES, SETTINGS, VALID UNTIL, IDENTIFIED) and leaves the rest, the password and the grants untouched. Sessions already open keep the old settings.",
|
|
264
|
+
`${DOCS}/alter/user`,
|
|
265
|
+
),
|
|
266
|
+
SQLCH272: rule(
|
|
267
|
+
"SQLCH272",
|
|
268
|
+
"metadata",
|
|
269
|
+
"Change a row policy",
|
|
270
|
+
"CREATE ROW POLICY OR REPLACE replaces the policy's condition, kind and roles; queries started after it see the new one.",
|
|
271
|
+
`${DOCS}/create/row-policy`,
|
|
272
|
+
),
|
|
273
|
+
SQLCH273: rule("SQLCH273", "metadata", "Grant a privilege or a role", "GRANT adds a privilege or a role to a user or role; it takes effect for new queries.", `${DOCS}/grant`),
|
|
274
|
+
SQLCH274: rule(
|
|
275
|
+
"SQLCH274",
|
|
276
|
+
"metadata",
|
|
277
|
+
"Revoke a privilege or a role",
|
|
278
|
+
"REVOKE takes a privilege or a role away, including one granted by hand to a grantee the build declares grants for; queries that relied on it fail from then on.",
|
|
279
|
+
`${DOCS}/revoke`,
|
|
280
|
+
),
|
|
238
281
|
SQLCH250: rule("SQLCH250", "drop", "Drop an object", "DROP removes the object and, for a table, its data; it is not undone.", `${DOCS}/drop`),
|
|
239
282
|
} as const satisfies Record<string, ClassifierRule>;
|
|
240
283
|
|
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
|
-
import { canonicalObject } from "./normalize";
|
|
8
|
-
import
|
|
7
|
+
import { canonicalObject, grantsObject, liveCanonical, objectKey, scopeOf } from "./normalize";
|
|
8
|
+
import { grantsByGrantee } from "../access";
|
|
9
|
+
import type { SchemaObject, UnreadableEntry } from "./diff";
|
|
9
10
|
import type { ClickHouseTarget } from "../live/bind";
|
|
10
|
-
import { readLiveSchema } from "../live/catalog";
|
|
11
|
+
import { readLiveAccess, readLiveSchema, type DeclaredAccess } from "../live/catalog";
|
|
11
12
|
import { renderFor, type Topology } from "../topology";
|
|
12
13
|
|
|
13
14
|
interface OutputDoc {
|
|
14
15
|
dialect?: string;
|
|
15
|
-
objects?: Array<{ export: string; ddl: string }>;
|
|
16
|
+
objects?: Array<{ export: string; ddl: string; type?: string }>;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/** The objects a `chant build` output holds, keyed by export name; with a `topology`, each declaration as rendered for it (`../topology.ts`). */
|
|
@@ -21,7 +22,15 @@ export function schemaFromBuildOutput(json: string, defaultDatabase = "default",
|
|
|
21
22
|
if (doc.dialect !== "clickhouse" || !Array.isArray(doc.objects)) {
|
|
22
23
|
throw new Error("not a sql lexicon build output: expected { dialect: \"clickhouse\", objects: [...] }");
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
const out: SchemaObject[] = doc.objects
|
|
26
|
+
.filter((o) => o.type !== "ClickHouse::Grant")
|
|
27
|
+
.map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
|
|
28
|
+
// Grants are compared per grantee (`../access.ts`).
|
|
29
|
+
for (const g of grantsByGrantee(doc.objects.map((o) => ({ ...o, type: o.type ?? "" })))) {
|
|
30
|
+
const canonical = grantsObject(g.ddl, g.grantee, defaultDatabase);
|
|
31
|
+
out.push({ key: objectKey(canonical), canonical });
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
export function schemaFromBuildFile(path: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
|
|
@@ -34,7 +43,7 @@ export function schemaFromBuildFile(path: string, defaultDatabase = "default", t
|
|
|
34
43
|
*/
|
|
35
44
|
export function keyedByQualifiedName(objects: readonly SchemaObject[]): Array<SchemaObject & { exportName: string }> {
|
|
36
45
|
return objects.map((o) => ({
|
|
37
|
-
key: o.canonical
|
|
46
|
+
key: objectKey(o.canonical),
|
|
38
47
|
canonical: o.canonical,
|
|
39
48
|
exportName: o.key,
|
|
40
49
|
}));
|
|
@@ -42,16 +51,30 @@ export function keyedByQualifiedName(objects: readonly SchemaObject[]): Array<Sc
|
|
|
42
51
|
|
|
43
52
|
/**
|
|
44
53
|
* What the server holds, keyed by `database.name`, limited to the databases
|
|
45
|
-
* the declarations use (and the target's scope)
|
|
46
|
-
*
|
|
54
|
+
* the declarations use (and the target's scope) and the functions they
|
|
55
|
+
* declare (`scopeOf`). The `default` database is
|
|
56
|
+
* left out, as import leaves it out. An object whose definition chant cannot
|
|
57
|
+
* read is not left out silently (#3653): it goes in `unreadable`, when
|
|
58
|
+
* given, and otherwise fails the read.
|
|
47
59
|
*/
|
|
48
|
-
export async function schemaFromServer(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
export async function schemaFromServer(
|
|
61
|
+
target: ClickHouseTarget,
|
|
62
|
+
databases?: ReadonlySet<string>,
|
|
63
|
+
unreadable?: UnreadableEntry[],
|
|
64
|
+
access: readonly DeclaredAccess[] = [],
|
|
65
|
+
): Promise<SchemaObject[]> {
|
|
66
|
+
const live = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, access))];
|
|
67
|
+
const out: SchemaObject[] = [];
|
|
68
|
+
for (const o of live) {
|
|
69
|
+
if (o.type === "ClickHouse::Database" && o.name === "default") continue;
|
|
70
|
+
if (databases && !databases.has(scopeOf(o))) continue;
|
|
71
|
+
const key = objectKey(o);
|
|
72
|
+
try {
|
|
73
|
+
out.push({ key, canonical: liveCanonical(o, target.defaultDatabase) });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (!unreadable) throw err;
|
|
76
|
+
unreadable.push({ object: key, type: o.type, reason: `its definition does not parse: ${(err as Error).message}` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
57
80
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
10
10
|
import type { Change } from "./diff";
|
|
11
11
|
|
|
12
|
-
const EXPRESSION_FIELDS = /^(orderBy|primaryKey|partitionBy|sampleBy|select|columns\.[^.]+\.(default|ttl)|indexes\..+|projections\..+)$/;
|
|
12
|
+
const EXPRESSION_FIELDS = /^(orderBy|primaryKey|partitionBy|sampleBy|select|lambda|using|columns\.[^.]+\.(default|ttl)|indexes\..+|projections\..+)$/;
|
|
13
13
|
|
|
14
14
|
const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
15
15
|
|
|
@@ -71,7 +71,7 @@ import { RebuildRefusal, type RebuildObservation } from "./observe";
|
|
|
71
71
|
import { DDL_SETTINGS } from "./replicas";
|
|
72
72
|
import { shardPartitions, sourcePartitionExpression, sourcePartitions } from "./partitions";
|
|
73
73
|
import { rowHash } from "./verify";
|
|
74
|
-
import { cutoverOf, observe,
|
|
74
|
+
import { cutoverOf, observe, serverCutoverProbe, syncReplica, utcLiteral, waitForCutover, waitOn, type CutoverProbe, type RebuildRun } from "./steps";
|
|
75
75
|
import { intoShard, onShard, onShardServers, shardTable, SHARD_DELETE_SETTINGS, SHARD_INSERT_SETTINGS, type Shard } from "./shards";
|
|
76
76
|
import { renderFor } from "../topology";
|
|
77
77
|
|
|
@@ -118,6 +118,8 @@ export interface BackfillDeps {
|
|
|
118
118
|
receipts?: ClickHouseReceiptStore;
|
|
119
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
120
|
afterPartition?: (partition: string, shard?: number) => void | Promise<void>;
|
|
121
|
+
/** What the wait for the cut-over reads. Default: the server's clock, queries and insert buffers. */
|
|
122
|
+
cutoverProbe?: CutoverProbe;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
|
|
@@ -128,16 +130,8 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
128
130
|
const n = o.names;
|
|
129
131
|
const cutover = cutoverOf(run, o);
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// clock has passed it, so none of them is still to arrive.
|
|
134
|
-
for (;;) {
|
|
135
|
-
const now = await serverNow(run.target);
|
|
136
|
-
if (now > cutover) break;
|
|
137
|
-
run.log(`-- waiting ${Math.ceil((cutover - now) / 1000)}s for the cut-over at ${new Date(cutover).toISOString()}`);
|
|
138
|
-
await sleep(Math.min(cutover - now + 50, 10_000), run.signal);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
133
|
+
// Rows before the cut-over are the backfill's; wait until none of them is still to arrive.
|
|
134
|
+
if (cutover !== undefined) await waitForCutover(run, cutover, deps.cutoverProbe ?? serverCutoverProbe(run, o), run.cutoverTimeoutMs !== undefined ? { timeoutMs: run.cutoverTimeoutMs } : {});
|
|
141
135
|
await waitOn(run, n.database, n.name);
|
|
142
136
|
await syncReplica(run, o, n.database, n.name);
|
|
143
137
|
|
|
@@ -335,17 +329,3 @@ async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: L
|
|
|
335
329
|
}
|
|
336
330
|
return repaired;
|
|
337
331
|
}
|
|
338
|
-
|
|
339
|
-
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
340
|
-
return new Promise((resolve, reject) => {
|
|
341
|
-
const t = setTimeout(resolve, ms);
|
|
342
|
-
signal?.addEventListener(
|
|
343
|
-
"abort",
|
|
344
|
-
() => {
|
|
345
|
-
clearTimeout(t);
|
|
346
|
-
reject(signal.reason);
|
|
347
|
-
},
|
|
348
|
-
{ once: true },
|
|
349
|
-
);
|
|
350
|
-
});
|
|
351
|
-
}
|
|
@@ -57,11 +57,15 @@ export type DualWrite =
|
|
|
57
57
|
* `cutoverColumn` is at or after the cut-over into the new table; the
|
|
58
58
|
* backfill copies the rows before it, and the rows at or after it that
|
|
59
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 `
|
|
61
|
-
* up to a whole second
|
|
60
|
+
* time when the view is made plus `cutoverDelay` (default `5s`), rounded
|
|
61
|
+
* up to a whole second. The backfill starts once the server's clock has
|
|
62
|
+
* passed it and every write into the old table begun before it has
|
|
63
|
+
* finished (`cutoverTimeout` bounds that wait). The column must be a time
|
|
62
64
|
* (`Date`, `DateTime`, `DateTime64`) that rows arrive in order of, give
|
|
63
65
|
* or take the delay; a row that arrives later than that is caught by
|
|
64
|
-
* the verification, which then fails the run.
|
|
66
|
+
* the verification, which then fails the run. Writers that batch rows
|
|
67
|
+
* for longer than five seconds before inserting them need a delay
|
|
68
|
+
* longer than their batches.
|
|
65
69
|
*/
|
|
66
70
|
mode: "materialized-view";
|
|
67
71
|
cutoverColumn: string;
|
|
@@ -105,6 +105,13 @@ export interface ClickHouseRebuildOpConfig {
|
|
|
105
105
|
* of the step waits this long. Default: `2m`.
|
|
106
106
|
*/
|
|
107
107
|
replicaTimeout?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Materialized-view mode: how long the backfill waits, once the cut-over
|
|
110
|
+
* has passed, for INSERTs into the old table begun before it to finish and
|
|
111
|
+
* for asynchronous inserts queued before it to be flushed, before it stops
|
|
112
|
+
* naming them. Default: `10m`.
|
|
113
|
+
*/
|
|
114
|
+
cutoverTimeout?: string;
|
|
108
115
|
/** Ownership stack. Default: `ownership.stack` in `chant.config.ts`. */
|
|
109
116
|
stack?: string;
|
|
110
117
|
/** Ownership env. Default: `ownership.env` in `chant.config.ts`. */
|
|
@@ -124,6 +131,7 @@ export interface ClickHouseRebuildArgs {
|
|
|
124
131
|
retain?: string;
|
|
125
132
|
mutationTimeout?: string;
|
|
126
133
|
replicaTimeout?: string;
|
|
134
|
+
cutoverTimeout?: string;
|
|
127
135
|
stack?: string;
|
|
128
136
|
ownershipEnv?: string;
|
|
129
137
|
cwd?: string;
|
|
@@ -158,6 +166,7 @@ export function ClickHouseRebuildOp(config: ClickHouseRebuildOpConfig): ClickHou
|
|
|
158
166
|
...(config.retain ? { retain: config.retain } : {}),
|
|
159
167
|
...(config.mutationTimeout ? { mutationTimeout: config.mutationTimeout } : {}),
|
|
160
168
|
...(config.replicaTimeout ? { replicaTimeout: config.replicaTimeout } : {}),
|
|
169
|
+
...(config.cutoverTimeout ? { cutoverTimeout: config.cutoverTimeout } : {}),
|
|
161
170
|
...(config.stack ? { stack: config.stack } : {}),
|
|
162
171
|
...(config.ownershipEnv ? { ownershipEnv: config.ownershipEnv } : {}),
|
|
163
172
|
...(config.path && config.path !== "." ? { cwd: config.path } : {}),
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* backfill that fails part way leaves the new table and its receipts, and
|
|
15
15
|
* the next run skips the partitions already copied, verifies and swaps
|
|
16
16
|
* with no gate, and keeps the old table.
|
|
17
|
+
* - A small table (#3700): the backfill waits only the default cut-over
|
|
18
|
+
* delay, and the wait sees an INSERT still running and an asynchronous
|
|
19
|
+
* insert still buffered.
|
|
17
20
|
* - An interrupted backfill: stopped after three partitions, then killed
|
|
18
21
|
* between a partition's INSERT and its receipt, then resumed: the receipts
|
|
19
22
|
* skip what was copied, the half-copied partition is cleared first, and the
|
|
@@ -38,6 +41,7 @@ import * as rebuildActivities from "../../op/activities/clickhouse-rebuild";
|
|
|
38
41
|
import type { ClickHouseRebuildDeps } from "../../op/activities/clickhouse-rebuild";
|
|
39
42
|
import { ClickHouseRebuildOp, type ClickHouseRebuildOpConfig } from "./op";
|
|
40
43
|
import { clickhouseReceiptStore, RECEIPTS_DATABASE, RECEIPTS_TABLE, type ClickHouseReceiptStore } from "./receipts";
|
|
44
|
+
import { serverCutoverProbe, type RebuildRun } from "./steps";
|
|
41
45
|
|
|
42
46
|
const enabled = await dockerAvailable();
|
|
43
47
|
let server: ScratchServer | undefined;
|
|
@@ -560,3 +564,61 @@ ORDER BY ${orderBy}`;
|
|
|
560
564
|
await q("DROP TABLE shop.sessions__chant_new SYNC");
|
|
561
565
|
}, 300_000);
|
|
562
566
|
});
|
|
567
|
+
|
|
568
|
+
describe.skipIf(!enabled)("a small table waits seconds for its cut-over (#3700)", () => {
|
|
569
|
+
const tinyDdl = (orderBy: string) => `CREATE TABLE shop.tiny (ts DateTime, id UInt64) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY ${orderBy}`;
|
|
570
|
+
const V1: Obj = { export: "tiny", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: tinyDdl("(ts, id)") };
|
|
571
|
+
const V2: Obj = { ...V1, ddl: tinyDdl("(id, ts)") };
|
|
572
|
+
|
|
573
|
+
test("thirty rows reach the swap gate with the default delay, a few seconds after the view is made", async () => {
|
|
574
|
+
await clickhouseApply({ buildPath: join(dir, writeBuild("tiny-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
|
|
575
|
+
await q("INSERT INTO shop.tiny SELECT now() - toIntervalMinute(number), number FROM numbers(30)");
|
|
576
|
+
writeBuild("tiny-v2.json", [DB, V2]);
|
|
577
|
+
const lines: string[] = [];
|
|
578
|
+
const began = Date.now();
|
|
579
|
+
const r = await runOp(
|
|
580
|
+
{
|
|
581
|
+
name: "rebuild-tiny",
|
|
582
|
+
env: "e2e",
|
|
583
|
+
table: "shop.tiny",
|
|
584
|
+
dualWrite: { mode: "materialized-view", cutoverColumn: "ts" },
|
|
585
|
+
build: false,
|
|
586
|
+
path: dir,
|
|
587
|
+
output: "tiny-v2.json",
|
|
588
|
+
stack: MARKER.stack,
|
|
589
|
+
ownershipEnv: MARKER.env,
|
|
590
|
+
},
|
|
591
|
+
new Ledger().port,
|
|
592
|
+
{ log: (l: string) => void lines.push(l) },
|
|
593
|
+
);
|
|
594
|
+
expect(r.status).toBe("gated");
|
|
595
|
+
expect(outcome(r, "VerifiedRows")).toBe(30);
|
|
596
|
+
// The delay is five seconds, rounded up to the next whole second.
|
|
597
|
+
const waited = lines.filter((l) => l.startsWith("-- waiting") && l.includes("for the cut-over"));
|
|
598
|
+
expect(waited.length).toBeGreaterThan(0);
|
|
599
|
+
expect(Number(/waiting (\d+)s/.exec(waited[0]!)![1])).toBeLessThanOrEqual(6);
|
|
600
|
+
expect(Date.now() - began).toBeLessThan(30_000);
|
|
601
|
+
}, 120_000);
|
|
602
|
+
|
|
603
|
+
test("the wait names an INSERT begun before the cut-over while it runs, and an asynchronous insert while it is buffered", async () => {
|
|
604
|
+
await q("CREATE TABLE shop.writes (ts DateTime, id UInt64) ENGINE = MergeTree ORDER BY ts");
|
|
605
|
+
const run = { target: { endpoint: server!.endpoint } } as unknown as RebuildRun;
|
|
606
|
+
const probe = serverCutoverProbe(run, { names: { database: "shop", name: "writes" }, replicated: false } as never);
|
|
607
|
+
const later = (await probe.now()) + 60_000;
|
|
608
|
+
expect(await probe.pendingWrites(later)).toEqual([]);
|
|
609
|
+
|
|
610
|
+
const slow = clickhouseQuery(server!.endpoint, "INSERT INTO shop.writes SELECT now(), number + sleepEachRow(1) FROM numbers(3) SETTINGS max_block_size = 1", { queryId: "e2e-3700-slow" });
|
|
611
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
612
|
+
expect(await probe.pendingWrites(later)).toEqual(["e2e-3700-slow"]);
|
|
613
|
+
// Begun after the cut-over: not the backfill's to wait for.
|
|
614
|
+
expect(await probe.pendingWrites((await probe.now()) - 60_000)).toEqual([]);
|
|
615
|
+
await slow;
|
|
616
|
+
expect(await probe.pendingWrites(later)).toEqual([]);
|
|
617
|
+
|
|
618
|
+
await clickhouseQuery(server!.endpoint, "INSERT INTO shop.writes VALUES (now(), 7)", {
|
|
619
|
+
queryId: "e2e-3700-async",
|
|
620
|
+
settings: { async_insert: "1", wait_for_async_insert: "0", async_insert_busy_timeout_min_ms: "4000", async_insert_busy_timeout_max_ms: "4000", async_insert_use_adaptive_busy_timeout: "0" },
|
|
621
|
+
});
|
|
622
|
+
expect(await probe.pendingWrites(later)).toEqual(["e2e-3700-async"]);
|
|
623
|
+
}, 60_000);
|
|
624
|
+
});
|