@intentius/chant-lexicon-sql 0.124.31 → 0.124.33

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.
Files changed (322) hide show
  1. package/dist/change-set.js +250 -0
  2. package/dist/clickhouse/access.js +722 -0
  3. package/dist/clickhouse/apply/apply.js +243 -0
  4. package/dist/clickhouse/apply/mutations.js +66 -0
  5. package/dist/clickhouse/apply/statements.js +545 -0
  6. package/dist/clickhouse/catalog-types.js +7 -0
  7. package/dist/clickhouse/container.js +152 -0
  8. package/dist/clickhouse/engine-syntax.js +159 -0
  9. package/dist/clickhouse/entities.js +593 -0
  10. package/dist/clickhouse/expressions.js +107 -0
  11. package/dist/clickhouse/http.js +55 -0
  12. package/dist/clickhouse/import/generator.js +192 -0
  13. package/dist/clickhouse/import/inlined.js +207 -0
  14. package/dist/clickhouse/import/ir.js +160 -0
  15. package/dist/clickhouse/import/live-export.js +131 -0
  16. package/dist/clickhouse/import/parser.js +17 -0
  17. package/dist/clickhouse/live/bind.js +116 -0
  18. package/dist/clickhouse/live/catalog.js +223 -0
  19. package/dist/clickhouse/live/describe-resources.js +91 -0
  20. package/dist/clickhouse/live/undeclarable.js +59 -0
  21. package/dist/clickhouse/overlays/codecs.js +57 -0
  22. package/dist/clickhouse/overlays/engines.js +101 -0
  23. package/dist/clickhouse/overlays/kinds.js +1 -0
  24. package/dist/clickhouse/overlays/projections.js +26 -0
  25. package/dist/clickhouse/overlays/skip-indexes.js +40 -0
  26. package/dist/clickhouse/overlays/ttl.js +34 -0
  27. package/dist/clickhouse/overlays/types.js +59 -0
  28. package/dist/clickhouse/ownership.js +83 -0
  29. package/dist/clickhouse/parser.js +674 -0
  30. package/dist/clickhouse/plan/commands.js +159 -0
  31. package/dist/clickhouse/plan/deep.js +345 -0
  32. package/dist/clickhouse/plan/diff.js +312 -0
  33. package/dist/clickhouse/plan/disruption.js +73 -0
  34. package/dist/clickhouse/plan/normalize.js +545 -0
  35. package/dist/clickhouse/plan/rebuild-handoff.js +57 -0
  36. package/dist/clickhouse/plan/report.js +26 -0
  37. package/dist/clickhouse/plan/rules.js +72 -0
  38. package/dist/clickhouse/plan/schema.js +67 -0
  39. package/dist/clickhouse/plan/server-format.js +35 -0
  40. package/dist/clickhouse/rebuild/backfill.js +265 -0
  41. package/dist/clickhouse/rebuild/engines.js +146 -0
  42. package/dist/clickhouse/rebuild/observe.js +260 -0
  43. package/dist/clickhouse/rebuild/op.js +152 -0
  44. package/dist/clickhouse/rebuild/partitions.js +65 -0
  45. package/dist/clickhouse/rebuild/receipts.js +120 -0
  46. package/dist/clickhouse/rebuild/replicas.js +121 -0
  47. package/dist/clickhouse/rebuild/shards.js +76 -0
  48. package/dist/clickhouse/rebuild/steps.js +383 -0
  49. package/dist/clickhouse/rebuild/verify.js +279 -0
  50. package/dist/clickhouse/row-types.js +151 -0
  51. package/dist/clickhouse/testing/fake-server.js +47 -0
  52. package/dist/clickhouse/testing/roundtrip-schema.js +25 -0
  53. package/dist/clickhouse/testing/writable-server.js +123 -0
  54. package/dist/clickhouse/tokens.js +23 -0
  55. package/dist/clickhouse/topology.js +463 -0
  56. package/dist/clickhouse/type-syntax.js +73 -0
  57. package/dist/clickhouse.js +25 -0
  58. package/dist/codegen/classifier-page.js +93 -0
  59. package/dist/codegen/clickhouse-module.js +249 -0
  60. package/dist/codegen/coverage-page.js +70 -0
  61. package/dist/codegen/docs-cli.js +3 -0
  62. package/dist/codegen/docs.js +222 -0
  63. package/dist/codegen/generate-cli.js +13 -0
  64. package/dist/codegen/generate.js +97 -0
  65. package/dist/codegen/package.js +28 -0
  66. package/dist/codegen/postgres-classifier-page.js +95 -0
  67. package/dist/codegen/postgres-module.js +418 -0
  68. package/dist/codegen/registry.js +52 -0
  69. package/dist/composites/audit-log-table.js +35 -0
  70. package/dist/composites/catalog.js +524 -0
  71. package/dist/composites/cdc-mirror.js +32 -0
  72. package/dist/composites/clickhouse.js +6 -0
  73. package/dist/composites/events-table.js +26 -0
  74. package/dist/composites/index.js +8 -0
  75. package/dist/composites/join-table.js +25 -0
  76. package/dist/composites/postgres.js +6 -0
  77. package/dist/composites/refreshed-view.js +24 -0
  78. package/dist/composites/replacing-table.js +28 -0
  79. package/dist/composites/rollup-view.js +29 -0
  80. package/dist/composites/sharded-table.js +27 -0
  81. package/dist/composites/soft-delete-table.js +34 -0
  82. package/dist/composites/tenant-table.js +26 -0
  83. package/dist/composites/typed.js +8 -0
  84. package/dist/config.js +206 -0
  85. package/dist/core/apply.js +93 -0
  86. package/dist/core/classifier.js +115 -0
  87. package/dist/core/container.js +69 -0
  88. package/dist/core/cursor.js +261 -0
  89. package/dist/core/diff.js +48 -0
  90. package/dist/core/entity.js +58 -0
  91. package/dist/core/find-templates.js +118 -0
  92. package/dist/core/foreign-tables.js +27 -0
  93. package/dist/core/handoff.js +32 -0
  94. package/dist/core/index.js +64 -0
  95. package/dist/core/interpolation.js +191 -0
  96. package/dist/core/normalize.js +43 -0
  97. package/dist/core/ownership.js +119 -0
  98. package/dist/core/receipts.js +22 -0
  99. package/dist/core/references.js +61 -0
  100. package/dist/core/statements.js +42 -0
  101. package/dist/core/template.js +79 -0
  102. package/dist/core/testing/http-stub.js +21 -0
  103. package/dist/core/tokens.js +202 -0
  104. package/dist/core/typed.js +40 -0
  105. package/dist/core/undeclarable.js +46 -0
  106. package/dist/coverage.js +161 -0
  107. package/dist/detect.js +32 -0
  108. package/dist/dialects.js +18 -0
  109. package/dist/files/build-root.js +72 -0
  110. package/dist/files/clickhouse.js +138 -0
  111. package/dist/files/common.js +58 -0
  112. package/dist/files/postgres.js +389 -0
  113. package/dist/generated/clickhouse-types.js +5 -0
  114. package/dist/generated/clickhouse.js +4361 -0
  115. package/dist/generated/postgres-types.js +227 -0
  116. package/dist/generated/postgres.js +3667 -0
  117. package/dist/import-generator.js +14 -0
  118. package/dist/import-parser.js +71 -0
  119. package/dist/index.js +24 -0
  120. package/dist/init-templates.js +290 -0
  121. package/dist/integrity.json +2 -2
  122. package/dist/lint/audit-catalog.js +31 -0
  123. package/dist/lint/audit-lineage.js +22 -0
  124. package/dist/lint/post-synth/audit-entries.js +34 -0
  125. package/dist/lint/post-synth/clickhouse-helpers.js +79 -0
  126. package/dist/lint/post-synth/clickhouse-type-check.js +162 -0
  127. package/dist/lint/post-synth/index.js +112 -0
  128. package/dist/lint/post-synth/postgres-audit-entries.js +35 -0
  129. package/dist/lint/post-synth/postgres-helpers.js +65 -0
  130. package/dist/lint/post-synth/postgres-names.js +507 -0
  131. package/dist/lint/post-synth/sql-helpers.js +23 -0
  132. package/dist/lint/post-synth/sql101.js +59 -0
  133. package/dist/lint/post-synth/sql102.js +34 -0
  134. package/dist/lint/post-synth/sqlch101.js +34 -0
  135. package/dist/lint/post-synth/sqlch102.js +23 -0
  136. package/dist/lint/post-synth/sqlch103.js +45 -0
  137. package/dist/lint/post-synth/sqlch104.js +41 -0
  138. package/dist/lint/post-synth/sqlch105.js +36 -0
  139. package/dist/lint/post-synth/sqlch106.js +27 -0
  140. package/dist/lint/post-synth/sqlch107.js +39 -0
  141. package/dist/lint/post-synth/sqlch108.js +28 -0
  142. package/dist/lint/post-synth/sqlch109.js +21 -0
  143. package/dist/lint/post-synth/sqlch110.js +31 -0
  144. package/dist/lint/post-synth/sqlch111.js +41 -0
  145. package/dist/lint/post-synth/sqlch112.js +28 -0
  146. package/dist/lint/post-synth/sqlch113.js +19 -0
  147. package/dist/lint/post-synth/sqlch114.js +26 -0
  148. package/dist/lint/post-synth/sqlch115.js +30 -0
  149. package/dist/lint/post-synth/sqlch116.js +22 -0
  150. package/dist/lint/post-synth/sqlch117.js +26 -0
  151. package/dist/lint/post-synth/sqlch118.js +27 -0
  152. package/dist/lint/post-synth/sqlch119.js +32 -0
  153. package/dist/lint/post-synth/sqlch120.js +20 -0
  154. package/dist/lint/post-synth/sqlch121.js +11 -0
  155. package/dist/lint/post-synth/sqlch122.js +11 -0
  156. package/dist/lint/post-synth/sqlch123.js +10 -0
  157. package/dist/lint/post-synth/sqlch124.js +21 -0
  158. package/dist/lint/post-synth/sqlch125.js +55 -0
  159. package/dist/lint/post-synth/sqlch126.js +62 -0
  160. package/dist/lint/post-synth/sqlch127.js +132 -0
  161. package/dist/lint/post-synth/sqlpg101.js +25 -0
  162. package/dist/lint/post-synth/sqlpg102.js +35 -0
  163. package/dist/lint/post-synth/sqlpg103.js +23 -0
  164. package/dist/lint/post-synth/sqlpg104.js +23 -0
  165. package/dist/lint/post-synth/sqlpg105.js +22 -0
  166. package/dist/lint/post-synth/sqlpg106.js +22 -0
  167. package/dist/lint/post-synth/sqlpg107.js +22 -0
  168. package/dist/lint/post-synth/sqlpg108.js +28 -0
  169. package/dist/lint/post-synth/sqlpg109.js +39 -0
  170. package/dist/lint/post-synth/sqlpg110.js +38 -0
  171. package/dist/lint/post-synth/sqlpg111.js +23 -0
  172. package/dist/lint/post-synth/sqlpg112.js +25 -0
  173. package/dist/lint/post-synth/sqlpg113.js +29 -0
  174. package/dist/lint/post-synth/sqlpg114.js +39 -0
  175. package/dist/lint/post-synth/sqlpg115.js +48 -0
  176. package/dist/lint/post-synth/sqlpg116.js +26 -0
  177. package/dist/lint/post-synth/sqlpg117.js +24 -0
  178. package/dist/lint/post-synth/sqlpg118.js +22 -0
  179. package/dist/lint/post-synth/sqlpg119.js +41 -0
  180. package/dist/lint/post-synth/sqlpg120.js +83 -0
  181. package/dist/lint/post-synth/sqlpg121.js +33 -0
  182. package/dist/lint/post-synth/sqlpg122.js +27 -0
  183. package/dist/lint/post-synth/sqlpg123.js +69 -0
  184. package/dist/lint/post-synth/sqlpg124.js +50 -0
  185. package/dist/lint/post-synth/sqlpg125.js +41 -0
  186. package/dist/lint/post-synth/sqlpg126.js +38 -0
  187. package/dist/lint/rules/index.js +12 -0
  188. package/dist/lint/rules/postgres-templates.js +27 -0
  189. package/dist/lint/rules/sqlch001.js +79 -0
  190. package/dist/lint/rules/sqlch002.js +124 -0
  191. package/dist/lint/rules/sqlch003.js +69 -0
  192. package/dist/lint/rules/sqlch004.js +83 -0
  193. package/dist/lint/rules/sqlpg001.js +68 -0
  194. package/dist/lint/rules/sqlpg002.js +69 -0
  195. package/dist/lint/rules/sqlpg003.js +58 -0
  196. package/dist/lint/rules/sqlpg004.js +77 -0
  197. package/dist/lint/rules/sqlpg005.js +86 -0
  198. package/dist/lint/rules/templates.js +25 -0
  199. package/dist/lint/rules/typed-names.js +132 -0
  200. package/dist/live-dialect.js +32 -0
  201. package/dist/lsp/catalog.js +44 -0
  202. package/dist/lsp/completions.js +136 -0
  203. package/dist/lsp/hover.js +115 -0
  204. package/dist/lsp/postgres-catalog.js +176 -0
  205. package/dist/lsp/postgres-template.js +171 -0
  206. package/dist/lsp/postgres.js +266 -0
  207. package/dist/lsp/refs.js +60 -0
  208. package/dist/lsp/registry.d.ts.map +1 -1
  209. package/dist/lsp/registry.js +19 -0
  210. package/dist/lsp/template.js +143 -0
  211. package/dist/manifest.json +1 -1
  212. package/dist/mcp/index.d.ts.map +1 -1
  213. package/dist/mcp/index.js +293 -0
  214. package/dist/mcp/postgres.js +117 -0
  215. package/dist/migration-statements.js +243 -0
  216. package/dist/op/activities/clickhouse-apply.js +83 -0
  217. package/dist/op/activities/clickhouse-emulator.js +53 -0
  218. package/dist/op/activities/clickhouse-rebuild.js +119 -0
  219. package/dist/op/activities/index.js +26 -0
  220. package/dist/op/activities/postgres-apply.js +126 -0
  221. package/dist/op/activities/postgres-emulator.js +70 -0
  222. package/dist/op/activities/postgres-migration.js +156 -0
  223. package/dist/op/activities/sql-exec.js +57 -0
  224. package/dist/op/activity-contracts.js +103 -0
  225. package/dist/package-cli.js +14 -0
  226. package/dist/plugin.js +259 -0
  227. package/dist/postgres/access/acl.js +344 -0
  228. package/dist/postgres/access/changes.js +30 -0
  229. package/dist/postgres/access/import.js +61 -0
  230. package/dist/postgres/access/live.js +96 -0
  231. package/dist/postgres/apply/apply.js +557 -0
  232. package/dist/postgres/apply/statements.js +882 -0
  233. package/dist/postgres/catalog-types.js +18 -0
  234. package/dist/postgres/container.js +58 -0
  235. package/dist/postgres/entities.js +1390 -0
  236. package/dist/postgres/entity-types.js +28 -0
  237. package/dist/postgres/import/generator.js +283 -0
  238. package/dist/postgres/import/ir.js +53 -0
  239. package/dist/postgres/import/live-export.js +83 -0
  240. package/dist/postgres/keywords.js +32 -0
  241. package/dist/postgres/live/bind.js +130 -0
  242. package/dist/postgres/live/catalog-kinds.js +301 -0
  243. package/dist/postgres/live/catalog.js +644 -0
  244. package/dist/postgres/live/client.js +151 -0
  245. package/dist/postgres/live/describe-resources.js +135 -0
  246. package/dist/postgres/live/undeclarable.js +111 -0
  247. package/dist/postgres/migrate/backfill.js +170 -0
  248. package/dist/postgres/migrate/batches.js +122 -0
  249. package/dist/postgres/migrate/carry.js +518 -0
  250. package/dist/postgres/migrate/handoff.js +59 -0
  251. package/dist/postgres/migrate/names.js +61 -0
  252. package/dist/postgres/migrate/observe.js +360 -0
  253. package/dist/postgres/migrate/op.js +178 -0
  254. package/dist/postgres/migrate/receipts.js +117 -0
  255. package/dist/postgres/migrate/replication.js +106 -0
  256. package/dist/postgres/migrate/steps.js +514 -0
  257. package/dist/postgres/overlays/columns.js +31 -0
  258. package/dist/postgres/overlays/constraints.js +52 -0
  259. package/dist/postgres/overlays/extension-objects.js +88 -0
  260. package/dist/postgres/overlays/indexes.js +25 -0
  261. package/dist/postgres/overlays/kinds.js +2 -0
  262. package/dist/postgres/overlays/partitioning.js +23 -0
  263. package/dist/postgres/overlays/relations.js +25 -0
  264. package/dist/postgres/overlays/storage.js +65 -0
  265. package/dist/postgres/overlays/type-ddl.js +36 -0
  266. package/dist/postgres/overlays/types.js +74 -0
  267. package/dist/postgres/parser.js +1824 -0
  268. package/dist/postgres/plan/commands.js +137 -0
  269. package/dist/postgres/plan/deep.js +324 -0
  270. package/dist/postgres/plan/diff.js +499 -0
  271. package/dist/postgres/plan/disruption.js +64 -0
  272. package/dist/postgres/plan/normalize.js +889 -0
  273. package/dist/postgres/plan/report.js +21 -0
  274. package/dist/postgres/plan/rules.js +115 -0
  275. package/dist/postgres/plan/schema.js +105 -0
  276. package/dist/postgres/plan/server-normalize.js +166 -0
  277. package/dist/postgres/providers/aurora.js +44 -0
  278. package/dist/postgres/providers/azure.js +53 -0
  279. package/dist/postgres/providers/cloud-sql.js +41 -0
  280. package/dist/postgres/providers/index.js +73 -0
  281. package/dist/postgres/providers/neon.js +44 -0
  282. package/dist/postgres/providers/rds.js +47 -0
  283. package/dist/postgres/providers/supabase.js +45 -0
  284. package/dist/postgres/providers/types.js +21 -0
  285. package/dist/postgres/row-types.js +107 -0
  286. package/dist/postgres/testing/fake-client.js +34 -0
  287. package/dist/postgres/testing/migration.js +48 -0
  288. package/dist/postgres/testing/roundtrip-schema.js +64 -0
  289. package/dist/postgres/testing/server.js +47 -0
  290. package/dist/postgres/testing/writable-server.js +160 -0
  291. package/dist/postgres/tokens.js +41 -0
  292. package/dist/postgres.js +15 -0
  293. package/dist/read/clickhouse.js +82 -0
  294. package/dist/read/objects.js +44 -0
  295. package/dist/read/postgres.js +104 -0
  296. package/dist/read.js +52 -0
  297. package/dist/receipts.js +207 -0
  298. package/dist/serializer.js +172 -0
  299. package/dist/skill-defs.js +93 -0
  300. package/dist/spec/catalog.js +191 -0
  301. package/dist/spec/fetch.d.ts.map +1 -1
  302. package/dist/spec/fetch.js +90 -0
  303. package/dist/spec/pin.js +42 -0
  304. package/dist/spec/postgres-catalog.js +171 -0
  305. package/dist/spec/postgres-fetch.js +91 -0
  306. package/dist/spec/postgres-pin.js +82 -0
  307. package/dist/sql-files.js +206 -0
  308. package/dist/sql-types-command.js +97 -0
  309. package/dist/sql-types.js +323 -0
  310. package/dist/token-source.js +156 -0
  311. package/dist/validate-cli.js +5 -0
  312. package/dist/validate.js +151 -0
  313. package/dist/workspace-outputs.js +191 -0
  314. package/package.json +7 -7
  315. package/src/codegen/package.ts +2 -2
  316. package/src/lint/rules/typescript-7.test.ts +2 -1
  317. package/src/lsp/catalog.ts +2 -2
  318. package/src/lsp/postgres-catalog.ts +2 -2
  319. package/src/lsp/registry.ts +2 -1
  320. package/src/mcp/index.ts +4 -2
  321. package/src/spec/fetch.ts +2 -2
  322. package/src/spec/postgres-fetch.ts +2 -2
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A replica of a Replicated database that is down (#3270): what each step of
3
+ * the rebuild does about it.
4
+ *
5
+ * DDL (create, dual-write view, EXCHANGE, the views' DETACH and ATTACH, the
6
+ * drops, the receipts table) goes through the database's log in Keeper. The
7
+ * replica the run talks to executes it, and the others take it from the log;
8
+ * one that is down runs it in order when it comes back. So DDL proceeds on
9
+ * the live replicas: every statement carries
10
+ * `distributed_ddl_output_mode = throw_only_active`, which "doesn't wait for
11
+ * inactive replicas of the Replicated database" and still throws when a live
12
+ * one fails the statement
13
+ * (https://clickhouse.com/docs/reference/settings/session-settings/distributed-ddl).
14
+ * With the default `throw` the statement would wait the whole
15
+ * `distributed_ddl_task_timeout` (180s) for the down replica and then fail,
16
+ * after the live replicas had already run it. A replica counts as inactive
17
+ * once its Keeper session has expired (the client's `session_timeout_ms`,
18
+ * 30s by default), so a statement right after a replica goes down waits that
19
+ * long and then goes on. `KILL QUERY ON CLUSTER` takes the same setting and
20
+ * behaves the same way.
21
+ *
22
+ * Reading rows is different. A part written on a replica that went down
23
+ * before the others fetched it is on that replica alone, and the rows in it
24
+ * cannot be read anywhere else. A backfill that went on without them would
25
+ * copy the table less those rows, the verification would compare two tables
26
+ * that both lack them and pass, and the swap would lose them. So before a
27
+ * step reads rows it waits, for a bounded time, until this replica has
28
+ * fetched everything the others wrote (`SYSTEM SYNC REPLICA ... LIGHTWEIGHT`,
29
+ * https://clickhouse.com/docs/sql-reference/statements/system#sync-replica).
30
+ * When the down replica holds nothing this one lacks, which is the usual case,
31
+ * the wait returns at once and the step goes on. Otherwise the step stops
32
+ * with a {@link ReplicaFetchError} naming the replica and its parts.
33
+ */
34
+ import { clickhouseQuery, ClickHouseQueryError } from "../http.js";
35
+ import { qualifiedIdent, sqlString } from "../apply/statements.js";
36
+ /** Settings every DDL statement of the rebuild carries: go on without the replicas that are down. Ignored in an Atomic database. */
37
+ export const DDL_SETTINGS = { distributed_ddl_output_mode: "throw_only_active" };
38
+ /** How long a step waits for this replica to fetch what the others wrote, by default. */
39
+ export const DEFAULT_REPLICA_TIMEOUT_MS = 120000;
40
+ /**
41
+ * One `SYSTEM SYNC REPLICA` waits at most this long (its `receive_timeout`),
42
+ * and the wait is repeated until the deadline. It keeps each HTTP request
43
+ * well inside the client's own five-minute response timeout, and puts a log
44
+ * line between them.
45
+ */
46
+ const SYNC_SLICE_S = 60;
47
+ /** A step stopped because this replica could not fetch parts that only another replica has. */
48
+ export class ReplicaFetchError extends Error {
49
+ constructor(
50
+ /** `db.t`. */
51
+ table,
52
+ /** The replica the run talks to. */
53
+ replica, missing, waitedMs) {
54
+ const sources = [...new Set(missing.map((m) => m.source))];
55
+ const shown = missing
56
+ .slice(0, 10)
57
+ .map((m) => m.part + " from " + m.source + (m.sourceActive ? "" : " (inactive)") + (m.lastError ? " (last fetch error: " + m.lastError + ")" : ""))
58
+ .join(", ");
59
+ super(`${table}: waited ${Math.round(waitedMs / 1000)}s for replica ${replica} to fetch what the other replicas wrote, and ${missing.length} part(s) are still to fetch: ${shown}${missing.length > 10 ? ", ..." : ""}. ` +
60
+ `Those rows are on ${sources.join(", ")} alone, so the step stops rather than read the table without them; nothing has been swapped. ` +
61
+ `Bring ${sources.join(", ")} back (it rejoins through Keeper and this replica fetches the parts), then run again. ` +
62
+ `replicaTimeout sets how long a step waits.`);
63
+ this.table = table;
64
+ this.replica = replica;
65
+ this.missing = missing;
66
+ this.waitedMs = waitedMs;
67
+ this.name = "ReplicaFetchError";
68
+ }
69
+ }
70
+ const isTimeout = (err) => err instanceof ClickHouseQueryError && /TIMEOUT_EXCEEDED|command timed out/.test(err.serverMessage);
71
+ /**
72
+ * Wait until this replica has fetched every part the others have written to
73
+ * `database.table`, for at most `timeoutMs`; then throw a
74
+ * {@link ReplicaFetchError} naming what it still lacks and where that is.
75
+ */
76
+ export async function syncReplicaWithin(endpoint, database, table, opts = {}) {
77
+ const started = Date.now();
78
+ const deadline = started + (opts.timeoutMs ?? DEFAULT_REPLICA_TIMEOUT_MS);
79
+ const sql = `SYSTEM SYNC REPLICA ${qualifiedIdent(database, table)} LIGHTWEIGHT`;
80
+ opts.log?.(sql);
81
+ for (;;) {
82
+ opts.signal?.throwIfAborted();
83
+ const slice = Math.max(1, Math.min(SYNC_SLICE_S, Math.ceil((deadline - Date.now()) / 1000)));
84
+ try {
85
+ await clickhouseQuery(endpoint, sql, { settings: { receive_timeout: String(slice) }, ...(opts.signal ? { signal: opts.signal } : {}) });
86
+ return;
87
+ }
88
+ catch (err) {
89
+ if (!isTimeout(err))
90
+ throw err;
91
+ }
92
+ const { replica, missing } = await missingParts(endpoint, database, table);
93
+ // Fetched between the timeout and the look: nothing is left to wait for.
94
+ if (missing.length === 0)
95
+ return;
96
+ if (Date.now() >= deadline)
97
+ throw new ReplicaFetchError(`${database}.${table}`, replica, missing, Date.now() - started);
98
+ opts.log?.(`-- ${database}.${table}: replica ${replica} is still to fetch ${missing.length} part(s) from ${[...new Set(missing.map((m) => m.source))].join(", ")}; waiting`);
99
+ }
100
+ }
101
+ /** The parts this replica's queue is still to fetch for `database.table`, with where from. */
102
+ export async function missingParts(endpoint, database, table) {
103
+ const where = `database = ${sqlString(database)} AND table = ${sqlString(table)}`;
104
+ const [replicas] = await clickhouseQuery(endpoint, `SELECT replica_name, replica_is_active FROM system.replicas WHERE ${where}`);
105
+ // The entries LIGHTWEIGHT waits for.
106
+ const queue = await clickhouseQuery(endpoint, `SELECT new_part_name, source_replica, last_exception FROM system.replication_queue WHERE ${where} ` +
107
+ `AND type IN ('GET_PART', 'ATTACH_PART', 'DROP_RANGE', 'REPLACE_RANGE', 'DROP_PART') ORDER BY new_part_name`);
108
+ const active = replicas?.replica_is_active ?? {};
109
+ return {
110
+ replica: replicas?.replica_name ?? "this replica",
111
+ missing: queue.map((e) => {
112
+ const lastError = e.last_exception.trim().split("\n")[0];
113
+ return {
114
+ part: e.new_part_name,
115
+ source: e.source_replica || "another replica",
116
+ sourceActive: Number(active[e.source_replica] ?? 0) === 1,
117
+ ...(lastError ? { lastError } : {}),
118
+ };
119
+ }),
120
+ };
121
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A rebuild across the shards of a cluster (#3663).
3
+ *
4
+ * On a `cluster:<name>` topology the rebuild's DDL runs `ON CLUSTER`, so
5
+ * every server gets the new table, the dual-write view and the swap. The
6
+ * rows do not move that way: each shard holds its own, and a copy run on
7
+ * the profile's server alone copies that server's shard and no other. So
8
+ * with more than one shard, the backfill and the verification work shard by
9
+ * shard, all through the profile's server:
10
+ *
11
+ * - a shard's rows are read with `cluster('<name>', db, t)` and
12
+ * `_shard_num = <n>`, one replica per shard, as the cluster's own
13
+ * configuration reaches it;
14
+ * - they are written back to the same shard with
15
+ * `INSERT INTO FUNCTION cluster('<name>', db, t__chant_new, <slot>)`, a
16
+ * constant sharding key that lands on that shard, inserted in the
17
+ * foreground so the statement returns once the shard has the rows;
18
+ * - a shard's half-copied rows are deleted with an `ALTER ... ON CLUSTER`
19
+ * whose condition holds on that shard's servers only
20
+ * (`getMacro('shard')`, the macro the cluster's Keeper paths already use).
21
+ *
22
+ * Each (shard, partition) is one copy with its own receipt, so a rerun after
23
+ * a failure on one shard skips what every shard already copied.
24
+ *
25
+ * With one shard nothing changes: the replicas of a shard replicate what is
26
+ * written on any of them, which is what the rebuild has always relied on.
27
+ */
28
+ import { clickhouseQuery } from "../http.js";
29
+ import { ident, sqlString } from "../apply/statements.js";
30
+ import { RebuildRefusal } from "./observe.js";
31
+ /** Settings for a copy into one shard: in the foreground, and read from the cluster rather than pushed down to each shard. */
32
+ export const SHARD_INSERT_SETTINGS = { distributed_foreground_insert: "1", parallel_distributed_insert_select: "0" };
33
+ /** Settings for a delete on one shard: its condition is the server's own macro, the same on every replica of the shard. */
34
+ export const SHARD_DELETE_SETTINGS = { allow_nondeterministic_mutations: "1", mutations_sync: "2" };
35
+ /**
36
+ * The shards of the target's cluster, when its topology is `cluster:<name>`
37
+ * and the cluster has more than one. Undefined otherwise: the rebuild works
38
+ * on the profile's server as it always has.
39
+ */
40
+ export async function clusterSharding(target) {
41
+ if (target.topology?.kind !== "cluster")
42
+ return undefined;
43
+ let cluster = target.topology.cluster;
44
+ const macro = /^\{(\w+)\}$/.exec(cluster);
45
+ if (macro) {
46
+ const [row] = await clickhouseQuery(target.endpoint, `SELECT getMacro(${sqlString(macro[1])}) AS c`);
47
+ cluster = row?.c ?? cluster;
48
+ }
49
+ const weights = await clickhouseQuery(target.endpoint, `SELECT shard_num AS shard, any(shard_weight) AS weight FROM system.clusters WHERE cluster = ${sqlString(cluster)} GROUP BY shard_num ORDER BY shard_num`);
50
+ if (weights.length <= 1)
51
+ return undefined;
52
+ const macros = new Map((await clickhouseQuery(target.endpoint, `SELECT _shard_num AS shard, getMacro('shard') AS macro FROM cluster(${sqlString(cluster)}, system.one)`)).map((r) => [Number(r.shard), r.macro]));
53
+ let slot = 0;
54
+ const shards = [];
55
+ for (const w of weights) {
56
+ const num = Number(w.shard);
57
+ const m = macros.get(num);
58
+ if (m === undefined)
59
+ throw new RebuildRefusal(`cluster ${cluster}: shard ${num} has no {shard} macro, which a rebuild across shards needs to delete a half-copied partition on that shard alone`);
60
+ shards.push({ num, macro: m, slot });
61
+ slot += Number(w.weight) || 1;
62
+ }
63
+ const distinct = new Set(shards.map((s) => s.macro));
64
+ if (distinct.size !== shards.length) {
65
+ throw new RebuildRefusal(`cluster ${cluster}: two shards share a {shard} macro (${shards.map((s) => `${s.num}=${s.macro}`).join(", ")}); each shard needs its own`);
66
+ }
67
+ return { cluster, shards };
68
+ }
69
+ /** One shard's rows of `database.table`, as a table expression; filter with {@link onShard}. */
70
+ export const shardTable = (sharding, database, table) => `cluster(${sqlString(sharding.cluster)}, ${ident(database)}, ${ident(table)})`;
71
+ /** The condition that keeps one shard's rows of a {@link shardTable}. */
72
+ export const onShard = (shard) => `_shard_num = ${shard.num}`;
73
+ /** Where a copy into one shard of `database.table` writes. */
74
+ export const intoShard = (sharding, shard, database, table) => `FUNCTION cluster(${sqlString(sharding.cluster)}, ${ident(database)}, ${ident(table)}, ${shard.slot})`;
75
+ /** The condition, in an `ALTER ... ON CLUSTER` mutation, that holds on that shard's servers only. */
76
+ export const onShardServers = (shard) => `getMacro('shard') = ${sqlString(shard.macro)}`;
@@ -0,0 +1,383 @@
1
+ /**
2
+ * The rebuild migration's steps (#3198), each one convergent: it observes
3
+ * the server (`./observe.ts`), does what is left of its own part, and
4
+ * reports. Running a step twice does what running it once did.
5
+ *
6
+ * The backfill and the verification are in `./backfill.ts` and
7
+ * `./verify.ts`; the Op that orders them is `./op.ts`.
8
+ */
9
+ import { computePlanDigest, parseDuration } from "@intentius/chant/op";
10
+ import { clickhouseQuery } from "../http.js";
11
+ import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stampedComment } from "../ownership.js";
12
+ import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString } from "../apply/statements.js";
13
+ import { waitForMutations } from "../apply/mutations.js";
14
+ import { renderFor } from "../topology.js";
15
+ import { DDL_SETTINGS, syncReplicaWithin } from "./replicas.js";
16
+ import { assertSameRows } from "./verify.js";
17
+ import { changesBetween, observeRebuild, rebuildNames, RebuildRefusal, showCreate, } from "./observe.js";
18
+ /** The server's clock, in milliseconds: the cut-over and the retention are the server's times, not this machine's. */
19
+ export async function serverNow(target) {
20
+ const [row] = await clickhouseQuery(target.endpoint, "SELECT toUnixTimestamp64Milli(now64(3)) AS t");
21
+ return Number(row?.t ?? Date.now());
22
+ }
23
+ /** `2026-10-02 12:00:00.000`, UTC: how a cut-over is written into SQL. */
24
+ export const sqlUtc = (ms) => new Date(ms).toISOString().replace("T", " ").replace("Z", "");
25
+ /** A `DateTime64` literal in UTC. */
26
+ export const utcLiteral = (ms) => `toDateTime64(${sqlString(sqlUtc(ms))}, 3, 'UTC')`;
27
+ export const observe = (run) => observeRebuild(run.target, run.declared, run.marker);
28
+ /**
29
+ * One DDL statement, rendered for the environment's topology when it names
30
+ * one (`../topology.ts`: `ON CLUSTER` and the engine). In a Replicated
31
+ * database it goes on without the replicas that are down, which run it from
32
+ * the database's log when they come back (`./replicas.ts`).
33
+ */
34
+ const q = (run, statement) => {
35
+ run.signal?.throwIfAborted();
36
+ const sql = renderFor(statement, run.target.topology);
37
+ run.log(sql);
38
+ return clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS, ...(run.signal ? { signal: run.signal } : {}) });
39
+ };
40
+ export async function waitOn(run, database, table) {
41
+ const ids = await waitForMutations(run.target.endpoint, database, table, {
42
+ ...(run.sharding ? { source: `clusterAllReplicas(${sqlString(run.sharding.cluster)}, system.mutations)` } : {}),
43
+ ...(run.mutationTimeoutMs !== undefined ? { timeoutMs: run.mutationTimeoutMs } : {}),
44
+ ...(run.signal ? { signal: run.signal } : {}),
45
+ });
46
+ if (ids.length > 0)
47
+ run.log(`-- waited for ${database}.${table} mutation(s) ${ids.join(", ")}`);
48
+ }
49
+ /**
50
+ * In a Replicated database, wait until this replica has every part the
51
+ * others have written to `database.table` (`SYSTEM SYNC REPLICA ...
52
+ * LIGHTWEIGHT`, which waits for fetches and not for merges). A step that
53
+ * reads rows on one replica after a copy ran on another reads them all.
54
+ * A replica that is down and holds parts this one lacks stops the step once
55
+ * `replicaTimeoutMs` has passed, naming it (`./replicas.ts`). Nothing to
56
+ * wait for in an Atomic database.
57
+ */
58
+ export async function syncReplica(run, o, database, table) {
59
+ if (!o.replicated)
60
+ return;
61
+ await syncReplicaWithin(run.target.endpoint, database, table, {
62
+ ...(run.replicaTimeoutMs !== undefined ? { timeoutMs: run.replicaTimeoutMs } : {}),
63
+ log: run.log,
64
+ ...(run.signal ? { signal: run.signal } : {}),
65
+ });
66
+ }
67
+ /** The trailer pairs a working object carries besides the marker. */
68
+ const working = (key, role, extra = {}) => ({ [REBUILD_TRAILER_KEY]: key, role, ...extra });
69
+ /**
70
+ * Create the new table from the declaration, under `t__chant_new`, marked as
71
+ * this project's and this rebuild's. The old table's unfinished mutations are
72
+ * waited on first, so what is copied is what they produce. A new table that
73
+ * is already there is kept when it matches the declaration; one made from an
74
+ * earlier declaration is refused, and the run's onFailure drops it.
75
+ */
76
+ export async function createNewTable(run) {
77
+ const o = await observe(run);
78
+ const table = `${o.names.database}.${o.names.newName}`;
79
+ if (o.state !== "rebuild")
80
+ return { state: o.state, table, created: false };
81
+ await waitOn(run, o.names.database, o.names.name);
82
+ if (o.newTable) {
83
+ const live = await showCreate(run.target, o.names.newTable);
84
+ const stale = (await changesBetween(run.target, o.names.key, { ...live, name: run.declared.canonical.name }, run.declared.canonical)).filter((c) => c.field !== "comment");
85
+ if (stale.length > 0) {
86
+ throw new RebuildRefusal(`${table} was made from another declaration of ${o.names.key} (${stale.map((c) => `${c.rule} on ${c.field}`).join(", ")}). ` +
87
+ `The run fails so onFailure drops it, and the next run starts again from the current declaration.`);
88
+ }
89
+ run.log(`-- ${table} is there and matches the declaration`);
90
+ return { state: o.state, table, created: false };
91
+ }
92
+ const renamed = renamedDeclaration(run.declared, o.names.newName, run.target.defaultDatabase);
93
+ await q(run, createStatement(renamed, run.marker, { trailer: working(o.names.key, "new") }));
94
+ return { state: o.state, table, created: true };
95
+ }
96
+ /**
97
+ * Materialized-view mode: create `t__chant_dual`, a materialized view on the
98
+ * old table that writes every row at or after the cut-over into the new
99
+ * table. The cut-over is the server's time now plus the delay, kept in the
100
+ * view's own comment (`cutover=`), so a later run and the backfill read it
101
+ * back from the server. The rows at or after the cut-over that the old table
102
+ * already held are the backfill's (`./backfill.ts`). App mode creates nothing; its gate is the Op's.
103
+ */
104
+ export async function startDualWrite(run) {
105
+ const o = await observe(run);
106
+ const mode = run.dualWrite.mode;
107
+ if (o.state !== "rebuild" || run.dualWrite.mode === "app")
108
+ return { state: o.state, mode, created: false };
109
+ if (o.dual)
110
+ return { state: o.state, mode, cutover: o.dual.pairs.get("cutover"), created: false };
111
+ if (!o.newTable)
112
+ throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
113
+ const column = run.dualWrite.cutoverColumn;
114
+ const cut = o.copied.find((c) => c.name === column);
115
+ if (!cut) {
116
+ throw new RebuildRefusal(`${o.names.key}: the cut-over column ${column} is not a column the new table copies from the old one ` +
117
+ `(copied: ${o.copied.map((c) => c.name).join(", ")})`);
118
+ }
119
+ // On a whole second: a DateTime column holds whole seconds, and a row written
120
+ // during the cut-over's second would otherwise carry a time before it and
121
+ // reach the old table after the backfill has started.
122
+ const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? CUTOVER_DELAY)) / 1000) * 1000;
123
+ const iso = new Date(cutover).toISOString();
124
+ const select = o.copied.map((c) => (c.source === c.name ? ident(c.name) : `${ident(c.source)} AS ${ident(c.name)}`)).join(", ");
125
+ const comment = stampedComment(`chant rebuild of ${o.names.key}: rows at or after the cut-over`, run.marker, working(o.names.key, "dual", { cutover: iso }));
126
+ await q(run, `CREATE MATERIALIZED VIEW ${o.names.dualView} TO ${o.names.newTable} AS SELECT ${select} FROM ${o.names.table} ` +
127
+ `WHERE ${ident(cut.source)} >= ${utcLiteral(cutover)} COMMENT ${sqlString(comment)}`);
128
+ return { state: o.state, mode, cutover: iso, created: true };
129
+ }
130
+ /** How far after the dual-write view is made the cut-over falls, unless `cutoverDelay` says otherwise. */
131
+ export const CUTOVER_DELAY = "5s";
132
+ /** The cut-over in milliseconds, from the dual-write view; undefined in app mode. */
133
+ export function cutoverOf(run, o) {
134
+ if (run.dualWrite.mode === "app")
135
+ return undefined;
136
+ const iso = o.dual?.pairs?.get("cutover");
137
+ if (!iso)
138
+ throw new RebuildRefusal(`${o.names.key}: the dual-write view ${o.names.database}.${o.names.dualName} is not there; the Dual write phase makes it`);
139
+ return Date.parse(iso);
140
+ }
141
+ /** How long the backfill waits, once the cut-over has passed, for the writes begun before it. Default: ten minutes. */
142
+ export const CUTOVER_TIMEOUT_MS = 10 * 60000;
143
+ /**
144
+ * Wait until no row before the cut-over is still to arrive in the old table,
145
+ * then return. Two conditions, both polled: the server's clock has passed
146
+ * the cut-over (until then a row can still be written with a time before
147
+ * it), and every write into the old table that began before the cut-over has
148
+ * finished (a long INSERT, or an asynchronous insert still in its buffer,
149
+ * can carry such rows after the clock has passed). The second wait is
150
+ * bounded by `timeoutMs`; past it the step stops naming the writes, rather
151
+ * than copy without their rows. A row whose time lags further behind its
152
+ * write than `cutoverDelay` is neither: the verification finds it.
153
+ */
154
+ export async function waitForCutover(run, cutover, probe, opts = {}) {
155
+ const at = new Date(cutover).toISOString();
156
+ for (;;) {
157
+ const now = await probe.now();
158
+ if (now > cutover)
159
+ break;
160
+ run.log(`-- waiting ${Math.ceil((cutover - now) / 1000)}s for the cut-over at ${at}`);
161
+ await sleep(Math.min(cutover - now + 50, 10000), run.signal);
162
+ }
163
+ const timeoutMs = opts.timeoutMs ?? CUTOVER_TIMEOUT_MS;
164
+ const deadline = Date.now() + timeoutMs;
165
+ let pollMs = opts.pollMs ?? 100;
166
+ let logged = "";
167
+ for (;;) {
168
+ const pending = await probe.pendingWrites(cutover);
169
+ if (pending.length === 0)
170
+ return;
171
+ if (Date.now() >= deadline) {
172
+ throw new RebuildRefusal(`writes into the old table begun before the cut-over at ${at} are still running after ${Math.round(timeoutMs / 1000)}s: ${pending.join(", ")}. ` +
173
+ `Let them finish, or stop them, and run again; cutoverTimeout sets how long the backfill waits.`);
174
+ }
175
+ const line = pending.join(", ");
176
+ if (line !== logged)
177
+ run.log(`-- waiting for writes begun before the cut-over at ${at}: ${line}`);
178
+ logged = line;
179
+ await sleep(pollMs, run.signal);
180
+ pollMs = Math.min(pollMs * 2, 2000);
181
+ }
182
+ }
183
+ /** The table an `INSERT INTO` statement writes to, or undefined for anything else (`INSERT INTO FUNCTION` included). */
184
+ export function insertTarget(query, currentDatabase) {
185
+ const text = query.replace(/^(?:\s+|--[^\n]*\n|\/\*[\s\S]*?\*\/)*/, "");
186
+ const part = "(`(?:[^`\\\\]|\\\\.)+`|\"(?:[^\"\\\\]|\\\\.)+\"|[A-Za-z_][A-Za-z0-9_]*)";
187
+ const m = new RegExp(`^INSERT\\s+INTO\\s+(?:TABLE\\s+)?${part}(?:\\s*\\.\\s*${part})?`, "i").exec(text);
188
+ if (!m || /^FUNCTION$/i.test(m[1]))
189
+ return undefined;
190
+ const unquote = (s) => (/^[`"]/.test(s) ? s.slice(1, -1).replace(/\\(.)/g, "$1") : s);
191
+ return m[2] !== undefined ? { database: unquote(m[1]), name: unquote(m[2]) } : { database: currentDatabase, name: unquote(m[1]) };
192
+ }
193
+ /**
194
+ * The probe against the server. On a cluster of shards it reads every
195
+ * server's queries and buffers, and in a Replicated database every
196
+ * replica's, since a write can be running on any of them. A server that
197
+ * cannot be reached is left out rather than failing the wait.
198
+ */
199
+ export function serverCutoverProbe(run, o) {
200
+ const { database, name } = o.names;
201
+ const cluster = run.sharding?.cluster ?? (o.replicated ? database : undefined);
202
+ const from = (table) => (cluster ? `clusterAllReplicas(${sqlString(cluster)}, system.${table})` : `system.${table}`);
203
+ // A server that cannot be reached runs no write the backfill could miss
204
+ // from here: its parts reach this one by replication, which the backfill
205
+ // waits for (`syncReplica`), or the verification finds them missing. So an
206
+ // unreachable replica or shard is skipped, as every other step goes on
207
+ // with the live ones; `clusterAllReplicas` counts each replica as a shard.
208
+ const opts = { ...(cluster ? { settings: { skip_unavailable_shards: "1" } } : {}), ...(run.signal ? { signal: run.signal } : {}) };
209
+ return {
210
+ now: () => serverNow(run.target),
211
+ async pendingWrites(cutover) {
212
+ const running = await clickhouseQuery(run.target.endpoint, `SELECT query_id, query, current_database, elapsed, toUnixTimestamp64Milli(now64(3)) AS now FROM ${from("processes")} WHERE query_kind = 'Insert'`, opts);
213
+ const queued = await clickhouseQuery(run.target.endpoint, `SELECT entries.query_id AS ids FROM ${from("asynchronous_inserts")} ` +
214
+ `WHERE database = ${sqlString(database)} AND table = ${sqlString(name)} AND toUnixTimestamp64Milli(first_update) < ${cutover}`, opts);
215
+ const ids = running
216
+ .filter((r) => {
217
+ const t = insertTarget(r.query, r.current_database);
218
+ return t?.database === database && t.name === name && Number(r.now) - Number(r.elapsed) * 1000 < cutover;
219
+ })
220
+ .map((r) => r.query_id);
221
+ for (const r of queued)
222
+ ids.push(...r.ids);
223
+ return [...new Set(ids)];
224
+ },
225
+ };
226
+ }
227
+ export function sleep(ms, signal) {
228
+ return new Promise((resolve, reject) => {
229
+ signal?.throwIfAborted();
230
+ const t = setTimeout(resolve, ms);
231
+ signal?.addEventListener("abort", () => {
232
+ clearTimeout(t);
233
+ reject(signal.reason);
234
+ }, { once: true });
235
+ });
236
+ }
237
+ /**
238
+ * Swap the new table in: compare every row of the two tables once more, as
239
+ * the verification does, and stop on any difference; then `EXCHANGE TABLES`,
240
+ * drop the dual-write view, rename the old table (now under the new table's name) to `t__chant_old`
241
+ * with its retention date, recreate the materialized views that read the
242
+ * table, and stamp the table's own comment as declared.
243
+ *
244
+ * Each part checks for itself whether it is done, so a swap interrupted
245
+ * anywhere finishes on the next run. The table's comment is stamped last:
246
+ * until it is, the table still carries `role=new` and the next run knows the
247
+ * swap has not finished.
248
+ *
249
+ * Recreating a dependent view: in this server line a materialized view
250
+ * follows its source by name, so after the EXCHANGE it already reads the new
251
+ * table. It is detached and attached again so the server analyzes its query
252
+ * against the new table's columns and types, and a view the new definition
253
+ * breaks fails here, with the old table still kept, rather than on the next
254
+ * insert. A view with an inner table keeps its data, which dropping and
255
+ * creating it would not. Inserts that land while a view is detached are not
256
+ * passed to it; in materialized-view mode writes go on through the swap, so
257
+ * that window is the length of one DETACH and one ATTACH. In a Replicated
258
+ * database the DETACH is `PERMANENTLY`, the form that database takes, and
259
+ * the EXCHANGE, the DETACH and the ATTACH each run on every replica: now on
260
+ * the live ones, and from the database's log on one that is down, when it
261
+ * comes back.
262
+ */
263
+ export async function swapTables(run) {
264
+ const o = await observe(run);
265
+ const n = o.names;
266
+ if (o.state === "done")
267
+ return { state: o.state, swapped: false, dependents: [] };
268
+ if (o.state === "swapped" && !o.exchanged) {
269
+ return { state: o.state, swapped: false, dependents: [], oldTable: `${n.database}.${n.oldName}`, ...retention(o) };
270
+ }
271
+ if (!o.exchanged) {
272
+ if (!o.newTable)
273
+ throw new RebuildRefusal(`${n.key}: there is no new table to swap in`);
274
+ // The new table holds every row the old one does, or nothing is swapped
275
+ // (sql-yodeler#45): rows that reached the old table alone since the
276
+ // verification would stay behind in it.
277
+ await assertSameRows(run, o);
278
+ await q(run, `EXCHANGE TABLES ${n.table} AND ${n.newTable}`);
279
+ }
280
+ // The dual-write view now reads the new table by name; drop it before
281
+ // anything else so it writes nothing more into the old one.
282
+ const dual = await objectComment(run, n.database, n.dualName);
283
+ if (dual !== undefined && readTrailerPairs(dual)?.get(REBUILD_TRAILER_KEY) === n.key && carriesMarker(dual, run.marker)) {
284
+ await q(run, `DROP VIEW ${n.dualView} SYNC`);
285
+ }
286
+ if ((await objectComment(run, n.database, n.oldName)) === undefined) {
287
+ await q(run, `RENAME TABLE ${n.newTable} TO ${n.oldTable}`);
288
+ }
289
+ const oldComment = await objectComment(run, n.database, n.oldName);
290
+ let retainUntil = readTrailerPairs(oldComment)?.get("retain-until");
291
+ if (readTrailerPairs(oldComment)?.get("role") !== "old") {
292
+ retainUntil = new Date((await serverNow(run.target)) + run.retainMs).toISOString();
293
+ const comment = stampedComment(`chant rebuild of ${n.key}: the old table, kept until ${retainUntil}`, run.marker, working(n.key, "old", { "retain-until": retainUntil }));
294
+ await q(run, `ALTER TABLE ${n.oldTable} MODIFY COMMENT ${sqlString(comment)}`);
295
+ }
296
+ const dependents = await dependentViews(run, n.database, n.name);
297
+ for (const d of dependents) {
298
+ const [database, name] = d;
299
+ // A Replicated database takes DETACH only as PERMANENTLY, run on every replica.
300
+ const permanently = database === n.database ? o.replicated : await isReplicatedDatabase(run, database);
301
+ await q(run, `DETACH TABLE ${qualifiedIdent(database, name)}${permanently ? " PERMANENTLY" : ""}`);
302
+ await q(run, `ATTACH TABLE ${qualifiedIdent(database, name)}`);
303
+ }
304
+ await q(run, `ALTER TABLE ${n.table} MODIFY COMMENT ${sqlString(stampedComment(run.declared.canonical.comment, run.marker))}`);
305
+ return { state: "swapped", swapped: true, dependents: dependents.map(([d, v]) => `${d}.${v}`), oldTable: `${n.database}.${n.oldName}`, ...(retainUntil ? { retainUntil } : {}) };
306
+ }
307
+ async function isReplicatedDatabase(run, database) {
308
+ const [row] = await clickhouseQuery(run.target.endpoint, `SELECT engine FROM system.databases WHERE name = ${sqlString(database)}`);
309
+ return row?.engine === "Replicated";
310
+ }
311
+ async function objectComment(run, database, name) {
312
+ const [row] = await clickhouseQuery(run.target.endpoint, `SELECT comment FROM system.tables WHERE database = ${sqlString(database)} AND name = ${sqlString(name)}`);
313
+ return row?.comment;
314
+ }
315
+ /** The materialized views that read `database.name`, as `system.tables` lists them. */
316
+ export async function dependentViews(run, database, name) {
317
+ const [row] = await clickhouseQuery(run.target.endpoint, `SELECT dependencies_database, dependencies_table FROM system.tables WHERE database = ${sqlString(database)} AND name = ${sqlString(name)}`);
318
+ if (!row)
319
+ return [];
320
+ return row.dependencies_table.map((t, i) => [row.dependencies_database[i], t]);
321
+ }
322
+ // ── retain and drop ────────────────────────────────────────────────────
323
+ function retention(o) {
324
+ const until = o.old?.pairs?.get("retain-until");
325
+ return until ? { retainUntil: until } : {};
326
+ }
327
+ /** Where the old table is kept and until when, and the digest the drop gate binds. */
328
+ export async function retainPlan(run) {
329
+ const o = await observe(run);
330
+ if (!o.old)
331
+ return { state: o.state, due: false };
332
+ const retainUntil = o.old.pairs?.get("retain-until");
333
+ const due = retainUntil !== undefined && (await serverNow(run.target)) >= Date.parse(retainUntil);
334
+ const oldTable = `${o.names.database}.${o.names.oldName}`;
335
+ return {
336
+ state: o.state,
337
+ oldTable,
338
+ ...(retainUntil ? { retainUntil } : {}),
339
+ due,
340
+ dropDigest: computePlanDigest("clickhouse-rebuild-drop", { table: o.names.key, old: oldTable, uuid: o.old.uuid, retainUntil: retainUntil ?? null }),
341
+ };
342
+ }
343
+ /** Drop the old table once its retention date has passed. Before then, say until when and drop nothing. */
344
+ export async function dropOldTable(run) {
345
+ const plan = await retainPlan(run);
346
+ if (!plan.oldTable)
347
+ return { state: plan.state, dropped: false };
348
+ if (!plan.due) {
349
+ run.log(`-- ${plan.oldTable} is kept until ${plan.retainUntil}; a run after that drops it`);
350
+ return { state: plan.state, dropped: false, oldTable: plan.oldTable, ...(plan.retainUntil ? { retainUntil: plan.retainUntil } : {}) };
351
+ }
352
+ const n = rebuildNames(run.declared.canonical.database ?? run.target.defaultDatabase, run.declared.canonical.name);
353
+ await q(run, `DROP TABLE ${n.oldTable} SYNC`);
354
+ return { state: "done", dropped: true, oldTable: plan.oldTable };
355
+ }
356
+ /**
357
+ * onFailure: drop the dual-write view and the new table, and nothing else.
358
+ * Each is dropped only when its comment names this rebuild and its role and
359
+ * carries this project's marker. After the EXCHANGE the new table is under
360
+ * the table's own name and the old one under the new table's name without
361
+ * `role=new`, so neither is touched: the swap is finished by the next run,
362
+ * not undone. The old table kept after a swap (`role=old`) is never dropped
363
+ * here; the drop gate decides that.
364
+ */
365
+ export async function compensate(run) {
366
+ const n = rebuildNames(run.declared.canonical.database ?? run.target.defaultDatabase, run.declared.canonical.name);
367
+ const rows = await clickhouseQuery(run.target.endpoint, `SELECT name, comment FROM system.tables WHERE database = ${sqlString(n.database)} AND name IN (${sqlString(n.dualName)}, ${sqlString(n.newName)})`);
368
+ const dropped = [];
369
+ for (const [name, role, what] of [
370
+ [n.dualName, "dual", "VIEW"],
371
+ [n.newName, "new", "TABLE"],
372
+ ]) {
373
+ const row = rows.find((r) => r.name === name);
374
+ const pairs = readTrailerPairs(row?.comment);
375
+ if (!row || pairs?.get(REBUILD_TRAILER_KEY) !== n.key || pairs.get("role") !== role || !carriesMarker(row.comment, run.marker))
376
+ continue;
377
+ const sql = renderFor(`DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`, run.target.topology);
378
+ run.log(sql);
379
+ await clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS });
380
+ dropped.push(`${n.database}.${name}`);
381
+ }
382
+ return { dropped };
383
+ }