@intentius/chant-lexicon-sql 0.124.31 → 0.124.32
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/change-set.js +250 -0
- package/dist/clickhouse/access.js +722 -0
- package/dist/clickhouse/apply/apply.js +243 -0
- package/dist/clickhouse/apply/mutations.js +66 -0
- package/dist/clickhouse/apply/statements.js +545 -0
- package/dist/clickhouse/catalog-types.js +7 -0
- package/dist/clickhouse/container.js +152 -0
- package/dist/clickhouse/engine-syntax.js +159 -0
- package/dist/clickhouse/entities.js +593 -0
- package/dist/clickhouse/expressions.js +107 -0
- package/dist/clickhouse/http.js +55 -0
- package/dist/clickhouse/import/generator.js +192 -0
- package/dist/clickhouse/import/inlined.js +207 -0
- package/dist/clickhouse/import/ir.js +160 -0
- package/dist/clickhouse/import/live-export.js +131 -0
- package/dist/clickhouse/import/parser.js +17 -0
- package/dist/clickhouse/live/bind.js +116 -0
- package/dist/clickhouse/live/catalog.js +223 -0
- package/dist/clickhouse/live/describe-resources.js +91 -0
- package/dist/clickhouse/live/undeclarable.js +59 -0
- package/dist/clickhouse/overlays/codecs.js +57 -0
- package/dist/clickhouse/overlays/engines.js +101 -0
- package/dist/clickhouse/overlays/kinds.js +1 -0
- package/dist/clickhouse/overlays/projections.js +26 -0
- package/dist/clickhouse/overlays/skip-indexes.js +40 -0
- package/dist/clickhouse/overlays/ttl.js +34 -0
- package/dist/clickhouse/overlays/types.js +59 -0
- package/dist/clickhouse/ownership.js +83 -0
- package/dist/clickhouse/parser.js +674 -0
- package/dist/clickhouse/plan/commands.js +159 -0
- package/dist/clickhouse/plan/deep.js +345 -0
- package/dist/clickhouse/plan/diff.js +312 -0
- package/dist/clickhouse/plan/disruption.js +73 -0
- package/dist/clickhouse/plan/normalize.js +545 -0
- package/dist/clickhouse/plan/rebuild-handoff.js +57 -0
- package/dist/clickhouse/plan/report.js +26 -0
- package/dist/clickhouse/plan/rules.js +72 -0
- package/dist/clickhouse/plan/schema.js +67 -0
- package/dist/clickhouse/plan/server-format.js +35 -0
- package/dist/clickhouse/rebuild/backfill.js +265 -0
- package/dist/clickhouse/rebuild/engines.js +146 -0
- package/dist/clickhouse/rebuild/observe.js +260 -0
- package/dist/clickhouse/rebuild/op.js +152 -0
- package/dist/clickhouse/rebuild/partitions.js +65 -0
- package/dist/clickhouse/rebuild/receipts.js +120 -0
- package/dist/clickhouse/rebuild/replicas.js +121 -0
- package/dist/clickhouse/rebuild/shards.js +76 -0
- package/dist/clickhouse/rebuild/steps.js +383 -0
- package/dist/clickhouse/rebuild/verify.js +279 -0
- package/dist/clickhouse/row-types.js +151 -0
- package/dist/clickhouse/testing/fake-server.js +47 -0
- package/dist/clickhouse/testing/roundtrip-schema.js +25 -0
- package/dist/clickhouse/testing/writable-server.js +123 -0
- package/dist/clickhouse/tokens.js +23 -0
- package/dist/clickhouse/topology.js +463 -0
- package/dist/clickhouse/type-syntax.js +73 -0
- package/dist/clickhouse.js +25 -0
- package/dist/codegen/classifier-page.js +93 -0
- package/dist/codegen/clickhouse-module.js +249 -0
- package/dist/codegen/coverage-page.js +70 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +222 -0
- package/dist/codegen/generate-cli.js +13 -0
- package/dist/codegen/generate.js +97 -0
- package/dist/codegen/package.js +28 -0
- package/dist/codegen/postgres-classifier-page.js +95 -0
- package/dist/codegen/postgres-module.js +418 -0
- package/dist/codegen/registry.js +52 -0
- package/dist/composites/audit-log-table.js +35 -0
- package/dist/composites/catalog.js +524 -0
- package/dist/composites/cdc-mirror.js +32 -0
- package/dist/composites/clickhouse.js +6 -0
- package/dist/composites/events-table.js +26 -0
- package/dist/composites/index.js +8 -0
- package/dist/composites/join-table.js +25 -0
- package/dist/composites/postgres.js +6 -0
- package/dist/composites/refreshed-view.js +24 -0
- package/dist/composites/replacing-table.js +28 -0
- package/dist/composites/rollup-view.js +29 -0
- package/dist/composites/sharded-table.js +27 -0
- package/dist/composites/soft-delete-table.js +34 -0
- package/dist/composites/tenant-table.js +26 -0
- package/dist/composites/typed.js +8 -0
- package/dist/config.js +206 -0
- package/dist/core/apply.js +93 -0
- package/dist/core/classifier.js +115 -0
- package/dist/core/container.js +69 -0
- package/dist/core/cursor.js +261 -0
- package/dist/core/diff.js +48 -0
- package/dist/core/entity.js +58 -0
- package/dist/core/find-templates.js +118 -0
- package/dist/core/foreign-tables.js +27 -0
- package/dist/core/handoff.js +32 -0
- package/dist/core/index.js +64 -0
- package/dist/core/interpolation.js +191 -0
- package/dist/core/normalize.js +43 -0
- package/dist/core/ownership.js +119 -0
- package/dist/core/receipts.js +22 -0
- package/dist/core/references.js +61 -0
- package/dist/core/statements.js +42 -0
- package/dist/core/template.js +79 -0
- package/dist/core/testing/http-stub.js +21 -0
- package/dist/core/tokens.js +202 -0
- package/dist/core/typed.js +40 -0
- package/dist/core/undeclarable.js +46 -0
- package/dist/coverage.js +161 -0
- package/dist/detect.js +32 -0
- package/dist/dialects.js +18 -0
- package/dist/files/build-root.js +72 -0
- package/dist/files/clickhouse.js +138 -0
- package/dist/files/common.js +58 -0
- package/dist/files/postgres.js +389 -0
- package/dist/generated/clickhouse-types.js +5 -0
- package/dist/generated/clickhouse.js +4361 -0
- package/dist/generated/postgres-types.js +227 -0
- package/dist/generated/postgres.js +3667 -0
- package/dist/import-generator.js +14 -0
- package/dist/import-parser.js +71 -0
- package/dist/index.js +24 -0
- package/dist/init-templates.js +290 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +31 -0
- package/dist/lint/audit-lineage.js +22 -0
- package/dist/lint/post-synth/audit-entries.js +34 -0
- package/dist/lint/post-synth/clickhouse-helpers.js +79 -0
- package/dist/lint/post-synth/clickhouse-type-check.js +162 -0
- package/dist/lint/post-synth/index.js +112 -0
- package/dist/lint/post-synth/postgres-audit-entries.js +35 -0
- package/dist/lint/post-synth/postgres-helpers.js +65 -0
- package/dist/lint/post-synth/postgres-names.js +507 -0
- package/dist/lint/post-synth/sql-helpers.js +23 -0
- package/dist/lint/post-synth/sql101.js +59 -0
- package/dist/lint/post-synth/sql102.js +34 -0
- package/dist/lint/post-synth/sqlch101.js +34 -0
- package/dist/lint/post-synth/sqlch102.js +23 -0
- package/dist/lint/post-synth/sqlch103.js +45 -0
- package/dist/lint/post-synth/sqlch104.js +41 -0
- package/dist/lint/post-synth/sqlch105.js +36 -0
- package/dist/lint/post-synth/sqlch106.js +27 -0
- package/dist/lint/post-synth/sqlch107.js +39 -0
- package/dist/lint/post-synth/sqlch108.js +28 -0
- package/dist/lint/post-synth/sqlch109.js +21 -0
- package/dist/lint/post-synth/sqlch110.js +31 -0
- package/dist/lint/post-synth/sqlch111.js +41 -0
- package/dist/lint/post-synth/sqlch112.js +28 -0
- package/dist/lint/post-synth/sqlch113.js +19 -0
- package/dist/lint/post-synth/sqlch114.js +26 -0
- package/dist/lint/post-synth/sqlch115.js +30 -0
- package/dist/lint/post-synth/sqlch116.js +22 -0
- package/dist/lint/post-synth/sqlch117.js +26 -0
- package/dist/lint/post-synth/sqlch118.js +27 -0
- package/dist/lint/post-synth/sqlch119.js +32 -0
- package/dist/lint/post-synth/sqlch120.js +20 -0
- package/dist/lint/post-synth/sqlch121.js +11 -0
- package/dist/lint/post-synth/sqlch122.js +11 -0
- package/dist/lint/post-synth/sqlch123.js +10 -0
- package/dist/lint/post-synth/sqlch124.js +21 -0
- package/dist/lint/post-synth/sqlch125.js +55 -0
- package/dist/lint/post-synth/sqlch126.js +62 -0
- package/dist/lint/post-synth/sqlch127.js +132 -0
- package/dist/lint/post-synth/sqlpg101.js +25 -0
- package/dist/lint/post-synth/sqlpg102.js +35 -0
- package/dist/lint/post-synth/sqlpg103.js +23 -0
- package/dist/lint/post-synth/sqlpg104.js +23 -0
- package/dist/lint/post-synth/sqlpg105.js +22 -0
- package/dist/lint/post-synth/sqlpg106.js +22 -0
- package/dist/lint/post-synth/sqlpg107.js +22 -0
- package/dist/lint/post-synth/sqlpg108.js +28 -0
- package/dist/lint/post-synth/sqlpg109.js +39 -0
- package/dist/lint/post-synth/sqlpg110.js +38 -0
- package/dist/lint/post-synth/sqlpg111.js +23 -0
- package/dist/lint/post-synth/sqlpg112.js +25 -0
- package/dist/lint/post-synth/sqlpg113.js +29 -0
- package/dist/lint/post-synth/sqlpg114.js +39 -0
- package/dist/lint/post-synth/sqlpg115.js +48 -0
- package/dist/lint/post-synth/sqlpg116.js +26 -0
- package/dist/lint/post-synth/sqlpg117.js +24 -0
- package/dist/lint/post-synth/sqlpg118.js +22 -0
- package/dist/lint/post-synth/sqlpg119.js +41 -0
- package/dist/lint/post-synth/sqlpg120.js +83 -0
- package/dist/lint/post-synth/sqlpg121.js +33 -0
- package/dist/lint/post-synth/sqlpg122.js +27 -0
- package/dist/lint/post-synth/sqlpg123.js +69 -0
- package/dist/lint/post-synth/sqlpg124.js +50 -0
- package/dist/lint/post-synth/sqlpg125.js +41 -0
- package/dist/lint/post-synth/sqlpg126.js +38 -0
- package/dist/lint/rules/index.js +12 -0
- package/dist/lint/rules/postgres-templates.js +27 -0
- package/dist/lint/rules/sqlch001.js +79 -0
- package/dist/lint/rules/sqlch002.js +124 -0
- package/dist/lint/rules/sqlch003.js +69 -0
- package/dist/lint/rules/sqlch004.js +83 -0
- package/dist/lint/rules/sqlpg001.js +68 -0
- package/dist/lint/rules/sqlpg002.js +69 -0
- package/dist/lint/rules/sqlpg003.js +58 -0
- package/dist/lint/rules/sqlpg004.js +77 -0
- package/dist/lint/rules/sqlpg005.js +86 -0
- package/dist/lint/rules/templates.js +25 -0
- package/dist/lint/rules/typed-names.js +132 -0
- package/dist/live-dialect.js +32 -0
- package/dist/lsp/catalog.js +44 -0
- package/dist/lsp/completions.js +136 -0
- package/dist/lsp/hover.js +115 -0
- package/dist/lsp/postgres-catalog.js +176 -0
- package/dist/lsp/postgres-template.js +171 -0
- package/dist/lsp/postgres.js +266 -0
- package/dist/lsp/refs.js +60 -0
- package/dist/lsp/registry.d.ts.map +1 -1
- package/dist/lsp/registry.js +19 -0
- package/dist/lsp/template.js +143 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +293 -0
- package/dist/mcp/postgres.js +117 -0
- package/dist/migration-statements.js +243 -0
- package/dist/op/activities/clickhouse-apply.js +83 -0
- package/dist/op/activities/clickhouse-emulator.js +53 -0
- package/dist/op/activities/clickhouse-rebuild.js +119 -0
- package/dist/op/activities/index.js +26 -0
- package/dist/op/activities/postgres-apply.js +126 -0
- package/dist/op/activities/postgres-emulator.js +70 -0
- package/dist/op/activities/postgres-migration.js +156 -0
- package/dist/op/activities/sql-exec.js +57 -0
- package/dist/op/activity-contracts.js +103 -0
- package/dist/package-cli.js +14 -0
- package/dist/plugin.js +259 -0
- package/dist/postgres/access/acl.js +344 -0
- package/dist/postgres/access/changes.js +30 -0
- package/dist/postgres/access/import.js +61 -0
- package/dist/postgres/access/live.js +96 -0
- package/dist/postgres/apply/apply.js +557 -0
- package/dist/postgres/apply/statements.js +882 -0
- package/dist/postgres/catalog-types.js +18 -0
- package/dist/postgres/container.js +58 -0
- package/dist/postgres/entities.js +1390 -0
- package/dist/postgres/entity-types.js +28 -0
- package/dist/postgres/import/generator.js +283 -0
- package/dist/postgres/import/ir.js +53 -0
- package/dist/postgres/import/live-export.js +83 -0
- package/dist/postgres/keywords.js +32 -0
- package/dist/postgres/live/bind.js +130 -0
- package/dist/postgres/live/catalog-kinds.js +301 -0
- package/dist/postgres/live/catalog.js +644 -0
- package/dist/postgres/live/client.js +151 -0
- package/dist/postgres/live/describe-resources.js +135 -0
- package/dist/postgres/live/undeclarable.js +111 -0
- package/dist/postgres/migrate/backfill.js +170 -0
- package/dist/postgres/migrate/batches.js +122 -0
- package/dist/postgres/migrate/carry.js +518 -0
- package/dist/postgres/migrate/handoff.js +59 -0
- package/dist/postgres/migrate/names.js +61 -0
- package/dist/postgres/migrate/observe.js +360 -0
- package/dist/postgres/migrate/op.js +178 -0
- package/dist/postgres/migrate/receipts.js +117 -0
- package/dist/postgres/migrate/replication.js +106 -0
- package/dist/postgres/migrate/steps.js +514 -0
- package/dist/postgres/overlays/columns.js +31 -0
- package/dist/postgres/overlays/constraints.js +52 -0
- package/dist/postgres/overlays/extension-objects.js +88 -0
- package/dist/postgres/overlays/indexes.js +25 -0
- package/dist/postgres/overlays/kinds.js +2 -0
- package/dist/postgres/overlays/partitioning.js +23 -0
- package/dist/postgres/overlays/relations.js +25 -0
- package/dist/postgres/overlays/storage.js +65 -0
- package/dist/postgres/overlays/type-ddl.js +36 -0
- package/dist/postgres/overlays/types.js +74 -0
- package/dist/postgres/parser.js +1824 -0
- package/dist/postgres/plan/commands.js +137 -0
- package/dist/postgres/plan/deep.js +324 -0
- package/dist/postgres/plan/diff.js +499 -0
- package/dist/postgres/plan/disruption.js +64 -0
- package/dist/postgres/plan/normalize.js +889 -0
- package/dist/postgres/plan/report.js +21 -0
- package/dist/postgres/plan/rules.js +115 -0
- package/dist/postgres/plan/schema.js +105 -0
- package/dist/postgres/plan/server-normalize.js +166 -0
- package/dist/postgres/providers/aurora.js +44 -0
- package/dist/postgres/providers/azure.js +53 -0
- package/dist/postgres/providers/cloud-sql.js +41 -0
- package/dist/postgres/providers/index.js +73 -0
- package/dist/postgres/providers/neon.js +44 -0
- package/dist/postgres/providers/rds.js +47 -0
- package/dist/postgres/providers/supabase.js +45 -0
- package/dist/postgres/providers/types.js +21 -0
- package/dist/postgres/row-types.js +107 -0
- package/dist/postgres/testing/fake-client.js +34 -0
- package/dist/postgres/testing/migration.js +48 -0
- package/dist/postgres/testing/roundtrip-schema.js +64 -0
- package/dist/postgres/testing/server.js +47 -0
- package/dist/postgres/testing/writable-server.js +160 -0
- package/dist/postgres/tokens.js +41 -0
- package/dist/postgres.js +15 -0
- package/dist/read/clickhouse.js +82 -0
- package/dist/read/objects.js +44 -0
- package/dist/read/postgres.js +104 -0
- package/dist/read.js +52 -0
- package/dist/receipts.js +207 -0
- package/dist/serializer.js +172 -0
- package/dist/skill-defs.js +93 -0
- package/dist/spec/catalog.js +191 -0
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/fetch.js +90 -0
- package/dist/spec/pin.js +42 -0
- package/dist/spec/postgres-catalog.js +171 -0
- package/dist/spec/postgres-fetch.js +91 -0
- package/dist/spec/postgres-pin.js +82 -0
- package/dist/sql-files.js +206 -0
- package/dist/sql-types-command.js +97 -0
- package/dist/sql-types.js +323 -0
- package/dist/token-source.js +156 -0
- package/dist/validate-cli.js +5 -0
- package/dist/validate.js +151 -0
- package/dist/workspace-outputs.js +191 -0
- package/package.json +7 -7
- package/src/codegen/package.ts +2 -2
- package/src/lint/rules/typescript-7.test.ts +2 -1
- package/src/lsp/catalog.ts +2 -2
- package/src/lsp/postgres-catalog.ts +2 -2
- package/src/lsp/registry.ts +2 -1
- package/src/mcp/index.ts +4 -2
- package/src/spec/fetch.ts +2 -2
- package/src/spec/postgres-fetch.ts +2 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rebuild migration's view of the server (#3198): which table is being
|
|
3
|
+
* rebuilt, how far the rebuild has got, and what it will do.
|
|
4
|
+
*
|
|
5
|
+
* Every step of the Op re-observes from here rather than trusting what an
|
|
6
|
+
* earlier run or step said, so the Op converges: run it again after a crash,
|
|
7
|
+
* a gate or an approval, and each step finds its work done or still to do.
|
|
8
|
+
* The progress is read off the server's own objects:
|
|
9
|
+
*
|
|
10
|
+
* - the table being rebuilt, `db.t`, as declared and as the server has it;
|
|
11
|
+
* - `db.t__chant_new`, the new table being filled (`role=new` in its comment);
|
|
12
|
+
* - `db.t__chant_dual`, the dual-write materialized view (`role=dual`);
|
|
13
|
+
* - `db.t__chant_old`, the old table kept after the swap (`role=old`, with
|
|
14
|
+
* `retain-until`).
|
|
15
|
+
*
|
|
16
|
+
* Each working object carries chant's ownership trailer plus
|
|
17
|
+
* `rebuild=<db.t>` and its role (`../ownership.ts`), so it is chant's and
|
|
18
|
+
* this rebuild's, and schema reads leave it out.
|
|
19
|
+
*
|
|
20
|
+
* States:
|
|
21
|
+
*
|
|
22
|
+
* - `rebuild`: the declaration differs from the server by at least one
|
|
23
|
+
* rebuild-class change, and the old table has not been swapped out yet.
|
|
24
|
+
* - `swapped`: the swap ran; the old table is retained until its date.
|
|
25
|
+
* - `done`: the server holds the declaration and nothing of the rebuild is
|
|
26
|
+
* left.
|
|
27
|
+
*
|
|
28
|
+
* Anything else is refused with a {@link RebuildRefusal}: a database that is
|
|
29
|
+
* neither Atomic nor Replicated, a table that is not there, a change that is
|
|
30
|
+
* not a rebuild, a working object chant did not make.
|
|
31
|
+
*
|
|
32
|
+
* A Replicated database (#3249) is Atomic on every replica, with its DDL run
|
|
33
|
+
* on each through Keeper, so the same EXCHANGE swaps the table everywhere.
|
|
34
|
+
* Its tables have to be `Replicated*MergeTree` on both sides of the rebuild:
|
|
35
|
+
* a plain MergeTree there keeps a different set of rows on each replica, and
|
|
36
|
+
* a copy run on one replica would fill the new table with that replica's rows
|
|
37
|
+
* alone.
|
|
38
|
+
*/
|
|
39
|
+
import { computePlanDigest } from "@intentius/chant/op";
|
|
40
|
+
import { clickhouseQuery } from "../http.js";
|
|
41
|
+
import { canonicalObject } from "../plan/normalize.js";
|
|
42
|
+
import { diffSchemas } from "../plan/diff.js";
|
|
43
|
+
import { dropFormattingOnly } from "../plan/server-format.js";
|
|
44
|
+
import { CLASSIFIER_RULES } from "../plan/rules.js";
|
|
45
|
+
import { CLICKHOUSE_ENTITY_TYPES } from "../entities.js";
|
|
46
|
+
import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stripMarkerFromStatement } from "../ownership.js";
|
|
47
|
+
import { ident, isRebuild, qualifiedIdent, sqlString } from "../apply/statements.js";
|
|
48
|
+
/** A refusal: the rebuild cannot start, or cannot go on, for a reason a person has to act on. */
|
|
49
|
+
export class RebuildRefusal extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "RebuildRefusal";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function rebuildNames(database, name) {
|
|
56
|
+
const newName = `${name}__chant_new`;
|
|
57
|
+
const dualName = `${name}__chant_dual`;
|
|
58
|
+
const oldName = `${name}__chant_old`;
|
|
59
|
+
return {
|
|
60
|
+
key: `${database}.${name}`,
|
|
61
|
+
database,
|
|
62
|
+
name,
|
|
63
|
+
newName,
|
|
64
|
+
dualName,
|
|
65
|
+
oldName,
|
|
66
|
+
table: qualifiedIdent(database, name),
|
|
67
|
+
newTable: qualifiedIdent(database, newName),
|
|
68
|
+
dualView: qualifiedIdent(database, dualName),
|
|
69
|
+
oldTable: qualifiedIdent(database, oldName),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const qualifiedKey = (o) => `${o.canonical.database ?? "default"}.${o.canonical.name}`;
|
|
73
|
+
/** The declared table with the given key (`db.t`), or a refusal naming what is declared. */
|
|
74
|
+
export function declaredTable(declared, key) {
|
|
75
|
+
const found = declared.find((o) => o.key === key || o.exportName === key);
|
|
76
|
+
if (!found) {
|
|
77
|
+
throw new RebuildRefusal(`${key} is not declared in the build. Declared tables: ${declared.filter((o) => o.type === CLICKHOUSE_ENTITY_TYPES.table).map(qualifiedKey).join(", ") || "none"}`);
|
|
78
|
+
}
|
|
79
|
+
if (found.type !== CLICKHOUSE_ENTITY_TYPES.table)
|
|
80
|
+
throw new RebuildRefusal(`${key} is a ${found.type}, and only a table is rebuilt`);
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The columns the backfill copies: every declared column the old table has
|
|
85
|
+
* under its name or its `-- previously:` name, except a `MATERIALIZED`,
|
|
86
|
+
* `ALIAS` or `EPHEMERAL` one, which the new table computes itself.
|
|
87
|
+
*/
|
|
88
|
+
export function copiedColumns(declared, live) {
|
|
89
|
+
const liveNames = new Set(live.columns.map((c) => c.name));
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const c of declared.columns) {
|
|
92
|
+
if (c.defaultKind && c.defaultKind !== "DEFAULT")
|
|
93
|
+
continue;
|
|
94
|
+
const source = liveNames.has(c.name) ? c.name : c.previously && liveNames.has(c.previously) ? c.previously : undefined;
|
|
95
|
+
if (source)
|
|
96
|
+
out.push({ name: c.name, source });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** A canonical definition without what names it, for comparing and digesting by role. */
|
|
101
|
+
export function anonymous(c) {
|
|
102
|
+
const out = {
|
|
103
|
+
...c,
|
|
104
|
+
columns: c.columns.map((col) => Object.fromEntries(Object.entries(col).filter(([k]) => k !== "text"))),
|
|
105
|
+
};
|
|
106
|
+
for (const k of ["name", "database", "previously", "comment"])
|
|
107
|
+
delete out[k];
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
async function serverObjects(target, names) {
|
|
111
|
+
const rows = await clickhouseQuery(target.endpoint, `SELECT name, toString(uuid) AS uuid, engine, comment, partition_key FROM system.tables ` +
|
|
112
|
+
`WHERE database = ${sqlString(names.database)} AND name IN (${[names.name, names.newName, names.dualName, names.oldName].map(sqlString).join(", ")})`);
|
|
113
|
+
return new Map(rows.map((r) => {
|
|
114
|
+
const pairs = readTrailerPairs(r.comment);
|
|
115
|
+
return [r.name, { name: r.name, uuid: r.uuid, engine: r.engine, comment: r.comment, partitionKey: r.partition_key, ...(pairs?.has(REBUILD_TRAILER_KEY) ? { pairs } : {}) }];
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
/** The definition `SHOW CREATE` prints, less chant's trailer, canonical. */
|
|
119
|
+
export async function showCreate(target, qualified) {
|
|
120
|
+
const [row] = await clickhouseQuery(target.endpoint, `SHOW CREATE TABLE ${qualified}`);
|
|
121
|
+
return canonicalObject(stripMarkerFromStatement(row?.statement ?? ""), target.defaultDatabase);
|
|
122
|
+
}
|
|
123
|
+
/** The classified changes from one definition to another, compared by role, with server formatting differences dropped. */
|
|
124
|
+
export async function changesBetween(target, key, from, to) {
|
|
125
|
+
const diff = diffSchemas([{ key, canonical: from }], [{ key, canonical: to }]);
|
|
126
|
+
return dropFormattingOnly(target.endpoint, diff.changes);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A working object is this rebuild's only when it carries this project's
|
|
130
|
+
* marker, names this table and has the role its name says. One that has
|
|
131
|
+
* the name and not the marker is somebody else's, and the rebuild stops
|
|
132
|
+
* rather than drop or fill it.
|
|
133
|
+
*/
|
|
134
|
+
function ownWorkingObject(o, names, role, marker) {
|
|
135
|
+
if (!o)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (!carriesMarker(o.comment, marker) || o.pairs?.get(REBUILD_TRAILER_KEY) !== names.key || o.pairs.get("role") !== role) {
|
|
138
|
+
throw new RebuildRefusal(`${names.database}.${o.name} exists and is not this rebuild's ${role} table (its comment carries no rebuild=${names.key} role=${role} marker for this project). ` +
|
|
139
|
+
`The rebuild uses that name, so it stops rather than touch it; rename or drop it by hand.`);
|
|
140
|
+
}
|
|
141
|
+
return o;
|
|
142
|
+
}
|
|
143
|
+
/** Observe the rebuild of the declared table on the server. */
|
|
144
|
+
export async function observeRebuild(target, declared, marker) {
|
|
145
|
+
const database = declared.canonical.database ?? target.defaultDatabase;
|
|
146
|
+
const names = rebuildNames(database, declared.canonical.name);
|
|
147
|
+
const [db] = await clickhouseQuery(target.endpoint, `SELECT engine FROM system.databases WHERE name = ${sqlString(database)}`);
|
|
148
|
+
if (!db)
|
|
149
|
+
throw new RebuildRefusal(`database ${database} is not on the server, so there is no ${names.key} to rebuild; the applier creates it`);
|
|
150
|
+
if (db.engine !== "Atomic" && db.engine !== "Replicated") {
|
|
151
|
+
throw new RebuildRefusal(`database ${database} uses the ${db.engine} engine. The swap is EXCHANGE TABLES, which ClickHouse supports only in an Atomic database ` +
|
|
152
|
+
`(https://clickhouse.com/docs/sql-reference/statements/exchange) and the Replicated one built on it, and two RENAMEs in its place would leave a moment with no ${names.key} for writes to land in. ` +
|
|
153
|
+
`Convert the database to Atomic first (https://clickhouse.com/docs/engines/database-engines/atomic), then run the rebuild.`);
|
|
154
|
+
}
|
|
155
|
+
const replicated = db.engine === "Replicated";
|
|
156
|
+
const objects = await serverObjects(target, names);
|
|
157
|
+
const liveRow = objects.get(names.name);
|
|
158
|
+
if (!liveRow)
|
|
159
|
+
throw new RebuildRefusal(`${names.key} is not on the server, so there is nothing to rebuild; the applier creates it`);
|
|
160
|
+
// Between EXCHANGE and the end of the swap, the table's own name holds the
|
|
161
|
+
// new table, still carrying role=new, and the new table's name holds the
|
|
162
|
+
// old table until it is renamed; the swap step finishes that.
|
|
163
|
+
const exchanged = liveRow.pairs?.get(REBUILD_TRAILER_KEY) === names.key && liveRow.pairs.get("role") === "new";
|
|
164
|
+
const newTable = exchanged ? undefined : ownWorkingObject(objects.get(names.newName), names, "new", marker);
|
|
165
|
+
const exchangedOld = exchanged ? objects.get(names.newName) : undefined;
|
|
166
|
+
const dual = ownWorkingObject(objects.get(names.dualName), names, "dual", marker);
|
|
167
|
+
const old = ownWorkingObject(objects.get(names.oldName), names, "old", marker);
|
|
168
|
+
const liveCanonical = await showCreate(target, names.table);
|
|
169
|
+
const changes = (await changesBetween(target, names.key, { ...liveCanonical, name: declared.canonical.name }, declared.canonical)).filter((c) => !(c.field === "comment" && c.rule === "SQLCH203"));
|
|
170
|
+
const copied = copiedColumns(declared.canonical, liveCanonical);
|
|
171
|
+
let state;
|
|
172
|
+
if (old || exchanged) {
|
|
173
|
+
if (changes.some(isRebuild) && !exchanged) {
|
|
174
|
+
throw new RebuildRefusal(`${names.key} differs from its declaration by a rebuild again, and the old table of the last rebuild is still kept as ${names.database}.${names.oldName}. ` +
|
|
175
|
+
`Drop it (the rebuild's drop phase, or DROP TABLE) before rebuilding again.`);
|
|
176
|
+
}
|
|
177
|
+
state = "swapped";
|
|
178
|
+
}
|
|
179
|
+
else if (changes.some(isRebuild)) {
|
|
180
|
+
state = "rebuild";
|
|
181
|
+
}
|
|
182
|
+
else if (changes.length === 0) {
|
|
183
|
+
state = "done";
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
throw new RebuildRefusal(`${names.key} has no change that needs a rebuild: ${changes.map((c) => `${c.rule} ${CLASSIFIER_RULES[c.rule].title} on ${c.field}`).join("; ")}. ` +
|
|
187
|
+
`The applier makes these in place (ApplyOp with target "clickhouse").`);
|
|
188
|
+
}
|
|
189
|
+
if (state === "rebuild") {
|
|
190
|
+
if (replicated)
|
|
191
|
+
refuseUnreplicated(names, liveRow.engine, declared.canonical.engineName);
|
|
192
|
+
refuseSharedKeeperPath(names, liveCanonical.engine, declared.canonical.engine);
|
|
193
|
+
}
|
|
194
|
+
if (state === "done" && (newTable || dual)) {
|
|
195
|
+
throw new RebuildRefusal(`${names.key} already holds its declaration, and ${[newTable, dual].filter(Boolean).map((o) => `${names.database}.${o.name}`).join(" and ")} from an unfinished rebuild is still there. ` +
|
|
196
|
+
`The run fails so onFailure drops it.`);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
state,
|
|
200
|
+
names,
|
|
201
|
+
declared,
|
|
202
|
+
live: liveRow,
|
|
203
|
+
liveCanonical,
|
|
204
|
+
changes,
|
|
205
|
+
...(newTable ? { newTable } : {}),
|
|
206
|
+
...(dual ? { dual } : {}),
|
|
207
|
+
...(old ? { old } : {}),
|
|
208
|
+
...(exchangedOld ? { exchangedOld } : {}),
|
|
209
|
+
exchanged,
|
|
210
|
+
copied,
|
|
211
|
+
replicated,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const REPLICATED_MERGE_TREE = /^Replicated.*MergeTree$/;
|
|
215
|
+
/** In a Replicated database, both the table and its declaration have to replicate their rows. */
|
|
216
|
+
function refuseUnreplicated(names, live, declared) {
|
|
217
|
+
const side = !REPLICATED_MERGE_TREE.test(live) ? `the table is ${live}` : declared !== undefined && !REPLICATED_MERGE_TREE.test(declared) ? `the declaration makes it ${declared}` : undefined;
|
|
218
|
+
if (!side)
|
|
219
|
+
return;
|
|
220
|
+
throw new RebuildRefusal(`${names.key} is in a Replicated database and ${side}, which keeps a separate set of rows on each replica. ` +
|
|
221
|
+
`The backfill runs on one replica and would copy that replica's rows alone, while the EXCHANGE runs on every replica, so the others would swap in a table without their rows. ` +
|
|
222
|
+
`Rebuild it from and to a Replicated*MergeTree engine (ENGINE = ReplicatedMergeTree, with no arguments in a Replicated database), or move it to an Atomic database first.`);
|
|
223
|
+
}
|
|
224
|
+
/** The Keeper path a `Replicated*MergeTree` engine names, when it names one. */
|
|
225
|
+
function keeperPath(engine) {
|
|
226
|
+
const m = /^Replicated\w*MergeTree\(\s*('(?:[^'\\]|\\.)*')/.exec(engine ?? "");
|
|
227
|
+
return m?.[1];
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The new table is made from the declaration under another name. With an
|
|
231
|
+
* explicit Keeper path that is the old table's own and has no `{uuid}` in
|
|
232
|
+
* it, both tables would be the same replicated table to Keeper, and the
|
|
233
|
+
* create would fail on a replica that is already there or, worse, share parts.
|
|
234
|
+
*/
|
|
235
|
+
function refuseSharedKeeperPath(names, live, declared) {
|
|
236
|
+
const path = keeperPath(declared);
|
|
237
|
+
if (!path || path.includes("{uuid}") || path !== keeperPath(live))
|
|
238
|
+
return;
|
|
239
|
+
throw new RebuildRefusal(`${names.key} names its Keeper path explicitly, ${path}, and the new table made from the declaration would get the same path as the old table. ` +
|
|
240
|
+
`Put {uuid} in the path (or leave the engine's arguments out, which defaults to '/clickhouse/tables/{uuid}/{shard}'), so each table has its own.`);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* What the rebuild will do, independent of how far it has got: the changes,
|
|
244
|
+
* the definitions on both sides, the columns copied and how writes are kept
|
|
245
|
+
* up. The Plan phase publishes its digest; the gate binds a digest of this
|
|
246
|
+
* plus the verification.
|
|
247
|
+
*/
|
|
248
|
+
export function rebuildPlanSubject(o, dualWrite) {
|
|
249
|
+
return {
|
|
250
|
+
table: o.names.key,
|
|
251
|
+
from: anonymous(o.liveCanonical),
|
|
252
|
+
to: anonymous(o.declared.canonical),
|
|
253
|
+
changes: o.changes.map((c) => ({ rule: c.rule, field: c.field, ...(c.before !== undefined ? { before: c.before } : {}), ...(c.after !== undefined ? { after: c.after } : {}) })),
|
|
254
|
+
copied: o.copied,
|
|
255
|
+
dualWrite,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export const rebuildPlanDigest = (o, dualWrite) => computePlanDigest("clickhouse-rebuild", rebuildPlanSubject(o, dualWrite));
|
|
259
|
+
/** `ident` re-exported for the steps, which quote column names. */
|
|
260
|
+
export { ident };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ClickHouseRebuildOp` (#3198): the change ClickHouse cannot make with
|
|
3
|
+
* `ALTER` (a sorting key, a primary key, a partition key, an engine, a key
|
|
4
|
+
* column's type), run as a gated composite Op.
|
|
5
|
+
*
|
|
6
|
+
* export const { op } = ClickHouseRebuildOp({
|
|
7
|
+
* name: "rebuild-shop-events",
|
|
8
|
+
* env: "prod",
|
|
9
|
+
* table: "shop.events",
|
|
10
|
+
* dualWrite: { mode: "materialized-view", cutoverColumn: "ts" },
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* | Phase | Step |
|
|
14
|
+
* |---|---|
|
|
15
|
+
* | Build | `chant build` (`build: false` skips it) |
|
|
16
|
+
* | Plan | classify the table's change against the server; refuse what is not a rebuild |
|
|
17
|
+
* | Create | the new table, `t__chant_new`, from the declaration |
|
|
18
|
+
* | Dual write | a materialized view from the old table to the new one, or a gate for the app to stop writing |
|
|
19
|
+
* | Backfill | `INSERT ... SELECT` per partition, each with a receipt |
|
|
20
|
+
* | Verify | row counts and checksums per partition |
|
|
21
|
+
* | Approve | a gate bound to the plan and the verification |
|
|
22
|
+
* | Swap | `EXCHANGE TABLES`, then recreate the materialized views that read the table |
|
|
23
|
+
* | Retain | the old table, `t__chant_old`, until `retain` has passed |
|
|
24
|
+
* | Approve drop | a gate bound to that old table |
|
|
25
|
+
* | Drop | the old table, once its date has passed |
|
|
26
|
+
* | onFailure | drop the new table and the dual-write view |
|
|
27
|
+
*
|
|
28
|
+
* `gates: "outer"` (#3658) leaves out both approval gates and the Drop phase,
|
|
29
|
+
* for a caller that runs the Op under an approval of its own: the run goes
|
|
30
|
+
* from Plan to Retain, verification and the swap's own comparison included.
|
|
31
|
+
* `onFailure: "keep"` leaves out the onFailure, so a failed run's next run
|
|
32
|
+
* resumes the backfill from its receipts instead of starting again.
|
|
33
|
+
*
|
|
34
|
+
* Run it until it is done: each run goes as far as the next gate. Every step
|
|
35
|
+
* re-reads the server, so a run after a gate, a crash or an approval picks up
|
|
36
|
+
* where the last stopped. A run killed during the backfill (Ctrl-C, a lost
|
|
37
|
+
* machine) is not a failure, runs no onFailure, and the next run resumes the
|
|
38
|
+
* backfill from its receipts; a step that fails runs onFailure, and the next
|
|
39
|
+
* run starts again from a new table.
|
|
40
|
+
*/
|
|
41
|
+
import { Op, activity, gate, phase, stepOutput } from "@intentius/chant/op";
|
|
42
|
+
export function ClickHouseRebuildOp(config) {
|
|
43
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$|^[A-Za-z_][A-Za-z0-9_]*$/.test(config.table)) {
|
|
44
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": table must be "database.name" or an export name, got ${JSON.stringify(config.table)}`);
|
|
45
|
+
}
|
|
46
|
+
if (config.dualWrite?.mode !== "materialized-view" && config.dualWrite?.mode !== "app") {
|
|
47
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": dualWrite.mode must be "materialized-view" or "app"`);
|
|
48
|
+
}
|
|
49
|
+
if (config.dualWrite.mode === "materialized-view" && !config.dualWrite.cutoverColumn) {
|
|
50
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": materialized-view mode needs cutoverColumn, the time column rows are cut over on`);
|
|
51
|
+
}
|
|
52
|
+
if (config.gates !== undefined && config.gates !== "own" && config.gates !== "outer") {
|
|
53
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": gates must be "own" or "outer"`);
|
|
54
|
+
}
|
|
55
|
+
if (config.onFailure !== undefined && config.onFailure !== "drop" && config.onFailure !== "keep") {
|
|
56
|
+
throw new Error(`ClickHouseRebuildOp "${config.name}": onFailure must be "drop" or "keep"`);
|
|
57
|
+
}
|
|
58
|
+
const outer = config.gates === "outer";
|
|
59
|
+
const keep = config.onFailure === "keep";
|
|
60
|
+
const args = {
|
|
61
|
+
table: config.table,
|
|
62
|
+
buildPath: config.output ?? "dist/schema.json",
|
|
63
|
+
environment: config.env,
|
|
64
|
+
dualWrite: config.dualWrite,
|
|
65
|
+
...(config.retain ? { retain: config.retain } : {}),
|
|
66
|
+
...(config.mutationTimeout ? { mutationTimeout: config.mutationTimeout } : {}),
|
|
67
|
+
...(config.replicaTimeout ? { replicaTimeout: config.replicaTimeout } : {}),
|
|
68
|
+
...(config.cutoverTimeout ? { cutoverTimeout: config.cutoverTimeout } : {}),
|
|
69
|
+
...(config.stack ? { stack: config.stack } : {}),
|
|
70
|
+
...(config.ownershipEnv ? { ownershipEnv: config.ownershipEnv } : {}),
|
|
71
|
+
...(config.path && config.path !== "." ? { cwd: config.path } : {}),
|
|
72
|
+
...(keep ? { keepOnFailure: true } : {}),
|
|
73
|
+
};
|
|
74
|
+
const a = args;
|
|
75
|
+
const swapGate = config.gate?.gate ?? `approve-${config.name}`;
|
|
76
|
+
const dropGate = config.dropGate?.gate ?? `approve-${config.name}-drop`;
|
|
77
|
+
const policy = config.gate?.approval?.policy;
|
|
78
|
+
const step = (fn, extra = {}) => ({ ...activity(fn, a), ...extra });
|
|
79
|
+
// The swap waits for this gate, unless the caller holds the approval (`gates: "outer"`).
|
|
80
|
+
const approve = phase("Approve", [
|
|
81
|
+
gate(swapGate, {
|
|
82
|
+
...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
|
|
83
|
+
plan: stepOutput("verify", "planDigest"),
|
|
84
|
+
description: config.gate?.description ??
|
|
85
|
+
`Approve swapping the rebuilt ${config.table} in on ${config.env}: the run record's Verification line has the counts this approval binds`,
|
|
86
|
+
...(config.gate?.approval
|
|
87
|
+
? {
|
|
88
|
+
approval: {
|
|
89
|
+
...config.gate.approval,
|
|
90
|
+
...(policy
|
|
91
|
+
? {
|
|
92
|
+
context: {
|
|
93
|
+
verifiedPartitions: stepOutput("verify", "partitions"),
|
|
94
|
+
verifiedRows: stepOutput("verify", "rows"),
|
|
95
|
+
...config.gate.approval.context,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
: {}),
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
const phases = [
|
|
105
|
+
...(config.build === false ? [] : [phase("Build", [activity("chantBuild", { path: config.path ?? "." })])]),
|
|
106
|
+
phase("Plan", [step("clickhouseRebuildPlan", { id: "plan", outcomeAttribute: { name: "RebuildState", from: "state" } })]),
|
|
107
|
+
phase("Create", [step("clickhouseRebuildCreate")]),
|
|
108
|
+
phase("Dual write", [
|
|
109
|
+
config.dualWrite.mode === "app"
|
|
110
|
+
? gate(config.writesGate?.gate ?? `${config.name}-writes-stopped`, {
|
|
111
|
+
...(config.writesGate?.timeout ? { timeout: config.writesGate.timeout } : {}),
|
|
112
|
+
description: config.writesGate?.description ??
|
|
113
|
+
`Stop the application's writes to ${config.table} (pause or buffer them) and approve; they resume against ${config.table} after the swap`,
|
|
114
|
+
})
|
|
115
|
+
: step("clickhouseRebuildDualWrite", { outcomeAttribute: { name: "Cutover", from: "cutover" } }),
|
|
116
|
+
]),
|
|
117
|
+
phase("Backfill", [step("clickhouseRebuildBackfill", { profile: "fastIdempotent", timeout: config.backfillTimeout ?? "6h", outcomeAttribute: [{ name: "Copied", from: "copied" }, { name: "Skipped", from: "skipped" }] })]),
|
|
118
|
+
phase("Verify", [
|
|
119
|
+
step("clickhouseRebuildVerify", {
|
|
120
|
+
id: "verify",
|
|
121
|
+
outcomeAttribute: [
|
|
122
|
+
{ name: "Verification", from: "summary" },
|
|
123
|
+
{ name: "VerifiedPartitions", from: "partitions" },
|
|
124
|
+
{ name: "VerifiedRows", from: "rows" },
|
|
125
|
+
],
|
|
126
|
+
}),
|
|
127
|
+
]),
|
|
128
|
+
...(outer ? [] : [approve]),
|
|
129
|
+
phase("Swap", [step("clickhouseRebuildSwap", { profile: "longInfra", outcomeAttribute: { name: "Dependents", from: "dependents" } })]),
|
|
130
|
+
phase("Retain", [step("clickhouseRebuildRetain", { id: "retain", outcomeAttribute: { name: "RetainUntil", from: "retainUntil" } })]),
|
|
131
|
+
...(outer
|
|
132
|
+
? []
|
|
133
|
+
: [
|
|
134
|
+
phase("Approve drop", [
|
|
135
|
+
gate(dropGate, {
|
|
136
|
+
...(config.dropGate?.timeout ? { timeout: config.dropGate.timeout } : {}),
|
|
137
|
+
plan: stepOutput("retain", "dropDigest"),
|
|
138
|
+
description: config.dropGate?.description ?? `Approve dropping the old ${config.table} once its retention has passed`,
|
|
139
|
+
}),
|
|
140
|
+
]),
|
|
141
|
+
phase("Drop", [step("clickhouseRebuildDrop", { outcomeAttribute: { name: "Dropped", from: "dropped" } })]),
|
|
142
|
+
]),
|
|
143
|
+
];
|
|
144
|
+
const op = Op({
|
|
145
|
+
name: config.name,
|
|
146
|
+
overview: `Rebuild ${config.table} on ${config.env}: new table, ${config.dualWrite.mode === "app" ? "writes stopped" : "dual write"}, backfill, verify, ${outer ? "swap under the caller's approval" : "gated swap"}`,
|
|
147
|
+
labels: { Rebuild: "true", Env: config.env, Table: config.table },
|
|
148
|
+
phases,
|
|
149
|
+
...(keep ? {} : { onFailure: [phase("Compensate", [step("clickhouseRebuildCompensate")])] }),
|
|
150
|
+
});
|
|
151
|
+
return { op };
|
|
152
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Partitions, as the backfill copies them and the verification counts them.
|
|
3
|
+
*
|
|
4
|
+
* The unit of work is a partition of the old table. Its rows can land in
|
|
5
|
+
* other partitions of the new table when the rebuild changes the partition
|
|
6
|
+
* key, so "the rows of old partition p" is found on the new table by
|
|
7
|
+
* computing the old partition key over the new table's columns:
|
|
8
|
+
* `partitionID(<old key>)`, which the server computes the same way it named
|
|
9
|
+
* the old partition (`_partition_id`). When the key is unchanged that is the
|
|
10
|
+
* new table's own `_partition_id`; when the old table has no partition key
|
|
11
|
+
* every row is in partition `all`.
|
|
12
|
+
*/
|
|
13
|
+
import { clickhouseQuery } from "../http.js";
|
|
14
|
+
import { ident, sqlString } from "../apply/statements.js";
|
|
15
|
+
/** The old table's active partitions, by id. */
|
|
16
|
+
export async function sourcePartitions(target, names) {
|
|
17
|
+
const rows = await clickhouseQuery(target.endpoint, `SELECT partition_id FROM system.parts WHERE database = ${sqlString(names.database)} AND table = ${sqlString(names.name)} AND active ` +
|
|
18
|
+
`GROUP BY partition_id ORDER BY partition_id`);
|
|
19
|
+
return rows.map((r) => r.partition_id);
|
|
20
|
+
}
|
|
21
|
+
/** The old table's active partitions on each shard of a cluster (#3663), through this server. */
|
|
22
|
+
export async function shardPartitions(run, sharding, database, table) {
|
|
23
|
+
const rows = await clickhouseQuery(run.target.endpoint, `SELECT _shard_num AS shard, partition_id FROM cluster(${sqlString(sharding.cluster)}, system.parts) ` +
|
|
24
|
+
`WHERE database = ${sqlString(database)} AND table = ${sqlString(table)} AND active GROUP BY shard, partition_id ORDER BY shard, partition_id`);
|
|
25
|
+
return rows.map((r) => ({ shard: Number(r.shard), partition: r.partition_id }));
|
|
26
|
+
}
|
|
27
|
+
/** Rename the columns an expression names, old name to new, leaving function names and qualified names alone. */
|
|
28
|
+
export function renameColumns(expr, copied) {
|
|
29
|
+
let out = expr;
|
|
30
|
+
for (const c of copied) {
|
|
31
|
+
if (c.source === c.name)
|
|
32
|
+
continue;
|
|
33
|
+
const re = new RegExp(`(?<![\\w.\`])\`?${c.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\`?(?![\\w\`(])`, "g");
|
|
34
|
+
out = out.replace(re, ident(c.name));
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** A tuple key's elements as arguments (`(a, b)` is `a, b`); any other key as itself. */
|
|
39
|
+
function asArguments(key) {
|
|
40
|
+
const k = key.trim();
|
|
41
|
+
if (!k.startsWith("(") || !k.endsWith(")"))
|
|
42
|
+
return k;
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (let i = 0; i < k.length; i++) {
|
|
45
|
+
if (k[i] === "(")
|
|
46
|
+
depth++;
|
|
47
|
+
else if (k[i] === ")")
|
|
48
|
+
depth--;
|
|
49
|
+
if (depth === 0 && i < k.length - 1)
|
|
50
|
+
return k; // `(a) + (b)`: not one outer pair
|
|
51
|
+
}
|
|
52
|
+
return k.slice(1, -1);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The expression that gives, for a row of the new table, the id of the old
|
|
56
|
+
* table's partition the row came from.
|
|
57
|
+
*/
|
|
58
|
+
export function sourcePartitionExpression(oldKey, newKey, copied) {
|
|
59
|
+
if (oldKey.trim() === "")
|
|
60
|
+
return "'all'";
|
|
61
|
+
const renamed = renameColumns(oldKey, copied);
|
|
62
|
+
if (renamed === oldKey && oldKey.trim() === newKey.trim())
|
|
63
|
+
return "_partition_id";
|
|
64
|
+
return `partitionID(${asArguments(renamed)})`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect receipts kept in ClickHouse (#3198): where the rebuild migration's
|
|
3
|
+
* backfill records which partitions it has copied.
|
|
4
|
+
*
|
|
5
|
+
* A receipt is the proof that one effect ran, kept where the effect happened
|
|
6
|
+
* (concepts/effect-receipts.mdx). The backfill's effect is "this partition of
|
|
7
|
+
* the old table is in the new table", and both tables are on the server, so
|
|
8
|
+
* the receipt is a row on the same server:
|
|
9
|
+
*
|
|
10
|
+
* SELECT * FROM chant_receipts.receipts
|
|
11
|
+
*
|
|
12
|
+
* Why not git (core's lifecycle receipt store, `chant/lifecycle`): the
|
|
13
|
+
* receipt has to die with the data it witnesses. The rebuild's `onFailure`
|
|
14
|
+
* drops the new table; a receipt kept in git would go on saying partitions
|
|
15
|
+
* were copied into a table that no longer exists, and a resumed backfill
|
|
16
|
+
* would skip them. Here every receipt's expectation is bound to the new
|
|
17
|
+
* table's UUID, so a new table made again reads every old receipt as stale,
|
|
18
|
+
* and the receipts are read and written over the connection the copy already
|
|
19
|
+
* uses, with no commit and push per partition. Readable with nothing but a
|
|
20
|
+
* ClickHouse client, which is the walk-away property the other rows have.
|
|
21
|
+
*
|
|
22
|
+
* The store implements core's `ReceiptStore` seam
|
|
23
|
+
* (`@intentius/chant/op/receipt-store`), as the sql lexicon's shared
|
|
24
|
+
* `SqlReceiptStore` (`../../core/receipts.ts`). The backfill activity
|
|
25
|
+
* reaches the store directly, the way the fly lexicon's release component
|
|
26
|
+
* reaches its own. The same table holds the receipts of a sql project's
|
|
27
|
+
* `effect()` steps (`../../receipts.ts`), under their own addresses.
|
|
28
|
+
*
|
|
29
|
+
* Write discipline: the backfill writes a partition's receipt after the
|
|
30
|
+
* partition's copy succeeded, last, and nothing else writes one.
|
|
31
|
+
*
|
|
32
|
+
* In a Replicated database (#3249) the receipts sit beside the tables they
|
|
33
|
+
* witness, in `<db>.__chant_receipts`, a `ReplicatedReplacingMergeTree`: the
|
|
34
|
+
* database runs its CREATE on every replica and the table replicates its
|
|
35
|
+
* rows, so a receipt written by a copy on one replica is read by a resumed
|
|
36
|
+
* backfill on another. Each read first waits for this replica to fetch what
|
|
37
|
+
* the others wrote (`SYSTEM SYNC REPLICA ... LIGHTWEIGHT`). A separate
|
|
38
|
+
* `chant_receipts` database would exist on the one replica the backfill
|
|
39
|
+
* first ran on. With a replica down (#3270) the CREATE goes on without it,
|
|
40
|
+
* and a read that cannot fetch a receipt only that replica has stops the
|
|
41
|
+
* backfill, naming it (`./replicas.ts`).
|
|
42
|
+
*/
|
|
43
|
+
import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
44
|
+
import { receiptAddress } from "../../core/receipts.js";
|
|
45
|
+
import { clickhouseQuery } from "../http.js";
|
|
46
|
+
import { ident, sqlString } from "../apply/statements.js";
|
|
47
|
+
import { CLICKHOUSE_COMMENT_OWNERSHIP_KEYS, RECEIPTS_TRAILER_KEY } from "../ownership.js";
|
|
48
|
+
import { DDL_SETTINGS, syncReplicaWithin } from "./replicas.js";
|
|
49
|
+
import { renderFor } from "../topology.js";
|
|
50
|
+
/** The database the receipts live in. Its comment marks it as chant's, so schema reads leave it out. */
|
|
51
|
+
export const RECEIPTS_DATABASE = "chant_receipts";
|
|
52
|
+
/** The table, one row per receipt write. */
|
|
53
|
+
export const RECEIPTS_TABLE = "receipts";
|
|
54
|
+
/** The table in a Replicated database that holds the receipts of the rebuilds in it. */
|
|
55
|
+
export const REPLICATED_RECEIPTS_TABLE = "__chant_receipts";
|
|
56
|
+
/** Where the receipts are kept: `chant_receipts.receipts`, or `<db>.__chant_receipts` for a Replicated database. */
|
|
57
|
+
export function receiptsTable(replicatedIn) {
|
|
58
|
+
return replicatedIn ? { database: replicatedIn, name: REPLICATED_RECEIPTS_TABLE } : { database: RECEIPTS_DATABASE, name: RECEIPTS_TABLE };
|
|
59
|
+
}
|
|
60
|
+
const COMMENT = `chant effect receipts [chant ${CLICKHOUSE_COMMENT_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE} ${RECEIPTS_TRAILER_KEY}=effects]`;
|
|
61
|
+
/** Plain synchronous inserts: an async insert can be acknowledged before it is written. */
|
|
62
|
+
const SYNC = { async_insert: "0" };
|
|
63
|
+
export { receiptAddress } from "../../core/receipts.js";
|
|
64
|
+
/**
|
|
65
|
+
* A receipt store on the server at `endpoint`. Creates the receipts database
|
|
66
|
+
* and table on first write if they are not there. `identity` is the
|
|
67
|
+
* project's ownership stack and env, which every address starts with.
|
|
68
|
+
* `replicatedIn` names a Replicated database to keep them in instead, and
|
|
69
|
+
* `replicaTimeoutMs` how long a read waits for this replica to catch up there.
|
|
70
|
+
* `topology` is the environment's (`../topology.ts`): the database and table
|
|
71
|
+
* are created for it, `ON CLUSTER` and a `Replicated*` engine on a cluster.
|
|
72
|
+
* In a Replicated database they are created for that database whatever it says.
|
|
73
|
+
*/
|
|
74
|
+
export function clickhouseReceiptStore(endpoint, identity, opts = {}) {
|
|
75
|
+
const where = receiptsTable(opts.replicatedIn);
|
|
76
|
+
const topology = opts.replicatedIn ? { kind: "replicated" } : opts.topology;
|
|
77
|
+
const TABLE = `${ident(where.database)}.${ident(where.name)}`;
|
|
78
|
+
let ensured = false;
|
|
79
|
+
const ensure = async () => {
|
|
80
|
+
if (ensured)
|
|
81
|
+
return;
|
|
82
|
+
if (!opts.replicatedIn) {
|
|
83
|
+
await clickhouseQuery(endpoint, renderFor(`CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`, topology), { settings: DDL_SETTINGS });
|
|
84
|
+
}
|
|
85
|
+
await clickhouseQuery(endpoint, renderFor(`CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
|
|
86
|
+
`ENGINE = ReplacingMergeTree(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`, topology), { settings: DDL_SETTINGS });
|
|
87
|
+
ensured = true;
|
|
88
|
+
};
|
|
89
|
+
const exists = async () => {
|
|
90
|
+
const rows = await clickhouseQuery(endpoint, `SELECT count() AS n FROM system.tables WHERE database = ${sqlString(where.database)} AND name = ${sqlString(where.name)}`);
|
|
91
|
+
return Number(rows[0]?.n ?? 0) > 0;
|
|
92
|
+
};
|
|
93
|
+
/** Whether there is a table to read; in a Replicated database, after this replica has caught up with it. */
|
|
94
|
+
const readable = async () => {
|
|
95
|
+
if (!ensured && !(await exists()))
|
|
96
|
+
return false;
|
|
97
|
+
if (opts.replicatedIn)
|
|
98
|
+
await syncReplicaWithin(endpoint, where.database, where.name, opts.replicaTimeoutMs !== undefined ? { timeoutMs: opts.replicaTimeoutMs } : {});
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
const address = (receipt) => receiptAddress(identity, receipt.effect);
|
|
102
|
+
return {
|
|
103
|
+
async read(receipt) {
|
|
104
|
+
if (!(await readable()))
|
|
105
|
+
return undefined;
|
|
106
|
+
const rows = await clickhouseQuery(endpoint, `SELECT expectation FROM ${TABLE} WHERE address = ${sqlString(address(receipt))} ORDER BY written_at DESC LIMIT 1`);
|
|
107
|
+
return rows[0]?.expectation;
|
|
108
|
+
},
|
|
109
|
+
async readAll(prefix) {
|
|
110
|
+
if (!(await readable()))
|
|
111
|
+
return new Map();
|
|
112
|
+
const rows = await clickhouseQuery(endpoint, `SELECT address, argMax(expectation, written_at) AS expectation FROM ${TABLE} WHERE startsWith(address, ${sqlString(prefix)}) GROUP BY address`);
|
|
113
|
+
return new Map(rows.map((r) => [r.address, r.expectation]));
|
|
114
|
+
},
|
|
115
|
+
async write(receipt, expectation) {
|
|
116
|
+
await ensure();
|
|
117
|
+
await clickhouseQuery(endpoint, `INSERT INTO ${TABLE} (address, effect, expectation, run_id) VALUES (${sqlString(address(receipt))}, ${sqlString(receipt.effect)}, ${sqlString(expectation)}, ${sqlString(opts.runId ?? "")})`, { settings: SYNC });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|