feat: report migration progress and let a slow one survive its supervisor - #343
feat: report migration progress and let a slow one survive its supervisor#343FrameAutomata wants to merge 1 commit into
Conversation
…isor Migrations run to completion before the HTTP server binds, and runMigrationsOn executed every statement bare -- no logging, no timing. An index build on a large telemetry database takes tens of seconds (~10s measured on a 2M-row/8.1GB database in #335), so from the outside a boot that was still working looked exactly like one that had hung. Being killed part-way through is not self-repairing, and the two storage families fail differently. golang-migrate marks a version dirty before running it, so ClickHouse and Postgres come back refusing to boot until someone runs force. runMigrationsOn records the version after the statements instead, and because a file's statements are separately auto-committed, a multi-statement file is left half applied -- most are not idempotent (sqlite_telemetry/0017 is five ALTER TABLE ADD COLUMNs before a CREATE INDEX), so the retry dies on "duplicate column name" and every later boot dies the same way. Making that atomic is deliberately left to its own change, per #337: it changes behaviour for every migration, not just the slow ones. What this does is stop supervisors killing the process in the first place, and say what is happening while it works. Log each pending migration before it runs and again with its elapsed time. The announcement comes first so the newest line names whatever is still running. Already-applied migrations are skipped before the log call, so a steady-state restart stays silent and only a boot that actually applies something costs lines. The ClickHouse and Postgres paths use golang-migrate rather than runMigrationsOn, so route migrate's output through config.Logf too. Verbose has to be on to get the same guarantee: migrate names a migration before running it only on its verbose stream, and without it a stall there is silent and the newest line names the migration that already finished. The buffering internals riding that stream are dropped by prefix -- a skip list rather than a keep list, so migrate's error lines still reach the log. Give the chart a startupProbe: Kubernetes withholds liveness and readiness until it first succeeds, so the ~70-100s liveness budget only starts once the process is serving. 100 x 5s is deliberately under the Deployment's default 600s progressDeadlineSeconds, so a migration the probe tolerates is not simultaneously reported as a failed rollout -- which `helm --wait --atomic` or a GitOps self-heal answers by deleting the pod mid-migration, causing exactly what the probe prevents. Kubernetes was not the only supervisor with this problem: the four per-variant images and their compose files gave /health a 5s health-check start period, marking a container unhealthy ~95s in and breaking `--wait`, `depends_on: service_healthy`, and orchestrators that restart on it. Docker ends the start period at the first success, so raising it to 300s only ever costs a slow first boot. Refs #337 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013b8tjKviUGfyVkrF7ccQkm
|
Hard to tell, making migrations transactional means we block during a migration, more consistency BUT the extra safety comes with the penalty of downtime while a migration is happening. Right now in case of a blue green release while a migration is going requests from the old instance will still be served (with the old queries) when the new instance is up the traffic will move to it, unless the db changes shape in a way that the old instance can’t read this will be a 0s downtime deployment. By making this transactional no instance can’t safely serve requests while migration is going. Technically more accurate, but functionally potentially a worse experience. I’ll think about it more. |
|
That tradeoff is real in general, but I think it mostly dissolves here, because the cost and the benefit land on different deployment shapes. Three things I checked: Postgres / ClickHouse — the blue/green shape you're describing. Every file in SQLite / DuckDB — where multi-statement files actually exist, and therefore where the half-applied-file hazard is. But that's the embedded single-process deployment: the database is a local file, so there's no second instance sharing it to serve traffic while the migration runs. Blue/green isn't the model there. And it's opened in WAL ( So the downtime would be bought mainly on the shape that doesn't need the fix, and the shape that does need it isn't the one doing zero-downtime cutovers. Two honest caveats. It stops holding for Postgres the moment a None of this is in this PR either way — it only stops supervisors killing the process mid-migration, and leaves atomicity exactly as it is. Happy to open a separate issue for the deferred piece so it has somewhere to live while you think it over. Generated by Claude Code |
Closes #337.
The problem
runMigrationsOnran every statement as a bareExec— no transaction, no timing, no logging — andmigrations.Runis called synchronously fromcmd/run.gobefore the HTTP server binds. A boot that was still working looked exactly like one that had hung. #335 measured a singleCREATE INDEXonexception_stack_tracesat ~10s on a 2M-row / 8.1GB telemetry database.Being killed part-way through does not repair itself, and I found the two storage families fail differently — the issue described only one of them:
SetVersion(target, **true**)beforeRun(migrate.go:738), so the database comes back with a dirtyschema_migrationsand refuses to boot until someone runsforceThe SQLite case is worse than "re-runs and is killed again".
sqlite_telemetry/0017is fiveALTER TABLE ... ADD COLUMNstatements before aCREATE INDEX; those aren't idempotent, so a kill during the index build means the retry dies onduplicate column name— and so does every boot after it.sqlite/0003,sqlite_telemetry/0009andduckdb_telemetry/0002have the same shape.Making that atomic is deliberately not in this PR, per #337's own note: it changes behaviour for every migration, not just the slow ones, and deserves its own look. What this does is stop supervisors killing the process in the first place, and say what is happening while it works.
What changed
Progress logging. Each pending migration is announced before it runs and reported again with elapsed time. The announcement comes first so the newest line names whatever is still running, not what already finished. Already-applied migrations are skipped before the log call, so a steady-state restart is silent — only a boot that actually applies something costs lines.
The golang-migrate paths too. ClickHouse and Postgres don't go through
runMigrationsOn, and that's the chart's default variant — fixing only the embedded path would have left the exact deployment shape this is about silent.Verbose()has to be on to get the same guarantee: migrate names a migration before running it only on its verbose stream. The buffering internals riding that stream are dropped by prefix — a skip list rather than a keep list, so migrate's own error lines still reach the log.A
startupProbein the chart. Kubernetes withholds liveness and readiness until it first succeeds, so the ~70-100s liveness budget only starts once the process is serving.100 x 5sis deliberately under the Deployment's defaultprogressDeadlineSeconds(600s): a migration the probe tolerates must not simultaneously be reported as a failed rollout, becausehelm --wait --atomicor a GitOps self-heal answers that by deleting the pod mid-migration — causing precisely what the probe prevents. Raising it past that ceiling means raisingprogressDeadlineSecondstoo, which the values comment says.Docker had the same bug. The four per-variant images and their compose files gave
/healtha 5s health-check start period, marking a container unhealthy ~95s in and breaking--wait,depends_on: condition: service_healthy, and orchestrators that restart on health state. Docker ends the start period at the first success, so raising it to 300s only ever costs a slow first boot.CI.
migrate_logger_test.gois build-tagged, and the tagged job only ran./app/repositories/... ./app/chdb/...— so the test would have run in no job at all../app/migrations/...is added to that list, which is the hazard the workflow's own comment already documents forapp/chdb.Validation
go vetandgo testclean on the default tags and ontransactional_pg telemetry_ch(the combo CI uses)gofmtclean; defaultgo build ./...cleanhelm lintpasses; chart renders valid YAML across all three variants, with the probe present by default, removed understartupProbe.enabled=false, and honouring an overriddenfailureThresholdTwo tests are added: one pins the announce-before-work and silent-re-run properties on the embedded path, the other pins that the logger's skip list keeps migrate's error lines visible — swapping it for a keep list would silently swallow the reason a migration failed.
Deliberately left out
#337item 3) — the real fix for the half-applied-file case above, but it changes behaviour for every migrationbackfill.RunDashboards()blocks on advisory lock 824737001 silently, andcache.ProjectCache.InitandPostStartupHooksare pre-bind and silent too. Astep("name", ...)wrapper inrun.gowould generalize this; the startupProbe budget already covers themnotifySystemd()sendsREADY=1only after migrations, so aType=notifyunit with the defaultTimeoutStartSec=90reproduces the same kill.EXTEND_TIMEOUT_USECduring migration is the equivalent fixHappy to file follow-up issues for any of those.
🤖 Generated with Claude Code
https://claude.ai/code/session_013b8tjKviUGfyVkrF7ccQkm
Generated by Claude Code