ch-migrate-cli 0.6.0__tar.gz → 0.7.0__tar.gz

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.

Potentially problematic release.


This version of ch-migrate-cli might be problematic. Click here for more details.

Files changed (100) hide show
  1. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/PKG-INFO +124 -12
  2. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/README.md +123 -11
  3. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/__init__.py +1 -1
  4. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/alembic_env.py +84 -19
  5. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/bootstrap.py +5 -1
  6. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/cli.py +64 -45
  7. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/deps.py +28 -20
  8. ch_migrate_cli-0.7.0/ch_migrate/diff.py +277 -0
  9. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/display.py +3 -1
  10. ch_migrate_cli-0.7.0/ch_migrate/gate.py +59 -0
  11. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/introspect.py +7 -20
  12. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/lint.py +249 -93
  13. ch_migrate_cli-0.7.0/ch_migrate/migration_runner.py +250 -0
  14. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/mv_validate.py +14 -43
  15. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/scaffold.py +2 -6
  16. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/secrets.py +1 -2
  17. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/sql.py +10 -2
  18. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/statements.py +46 -7
  19. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/templates/bootstrap/init_users.sql +2 -0
  20. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/ui.py +4 -4
  21. ch_migrate_cli-0.7.0/ch_migrate/waiting/__init__.py +13 -0
  22. ch_migrate_cli-0.7.0/ch_migrate/waiting/budget.py +47 -0
  23. ch_migrate_cli-0.7.0/ch_migrate/waiting/display.py +265 -0
  24. ch_migrate_cli-0.7.0/ch_migrate/waiting/errors.py +37 -0
  25. ch_migrate_cli-0.7.0/ch_migrate/waiting/mutations.py +264 -0
  26. ch_migrate_cli-0.7.0/ch_migrate/waiting/reruns.py +182 -0
  27. ch_migrate_cli-0.7.0/ch_migrate/waiting/sql.py +36 -0
  28. ch_migrate_cli-0.7.0/ch_migrate/waiting/waiter.py +68 -0
  29. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/conftest.py +35 -2
  30. ch_migrate_cli-0.7.0/tests/integration/test_cloud.py +174 -0
  31. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_lint_pending.py +1 -3
  32. ch_migrate_cli-0.7.0/tests/integration/test_mutation_waiting.py +550 -0
  33. ch_migrate_cli-0.7.0/tests/integration/test_on_cluster.py +199 -0
  34. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_cli_output.py +4 -6
  35. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_deps.py +11 -3
  36. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_diff.py +56 -26
  37. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_hooks.py +50 -32
  38. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_lint.py +49 -47
  39. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_mv_validate.py +43 -43
  40. ch_migrate_cli-0.7.0/tests/test_reruns.py +123 -0
  41. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_snapshot.py +5 -2
  42. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_statements.py +53 -0
  43. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_status_command.py +2 -2
  44. ch_migrate_cli-0.7.0/tests/test_waiting.py +77 -0
  45. ch_migrate_cli-0.7.0/tests/test_waiting_display.py +174 -0
  46. ch_migrate_cli-0.6.0/ch_migrate/diff.py +0 -232
  47. ch_migrate_cli-0.6.0/ch_migrate/gate.py +0 -26
  48. ch_migrate_cli-0.6.0/tests/integration/test_cloud.py +0 -63
  49. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/.gitignore +0 -0
  50. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/LICENSE +0 -0
  51. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/authoring.py +0 -0
  52. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/baseline.py +0 -0
  53. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/config.py +0 -0
  54. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/connection.py +0 -0
  55. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/downgrade.py +0 -0
  56. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/env.py +0 -0
  57. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/helpers.py +0 -0
  58. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/hooks.py +0 -0
  59. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/idempotency.py +0 -0
  60. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/py.typed +0 -0
  61. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/rebase.py +0 -0
  62. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/runner.py +0 -0
  63. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/skills/ch-migrate/SKILL.md +0 -0
  64. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/alembic.ini.template +0 -0
  65. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/config.yaml.template +0 -0
  66. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/env.local.example.template +0 -0
  67. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/script.py.mako.template +0 -0
  68. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/ch_migrate/version_table.py +0 -0
  69. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/clickhouse_alembic/__init__.py +0 -0
  70. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/pyproject.toml +0 -0
  71. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/corpus/idempotency.yaml +0 -0
  72. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/fixtures/env_v0_4_1.py +0 -0
  73. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/dialect_spike_probe.py +0 -0
  74. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_cluster_harness.py +0 -0
  75. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_gate.py +0 -0
  76. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_idempotency_advice.py +0 -0
  77. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_irreversible.py +0 -0
  78. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_readme_quickstart.py +0 -0
  79. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_run_sql.py +0 -0
  80. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_session_env.py +0 -0
  81. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_smoke.py +0 -0
  82. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_sql_first.py +0 -0
  83. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_standalone_set.py +0 -0
  84. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/integration/test_version_table.py +0 -0
  85. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_bootstrap.py +0 -0
  86. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_compat_import.py +0 -0
  87. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_config.py +0 -0
  88. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_display.py +0 -0
  89. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_downgrade.py +0 -0
  90. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_helpers.py +0 -0
  91. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_idempotency.py +0 -0
  92. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_introspect.py +0 -0
  93. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_new.py +0 -0
  94. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_package.py +0 -0
  95. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_readme.py +0 -0
  96. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_rebase.py +0 -0
  97. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_scaffold.py +0 -0
  98. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_secrets.py +0 -0
  99. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_sql.py +0 -0
  100. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.7.0}/tests/test_version_table_policy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ch-migrate-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: SQL-first ClickHouse schema migrations across environments: the ch-migrate command
5
5
  Project-URL: Homepage, https://github.com/DRYCodeWorks/ch-migrate
6
6
  Project-URL: Repository, https://github.com/DRYCodeWorks/ch-migrate
@@ -65,8 +65,15 @@ This README describes the source checkout, which may be ahead of PyPI. To try an
65
65
 
66
66
  ## Upgrade from 0.x
67
67
 
68
- 0.6 requires Python 3.10+; 0.5.x is the last line supporting Python 3.9.
69
- A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades to 0.6; pin
68
+ **From 0.6:** 0.7 needs no `upgrade-env`. `up` now waits for the mutations and
69
+ `ON CLUSTER` DDL its statements start (see [Migrations that wait](#migrations-that-wait)),
70
+ so give CI jobs time to finish or pass `--timeout`. Where `bootstrap` couldn't grant
71
+ `SELECT ON system.*`, such as on ClickHouse Cloud, add the grants listed in the
72
+ [CHANGELOG](CHANGELOG.md). A loose requirement such as `ch-migrate-cli` upgrades to
73
+ 0.7; pin `ch-migrate-cli==0.6.0` to stay on 0.6.
74
+
75
+ **From 0.5:** 0.6 and later require Python 3.10+; 0.5.x is the last line supporting Python 3.9.
76
+ A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades past 0.5; pin
70
77
  `ch-migrate-cli==0.5.1` until each project has run the command below.
71
78
  After upgrading the package, run this in each migration project:
72
79
 
@@ -269,13 +276,111 @@ information or the single-server/Cloud default for version-table DDL. Review
269
276
  that DDL before using it for a self-hosted Replicated database. Cloud qualification
270
277
  is separate from the local suite; the local replicated harness has one shard.
271
278
 
279
+ ### Migrations that wait
280
+
281
+ `up` doesn't record a migration as applied until the work its statements started
282
+ has finished. After each statement that can write, it polls `system.mutations` on
283
+ the same checked ClickHouse session, so earlier `SET` statements stay in effect.
284
+ `op.execute`, raw `op.get_bind()` execution and `run_sql` all pass through that
285
+ check. Upgrades run in the CLI process, so killing it can't leave a child Alembic
286
+ runner advancing revisions. `down` waits the same way.
287
+
288
+ ```bash
289
+ ch-migrate up dev # No default waiting timeout
290
+ ch-migrate up dev --timeout 120 # One waiting budget across the invocation
291
+ ```
292
+
293
+ What `up` waits for:
294
+
295
+ - **Every unfinished mutation in the project database,** including one another
296
+ writer started. Before the first migration runs, `up` waits for any already
297
+ queued, and a failed one blocks the run. The version table's own mutations are
298
+ left to [its bookkeeping](#the-version-table).
299
+ - **Each replica that has to apply it.** Zero parts left isn't completion on its
300
+ own: the mutation must report `is_done`. A ReplicatedMergeTree mutation must be
301
+ finished on every replica, and a replica that hasn't received it yet is still
302
+ pending. Configure `cluster` for self-hosted replicated tables, or use a
303
+ Replicated database's automatic cluster; without either, `up` refuses a table
304
+ with more than one replica. SharedMergeTree on ClickHouse Cloud reports one
305
+ shared mutation.
306
+ - **`ON CLUSTER` DDL on every host.** `up` sends these statements with
307
+ `distributed_ddl_output_mode = 'throw'`, so ClickHouse answers once every host
308
+ has run the statement, and a host that fails or doesn't finish in time fails the
309
+ migration with ClickHouse's message. The limit is 270 seconds, below the
310
+ client's read timeout, or what's left of `--timeout` if that's less. Mutations
311
+ the statement starts are then waited for as above.
312
+
313
+ Statements ClickHouse already holds until they finish, such as a type-changing
314
+ `MODIFY COLUMN`, a `MODIFY TTL` that materializes, or a lightweight `DELETE`,
315
+ still hold their request as they did in 0.6.
316
+
317
+ While it waits, `up` shows progress on stderr. In a terminal, each mutation gets a
318
+ live bar weighted by bytes, with its parts and an estimate of the time left:
319
+
320
+ ```text
321
+ → Applying 3f9c2a1b backfill_digest
322
+ ━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━ 18.2 / 41.0 GiB · 23/41 parts · 1:12 · ~1:30 left
323
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 41.0 GiB · 2:03
324
+ ```
325
+
326
+ Self-hosted replicated tables get one row per replica. When output isn't a
327
+ terminal (CI logs, pipes, coding agents), `up` prints plain lines instead: one
328
+ when a wait starts, one at each 10% of the parts or every 30 seconds, and one
329
+ when it finishes:
330
+
331
+ ```text
332
+ → Applying 3f9c2a1b backfill_digest
333
+ waiting on mutation_7.txt (demo_dev.events): 0/41 parts
334
+ waiting on mutation_7.txt (demo_dev.events): 5/41 parts · 0:14
335
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 2:03
336
+ ```
337
+
338
+ Part sizes come from `system.parts`. If the migration role can't read it, the bar
339
+ counts parts only and says so once; progress never changes whether `up` succeeds.
340
+
341
+ **When `up` stops short.** After a timeout, Ctrl-C, dropped connection, killed
342
+ process or failed statement, ClickHouse keeps working and the migration isn't
343
+ recorded. `up` and `down` note each migration in `_ch_migrate_in_progress` when it
344
+ starts and clear the note once its version is recorded, so the next run knows the
345
+ migration was interrupted. That run waits for unfinished work, then:
346
+
347
+ - **Runs the migration again on its own** when every statement in it passes the
348
+ idempotency gate without a waiver, so a repeat leaves the same result.
349
+ - **Stops before sending anything** when the migration has a waived statement or
350
+ SQL the gate can't read, because that statement may already have applied. It
351
+ names each one. Check what the earlier run changed, fix the SQL if needed, then
352
+ name the revision: `ch-migrate up ENV --rerun-interrupted REV` (or `down`), or
353
+ `-x rerun_interrupted=REV` with `alembic`. Naming it accepts the repeat.
354
+
355
+ Every `UPDATE`, `DELETE` and `INSERT ... SELECT` needs a waiver, so an interrupted
356
+ backfill always takes that second path.
357
+
358
+ **Failed mutation.** ClickHouse's `latest_fail_reason` stops the run, and `up`
359
+ prints the scoped `KILL MUTATION` statement for an operator; ch-migrate never
360
+ runs it. ClickHouse keeps retrying a failed mutation, and later mutations on that
361
+ table queue behind it. If the cause is temporary, such as memory or disk, fix it
362
+ and run `up` again. If the statement itself is wrong, kill the mutation, fix the
363
+ SQL and run `up` again, naming the revision with `--rerun-interrupted` if it's
364
+ waived. Killing a mutation doesn't undo the parts it already rewrote.
365
+
366
+ Run one migration runner per database, using a CI concurrency key or an external
367
+ lock; the in-progress notes aren't a lock. Re-run `bootstrap` when upgrading a
368
+ restricted migration user to obtain the `system.replicas` and `system.parts` grants.
369
+
272
370
  ### Re-runnable migrations
273
371
 
274
372
  `up` statically checks pending upgrade statements before Alembic executes any of
275
373
  them. Idempotency and standalone-SET errors refuse the whole run and print the source file, line,
276
374
  statement, and suggested fix. There is no command-line bypass; `--skip-mv-check`
277
375
  does not skip this gate. Other findings are warnings in `up`; standalone `lint`
278
- retains their configured severities.
376
+ retains their configured severities. A direct `alembic upgrade` runs the same gate
377
+ from `env.py` before its first migration.
378
+
379
+ The gate reads SQL in `run_sql` files and in literal strings, f-strings and
380
+ `text("...")` passed to `op.execute`, or to `execute` and `exec_driver_sql` on a
381
+ connection such as `op.get_bind()`. SQL that only exists at run time, such as a
382
+ variable, a built string or `run_sql(name)`, can't be checked, so it needs a
383
+ reasoned waiver like any other statement the gate can't vouch for.
279
384
 
280
385
  Static lint rejects a standalone `SET` when `migrations/env.py` lacks the
281
386
  `CH_MIGRATE_ENV_VERSION = 2` marker: that statement would be ignored on the old
@@ -309,7 +414,7 @@ INSERT INTO {db}.target SELECT id FROM {db}.source;
309
414
  ```
310
415
 
311
416
  For inline Python SQL, use the same directive in a `#` comment directly above
312
- `op.execute`. Empty reasons remain errors. Waivers print as INFO with their
417
+ the call that sends it. Empty reasons remain errors. Waivers print as INFO with their
313
418
  reasons in `lint` and `up`; they do not make the statement idempotent.
314
419
  `MODIFY`, `MATERIALIZE`, `CLEAR COLUMN`, `TRUNCATE`, `OPTIMIZE`, `SYSTEM`, grants,
315
420
  revokes, `REPLACE PARTITION`, attaching or detaching a whole partition, and
@@ -318,11 +423,12 @@ The reviewable classification corpus is `tests/corpus/idempotency.yaml`.
318
423
 
319
424
  **Recovery:** fix the cause of a partial failure, then run `ch-migrate up ENV`
320
425
  again. Earlier idempotent statements can run again; the version advances only
321
- when the revision completes. On ClickHouse Cloud, a column that the failed attempt
322
- dropped or renamed stays locked until that change's mutation finishes: a retry
323
- before then fails with "affected by mutation ... which is not finished yet", even
324
- with `IF EXISTS`. Wait for it in `system.mutations`, then run `up` again. Before retrying waived operations, review what the
325
- earlier attempt actually changed.
426
+ when the revision completes. A dropped or renamed column stays locked until that
427
+ change's mutation finishes, and `up` waits for it before running the next
428
+ statement, so a retry after a later failure doesn't hit "affected by mutation ...
429
+ which is not finished yet". If `up` was stopped while it waited, run it again: it
430
+ waits for that mutation, then runs the revision again, unless the revision has a
431
+ statement the gate can't vouch for (see below).
326
432
 
327
433
  **Existing history:** `upgrade-env` records the current local script head(s) in
328
434
  `config.yaml` as `lint.gate_baseline`, preserving YAML comments. It records
@@ -435,7 +541,7 @@ Example: `ch-migrate new dev add_status --table logs`
435
541
 
436
542
  ### `up`
437
543
 
438
- `ch-migrate up ENV [-r REV] [--skip-mv-check] [--verbose]` applies migrations to `head` by default after the idempotency gate passes, printing one line per migration. `-r/--revision` selects a target. `--skip-mv-check` skips nonblocking materialized-view declaration checks, not the idempotency gate; use it only after reviewing those findings. If a migration fails, `up` names it, the SQL file, the statement and its line, and ClickHouse's error; `--verbose` adds the Python traceback.
544
+ `ch-migrate up ENV [-r REV] [--timeout SECONDS] [--skip-mv-check] [--verbose]` applies migrations to `head` by default after the idempotency gate passes, printing one line per migration. `-r/--revision` selects a target. `--timeout` is a finite positive waiting limit; omitting it waits without a deadline. `--skip-mv-check` skips nonblocking materialized-view declaration checks, not the idempotency gate; use it only after reviewing those findings. If a migration fails, `up` names it, the SQL file, the statement and its line, and ClickHouse's error; `--verbose` adds the Python traceback.
439
545
 
440
546
  Example: `ch-migrate up dev --revision abc123`
441
547
 
@@ -549,7 +655,13 @@ The legacy `CH_<ENV>_PASSWORD` remains supported. Never commit credentials or pa
549
655
 
550
656
  ### Hooks
551
657
 
552
- Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the migration batch; `post_migrate` runs after each revision. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do not put secrets in it.
658
+ Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the
659
+ migration batch; `post_migrate` runs after each revision body **before its version
660
+ is completed**, so hook mutations cannot escape the waiting barrier. A resumed
661
+ upgrade reuses its unfinished batch's receipts instead of repeating completed
662
+ hook writes. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through
663
+ the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do
664
+ not put secrets in it.
553
665
 
554
666
  ```yaml
555
667
  hooks:
@@ -23,8 +23,15 @@ This README describes the source checkout, which may be ahead of PyPI. To try an
23
23
 
24
24
  ## Upgrade from 0.x
25
25
 
26
- 0.6 requires Python 3.10+; 0.5.x is the last line supporting Python 3.9.
27
- A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades to 0.6; pin
26
+ **From 0.6:** 0.7 needs no `upgrade-env`. `up` now waits for the mutations and
27
+ `ON CLUSTER` DDL its statements start (see [Migrations that wait](#migrations-that-wait)),
28
+ so give CI jobs time to finish or pass `--timeout`. Where `bootstrap` couldn't grant
29
+ `SELECT ON system.*`, such as on ClickHouse Cloud, add the grants listed in the
30
+ [CHANGELOG](CHANGELOG.md). A loose requirement such as `ch-migrate-cli` upgrades to
31
+ 0.7; pin `ch-migrate-cli==0.6.0` to stay on 0.6.
32
+
33
+ **From 0.5:** 0.6 and later require Python 3.10+; 0.5.x is the last line supporting Python 3.9.
34
+ A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades past 0.5; pin
28
35
  `ch-migrate-cli==0.5.1` until each project has run the command below.
29
36
  After upgrading the package, run this in each migration project:
30
37
 
@@ -227,13 +234,111 @@ information or the single-server/Cloud default for version-table DDL. Review
227
234
  that DDL before using it for a self-hosted Replicated database. Cloud qualification
228
235
  is separate from the local suite; the local replicated harness has one shard.
229
236
 
237
+ ### Migrations that wait
238
+
239
+ `up` doesn't record a migration as applied until the work its statements started
240
+ has finished. After each statement that can write, it polls `system.mutations` on
241
+ the same checked ClickHouse session, so earlier `SET` statements stay in effect.
242
+ `op.execute`, raw `op.get_bind()` execution and `run_sql` all pass through that
243
+ check. Upgrades run in the CLI process, so killing it can't leave a child Alembic
244
+ runner advancing revisions. `down` waits the same way.
245
+
246
+ ```bash
247
+ ch-migrate up dev # No default waiting timeout
248
+ ch-migrate up dev --timeout 120 # One waiting budget across the invocation
249
+ ```
250
+
251
+ What `up` waits for:
252
+
253
+ - **Every unfinished mutation in the project database,** including one another
254
+ writer started. Before the first migration runs, `up` waits for any already
255
+ queued, and a failed one blocks the run. The version table's own mutations are
256
+ left to [its bookkeeping](#the-version-table).
257
+ - **Each replica that has to apply it.** Zero parts left isn't completion on its
258
+ own: the mutation must report `is_done`. A ReplicatedMergeTree mutation must be
259
+ finished on every replica, and a replica that hasn't received it yet is still
260
+ pending. Configure `cluster` for self-hosted replicated tables, or use a
261
+ Replicated database's automatic cluster; without either, `up` refuses a table
262
+ with more than one replica. SharedMergeTree on ClickHouse Cloud reports one
263
+ shared mutation.
264
+ - **`ON CLUSTER` DDL on every host.** `up` sends these statements with
265
+ `distributed_ddl_output_mode = 'throw'`, so ClickHouse answers once every host
266
+ has run the statement, and a host that fails or doesn't finish in time fails the
267
+ migration with ClickHouse's message. The limit is 270 seconds, below the
268
+ client's read timeout, or what's left of `--timeout` if that's less. Mutations
269
+ the statement starts are then waited for as above.
270
+
271
+ Statements ClickHouse already holds until they finish, such as a type-changing
272
+ `MODIFY COLUMN`, a `MODIFY TTL` that materializes, or a lightweight `DELETE`,
273
+ still hold their request as they did in 0.6.
274
+
275
+ While it waits, `up` shows progress on stderr. In a terminal, each mutation gets a
276
+ live bar weighted by bytes, with its parts and an estimate of the time left:
277
+
278
+ ```text
279
+ → Applying 3f9c2a1b backfill_digest
280
+ ━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━ 18.2 / 41.0 GiB · 23/41 parts · 1:12 · ~1:30 left
281
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 41.0 GiB · 2:03
282
+ ```
283
+
284
+ Self-hosted replicated tables get one row per replica. When output isn't a
285
+ terminal (CI logs, pipes, coding agents), `up` prints plain lines instead: one
286
+ when a wait starts, one at each 10% of the parts or every 30 seconds, and one
287
+ when it finishes:
288
+
289
+ ```text
290
+ → Applying 3f9c2a1b backfill_digest
291
+ waiting on mutation_7.txt (demo_dev.events): 0/41 parts
292
+ waiting on mutation_7.txt (demo_dev.events): 5/41 parts · 0:14
293
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 2:03
294
+ ```
295
+
296
+ Part sizes come from `system.parts`. If the migration role can't read it, the bar
297
+ counts parts only and says so once; progress never changes whether `up` succeeds.
298
+
299
+ **When `up` stops short.** After a timeout, Ctrl-C, dropped connection, killed
300
+ process or failed statement, ClickHouse keeps working and the migration isn't
301
+ recorded. `up` and `down` note each migration in `_ch_migrate_in_progress` when it
302
+ starts and clear the note once its version is recorded, so the next run knows the
303
+ migration was interrupted. That run waits for unfinished work, then:
304
+
305
+ - **Runs the migration again on its own** when every statement in it passes the
306
+ idempotency gate without a waiver, so a repeat leaves the same result.
307
+ - **Stops before sending anything** when the migration has a waived statement or
308
+ SQL the gate can't read, because that statement may already have applied. It
309
+ names each one. Check what the earlier run changed, fix the SQL if needed, then
310
+ name the revision: `ch-migrate up ENV --rerun-interrupted REV` (or `down`), or
311
+ `-x rerun_interrupted=REV` with `alembic`. Naming it accepts the repeat.
312
+
313
+ Every `UPDATE`, `DELETE` and `INSERT ... SELECT` needs a waiver, so an interrupted
314
+ backfill always takes that second path.
315
+
316
+ **Failed mutation.** ClickHouse's `latest_fail_reason` stops the run, and `up`
317
+ prints the scoped `KILL MUTATION` statement for an operator; ch-migrate never
318
+ runs it. ClickHouse keeps retrying a failed mutation, and later mutations on that
319
+ table queue behind it. If the cause is temporary, such as memory or disk, fix it
320
+ and run `up` again. If the statement itself is wrong, kill the mutation, fix the
321
+ SQL and run `up` again, naming the revision with `--rerun-interrupted` if it's
322
+ waived. Killing a mutation doesn't undo the parts it already rewrote.
323
+
324
+ Run one migration runner per database, using a CI concurrency key or an external
325
+ lock; the in-progress notes aren't a lock. Re-run `bootstrap` when upgrading a
326
+ restricted migration user to obtain the `system.replicas` and `system.parts` grants.
327
+
230
328
  ### Re-runnable migrations
231
329
 
232
330
  `up` statically checks pending upgrade statements before Alembic executes any of
233
331
  them. Idempotency and standalone-SET errors refuse the whole run and print the source file, line,
234
332
  statement, and suggested fix. There is no command-line bypass; `--skip-mv-check`
235
333
  does not skip this gate. Other findings are warnings in `up`; standalone `lint`
236
- retains their configured severities.
334
+ retains their configured severities. A direct `alembic upgrade` runs the same gate
335
+ from `env.py` before its first migration.
336
+
337
+ The gate reads SQL in `run_sql` files and in literal strings, f-strings and
338
+ `text("...")` passed to `op.execute`, or to `execute` and `exec_driver_sql` on a
339
+ connection such as `op.get_bind()`. SQL that only exists at run time, such as a
340
+ variable, a built string or `run_sql(name)`, can't be checked, so it needs a
341
+ reasoned waiver like any other statement the gate can't vouch for.
237
342
 
238
343
  Static lint rejects a standalone `SET` when `migrations/env.py` lacks the
239
344
  `CH_MIGRATE_ENV_VERSION = 2` marker: that statement would be ignored on the old
@@ -267,7 +372,7 @@ INSERT INTO {db}.target SELECT id FROM {db}.source;
267
372
  ```
268
373
 
269
374
  For inline Python SQL, use the same directive in a `#` comment directly above
270
- `op.execute`. Empty reasons remain errors. Waivers print as INFO with their
375
+ the call that sends it. Empty reasons remain errors. Waivers print as INFO with their
271
376
  reasons in `lint` and `up`; they do not make the statement idempotent.
272
377
  `MODIFY`, `MATERIALIZE`, `CLEAR COLUMN`, `TRUNCATE`, `OPTIMIZE`, `SYSTEM`, grants,
273
378
  revokes, `REPLACE PARTITION`, attaching or detaching a whole partition, and
@@ -276,11 +381,12 @@ The reviewable classification corpus is `tests/corpus/idempotency.yaml`.
276
381
 
277
382
  **Recovery:** fix the cause of a partial failure, then run `ch-migrate up ENV`
278
383
  again. Earlier idempotent statements can run again; the version advances only
279
- when the revision completes. On ClickHouse Cloud, a column that the failed attempt
280
- dropped or renamed stays locked until that change's mutation finishes: a retry
281
- before then fails with "affected by mutation ... which is not finished yet", even
282
- with `IF EXISTS`. Wait for it in `system.mutations`, then run `up` again. Before retrying waived operations, review what the
283
- earlier attempt actually changed.
384
+ when the revision completes. A dropped or renamed column stays locked until that
385
+ change's mutation finishes, and `up` waits for it before running the next
386
+ statement, so a retry after a later failure doesn't hit "affected by mutation ...
387
+ which is not finished yet". If `up` was stopped while it waited, run it again: it
388
+ waits for that mutation, then runs the revision again, unless the revision has a
389
+ statement the gate can't vouch for (see below).
284
390
 
285
391
  **Existing history:** `upgrade-env` records the current local script head(s) in
286
392
  `config.yaml` as `lint.gate_baseline`, preserving YAML comments. It records
@@ -393,7 +499,7 @@ Example: `ch-migrate new dev add_status --table logs`
393
499
 
394
500
  ### `up`
395
501
 
396
- `ch-migrate up ENV [-r REV] [--skip-mv-check] [--verbose]` applies migrations to `head` by default after the idempotency gate passes, printing one line per migration. `-r/--revision` selects a target. `--skip-mv-check` skips nonblocking materialized-view declaration checks, not the idempotency gate; use it only after reviewing those findings. If a migration fails, `up` names it, the SQL file, the statement and its line, and ClickHouse's error; `--verbose` adds the Python traceback.
502
+ `ch-migrate up ENV [-r REV] [--timeout SECONDS] [--skip-mv-check] [--verbose]` applies migrations to `head` by default after the idempotency gate passes, printing one line per migration. `-r/--revision` selects a target. `--timeout` is a finite positive waiting limit; omitting it waits without a deadline. `--skip-mv-check` skips nonblocking materialized-view declaration checks, not the idempotency gate; use it only after reviewing those findings. If a migration fails, `up` names it, the SQL file, the statement and its line, and ClickHouse's error; `--verbose` adds the Python traceback.
397
503
 
398
504
  Example: `ch-migrate up dev --revision abc123`
399
505
 
@@ -507,7 +613,13 @@ The legacy `CH_<ENV>_PASSWORD` remains supported. Never commit credentials or pa
507
613
 
508
614
  ### Hooks
509
615
 
510
- Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the migration batch; `post_migrate` runs after each revision. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do not put secrets in it.
616
+ Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the
617
+ migration batch; `post_migrate` runs after each revision body **before its version
618
+ is completed**, so hook mutations cannot escape the waiting barrier. A resumed
619
+ upgrade reuses its unfinished batch's receipts instead of repeating completed
620
+ hook writes. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through
621
+ the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do
622
+ not put secrets in it.
511
623
 
512
624
  ```yaml
513
625
  hooks:
@@ -5,7 +5,7 @@ Usage:
5
5
  from ch_migrate import read_sql, get_db, get_env_config, create_dictionary
6
6
  """
7
7
 
8
- __version__ = "0.6.0"
8
+ __version__ = "0.7.0"
9
9
 
10
10
 
11
11
  from typing import Any
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import os
7
+ from functools import partial
7
8
  from logging.config import fileConfig
8
9
  from pathlib import Path
9
10
  from types import SimpleNamespace
@@ -18,6 +19,7 @@ from dotenv import load_dotenv
18
19
  from sqlalchemy import URL, Column, MetaData, String, Table, create_engine, pool, text
19
20
  from sqlalchemy.sql.dml import Insert
20
21
 
22
+ from ch_migrate import ui
21
23
  from ch_migrate.config import get_env_config
22
24
  from ch_migrate.hooks import HookRegistry, run_hooks
23
25
  from ch_migrate.rebase import _literal_assignment
@@ -28,6 +30,8 @@ from ch_migrate.version_table import (
28
30
  inspect_version_table,
29
31
  sync_version_replica,
30
32
  )
33
+ from ch_migrate.waiting import MigrationWaiter
34
+ from ch_migrate.waiting.reruns import RerunGuard
31
35
 
32
36
  ENV_VERSION = 2
33
37
  DEFAULT_SESSION_TIMEOUT = 1800
@@ -96,18 +100,23 @@ def run() -> None:
96
100
  """Run the selected environment on one official-dialect session."""
97
101
  root = Path.cwd()
98
102
  load_dotenv(root / ".env.local")
99
- env_config = get_env_config(os.environ.get("CH_ENVIRONMENT", "dev"), root / "config.yaml")
103
+ # `ch-migrate up` runs Alembic in its own process and names the environment directly.
104
+ environment = context.config.attributes.get(
105
+ "ch_migrate_environment", os.environ.get("CH_ENVIRONMENT", "dev")
106
+ )
107
+ env_config = get_env_config(environment, root / "config.yaml")
100
108
  os.environ["CH_DATABASE"] = env_config["database"]
101
109
  if env_config.get("cluster"):
102
110
  os.environ["CH_CLUSTER"] = env_config["cluster"]
103
111
  else:
104
112
  os.environ.pop("CH_CLUSTER", None)
105
113
  if context.config.config_file_name is not None:
106
- fileConfig(context.config.config_file_name)
114
+ fileConfig(context.config.config_file_name, disable_existing_loggers=False)
115
+ _route_logs(context.config.attributes.get("ch_migrate_log_handler"))
107
116
  if context.is_offline_mode():
108
117
  _run_offline(env_config)
109
118
  else:
110
- _run_online(env_config)
119
+ _run_online(env_config, environment)
111
120
 
112
121
 
113
122
  def has_current_env(path: Path) -> bool:
@@ -118,6 +127,18 @@ def has_current_env(path: Path) -> bool:
118
127
  )
119
128
 
120
129
 
130
+ def _route_logs(handler: logging.Handler | None) -> None:
131
+ # `ch-migrate up` reports each migration in its own words, so Alembic's and
132
+ # ch-migrate's records go to its handler instead of alembic.ini's console handler.
133
+ if handler is None:
134
+ return
135
+ for name in ("alembic", "ch_migrate"):
136
+ named = logging.getLogger(name)
137
+ named.handlers = [handler]
138
+ named.propagate = False
139
+ named.setLevel(logging.INFO)
140
+
141
+
121
142
  def _run_offline(env_config: dict[str, Any]) -> None:
122
143
  context.configure(
123
144
  url=_url(env_config),
@@ -135,7 +156,7 @@ def _run_offline(env_config: dict[str, Any]) -> None:
135
156
  context.run_migrations()
136
157
 
137
158
 
138
- def _run_online(env_config: dict[str, Any]) -> None:
159
+ def _run_online(env_config: dict[str, Any], environment: str) -> None:
139
160
  # The session lives on whichever replica its connection reached: ClickHouse Cloud routes
140
161
  # each TCP connection, not each session id. A pool of its own keeps the session on one
141
162
  # connection instead of sharing the driver's default pool with other in-process clients.
@@ -146,36 +167,56 @@ def _run_online(env_config: dict[str, Any]) -> None:
146
167
  )
147
168
  try:
148
169
  with engine.connect() as connection:
149
- _run_connection(connection, env_config)
170
+ _run_connection(connection, env_config, environment)
150
171
  finally:
151
172
  engine.dispose()
152
173
  session_pool.clear()
153
174
 
154
175
 
155
- def _run_connection(connection, env_config: dict[str, Any]) -> None:
176
+ def _run_connection(connection, env_config: dict[str, Any], environment: str) -> None:
156
177
  hooks = HookRegistry.from_config(env_config.get("hooks"))
157
- database = env_config["database"]
178
+ state = _checked_session(connection, env_config)
179
+ context.configure(
180
+ connection=connection,
181
+ target_metadata=None,
182
+ version_table="alembic_version",
183
+ version_table_schema=state.database,
184
+ on_version_apply=_post_migrate(hooks, state.database),
185
+ ch_migrate_version_state=state,
186
+ )
187
+ waiter = MigrationWaiter(connection, state, context.config.attributes.get("ch_migrate_timeout"))
188
+ guard = _rerun_guard(waiter.client, state, environment)
189
+ try:
190
+ waiter.install(context.config)
191
+ with context.begin_transaction():
192
+ # Work an earlier run left unfinished finishes before anything is sent again.
193
+ waiter.wait(before_run=True)
194
+ run_hooks(
195
+ connection,
196
+ hooks.pre_migrate,
197
+ db=state.database,
198
+ phase="pre_migrate",
199
+ revision="all",
200
+ )
201
+ runtime = context.get_context()
202
+ runtime._migrations_fn = guard.steps(runtime._migrations_fn)
203
+ context.run_migrations()
204
+ finally:
205
+ waiter.close()
206
+
207
+
208
+ def _checked_session(connection, env_config: dict[str, Any]) -> VersionTableState:
158
209
  # Establish the session before requiring its existence on every later request.
159
210
  connection.exec_driver_sql("SELECT 1")
160
211
  client = connection.connection.dbapi_connection.client
161
212
  client.set_client_setting("session_check", 1)
162
- state = inspect_version_table(client, database, env_config.get("cluster"))
213
+ state = inspect_version_table(client, env_config["database"], env_config.get("cluster"))
163
214
  assert_version_mutations_healthy(client, state)
164
215
  state = sync_version_replica(client, state)
165
216
  if warning := state.warning():
166
217
  logger.warning(warning)
167
218
  connection.dialect.ddl_compiler = VersionTableDDLCompiler
168
- context.configure(
169
- connection=connection,
170
- target_metadata=None,
171
- version_table="alembic_version",
172
- version_table_schema=database,
173
- on_version_apply=_post_migrate(hooks, database),
174
- ch_migrate_version_state=state,
175
- )
176
- with context.begin_transaction():
177
- run_hooks(connection, hooks.pre_migrate, db=database, phase="pre_migrate", revision="all")
178
- context.run_migrations()
219
+ return state
179
220
 
180
221
 
181
222
  def _url(env_config: dict[str, Any]) -> URL:
@@ -210,3 +251,27 @@ def _post_migrate(hooks: HookRegistry, database: str):
210
251
  )
211
252
 
212
253
  return after_revision
254
+
255
+
256
+ def _rerun_guard(client, state: VersionTableState, environment: str) -> RerunGuard:
257
+ attributes = context.config.attributes
258
+ # `ch-migrate up` passes --rerun-interrupted directly; `down` and a direct alembic
259
+ # command pass it as -x rerun_interrupted=REV[,REV].
260
+ named = attributes.get("ch_migrate_rerun_interrupted") or context.get_x_argument(
261
+ as_dictionary=True
262
+ ).get("rerun_interrupted", "").split(",")
263
+ guard = RerunGuard(client, state, frozenset(name.strip() for name in named if name.strip()))
264
+ if not attributes.get("ch_migrate_gate_checked"):
265
+ # A direct `alembic upgrade` hasn't been through `ch-migrate up`'s gate.
266
+ guard.before_first_upgrade = partial(_enforce_gate, environment)
267
+ return guard
268
+
269
+
270
+ def _enforce_gate(environment: str) -> None:
271
+ from ch_migrate.gate import enforce_up_gate # lint imports this module
272
+
273
+ try:
274
+ enforce_up_gate(Path.cwd(), environment)
275
+ except ui.Failure as failure:
276
+ failure.show()
277
+ raise SystemExit(1) from None
@@ -147,6 +147,8 @@ def build_bootstrap_sql(
147
147
  f"GRANT SELECT ON system.databases TO {project}_migration_role;",
148
148
  f"GRANT SELECT ON system.tables TO {project}_migration_role;",
149
149
  f"GRANT SELECT ON system.mutations TO {project}_migration_role;",
150
+ f"GRANT CURRENT GRANTS(SELECT ON system.replicas) TO {project}_migration_role;",
151
+ f"GRANT CURRENT GRANTS(SELECT ON system.parts) TO {project}_migration_role;",
150
152
  f"GRANT CURRENT GRANTS(SYSTEM SYNC REPLICA ON {db}.*) TO {project}_migration_role;",
151
153
  f"GRANT CURRENT GRANTS(SYSTEM SYNC DATABASE REPLICA ON {db}.*) TO {project}_migration_role;",
152
154
  f"GRANT CURRENT GRANTS(CLUSTER ON *.*) TO {project}_migration_role;",
@@ -324,7 +326,9 @@ def run_bootstrap(
324
326
  secure = env_config.get("secure", True)
325
327
  port = env_config.get("port", 8443 if secure else 8123)
326
328
 
327
- ui.step(f"Bootstrapping {env_name}: database {env_config['database']} on {host}, as {admin_user}")
329
+ ui.step(
330
+ f"Bootstrapping {env_name}: database {env_config['database']} on {host}, as {admin_user}"
331
+ )
328
332
  ui.detail(f"Migration user: {migration_user}")
329
333
  if dict_reader_name:
330
334
  ui.detail(f"Dict reader: {dict_reader_name}")