ClickHouse Schema Migrations
Overview
Section titled “Overview”ClickHouse schema changes are managed through embedded, versioned migrations that follow the same model as PostgreSQL. The runner is wired into the same CLI commands.
| Scenario | What to use |
|---|---|
| Fresh install | frameworks cluster init clickhouse creates databases, applies the baseline schema, and runs migrations |
| Upgrading | frameworks cluster migrate applies pending migrations from pkg/database/sql/clickhouse/migrations/ |
cluster init clickhouse and cluster migrate are idempotent. Each migration
records itself in a per-database _migrations ledger; re-running is a no-op.
Migration structure
Section titled “Migration structure”Migrations are organized by target database, release version, and phase:
pkg/database/sql/clickhouse/migrations/└── periscope/ └── v0.2.31/ ├── expand/ │ ├── 001_add_filename_to_artifacts.sql │ └── 002_add_viewer_node_tracking.sql ├── postdeploy/ │ └── 001_rebuild_artifact_projection.sql └── contract/ └── 001_drop_legacy_artifact_view.sqlThe first path segment after clickhouse/migrations/ must match a configured
ClickHouse database name (today: periscope). The CLI embeds these files,
applies only migrations whose database exists in the manifest, and records
applied files in that database’s _migrations table.
Reconciliation model
Section titled “Reconciliation model”The baseline schema in pkg/database/sql/clickhouse/<db>.sql is the cumulative
current state at the latest release tag. Migrations advance the schema forward
in subsequent releases.
On a fresh install, frameworks cluster init clickhouse:
- Creates the configured databases.
- Applies the baseline schema (DDL is
IF NOT EXISTS, safe to re-run). - Runs every embedded migration up to the cluster’s resolved channel version.
The migration SQL is idempotent (also
IF NOT EXISTS), so DDL re-application no-ops on objects already created by the baseline; the_migrationsledger row is recorded by the same step.
On upgrades, frameworks cluster migrate runs only the migrations whose
ledger row is missing. There is no blind ledger pre-seed: every recorded
migration was produced by an actual SQL apply.
Release and recovery procedure
Section titled “Release and recovery procedure”For a normal platform release, use the lifecycle command that owns ClickHouse, PostgreSQL/YugabyteDB, service, and transition ordering:
frameworks cluster release plan --version vX.Y.Zframeworks cluster release apply --version vX.Y.Z --dry-runframeworks cluster release apply --version vX.Y.Z --yesIt applies expand migrations before platform artifacts, interleaves declared release transitions, applies postdeploy migrations afterward, and always leaves contract migrations deferred.
Use the following lower-level flow only for migration development, diagnosis, or a release-specific recovery runbook:
- Check release notes for required ClickHouse DDL.
- Confirm the phase. Routine rolling upgrades apply
expandmigrations before deploying new binaries.postdeploymigrations cover heavyMODIFY COLUMNrewrites orALTER TABLE … UPDATE/DELETEmutations and run after binaries are live.contractmigrations are explicit cleanup with--yesrequired. - Apply migrations through the CLI:
frameworks cluster migrate validateframeworks cluster migrate --phase expandvalidate is fast and safe to run anywhere — it checks the embedded migration
tree against the layout and per-phase safety rules.
- Dry-run first to see what will change:
frameworks cluster migrate --phase expand --dry-runDry-run runs the role under ansible-playbook --check --diff. The role
explicitly skips every mutating step (ledger table create, SQL apply, ledger
insert) and only reports pending migrations.
- Apply for real:
frameworks cluster migrate --phase expand --to-version v0.5.0--to-version is optional; when omitted the cluster’s resolved channel
version is used.
- Deploy services only when the recovery runbook explicitly proves there are no unhandled release transitions:
frameworks cluster upgrade --all- Run any required postdeploy documented in the release notes:
frameworks cluster migrate --phase postdeploy --to-version v0.5.0Direct cluster upgrade --all does not run declared release transitions and
fails closed when one gates a downstream service. It is not the normal whole
release path.
Moving the physical ClickHouse node
Section titled “Moving the physical ClickHouse node”frameworks cluster clickhouse migrate moves the existing Periscope dataset
to a new Replicated ClickHouse node. This is a data cutover, separate from the
versioned schema-migration commands above. The service endpoints deliberately
split readers from writers:
periscope-ingestandperiscope-meteringfollowwrite_endpoint;periscope-queryfollowsread_endpoint;METERING_SOURCE_IDcontinues to identify the same logical dataset and must not change during the move.
Before the final sync, stop both Ingest and Metering. Kafka buffers analytics events while Ingest is stopped, and Metering’s PostgreSQL cursor retains its exact position. The cutover command requires both confirmations:
frameworks cluster clickhouse migrate cutover \ --from <old-clickhouse-host> \ --ingest-stopped \ --metering-stoppedFor the online endpoint sequence, first set write_endpoint to the new node,
re-provision and resume Ingest, and let its Kafka lag drain. Then re-provision
and resume Metering with the unchanged source ID. Finally set read_endpoint
to the new node and re-provision Query. Do not resume Metering against the old
source after Ingest has begun writing the destination, and do not decommission
the old node until parity, service health, cursor progress, and Purser report
consumption are verified.
Ledger details
Section titled “Ledger details”The role creates <db>._migrations per ClickHouse database with this shape:
CREATE TABLE IF NOT EXISTS _migrations ( version LowCardinality(String), phase LowCardinality(String), seq UInt32, filename String, checksum FixedString(64), applied_at DateTime64(3) DEFAULT now64()) ENGINE = ReplacingMergeTree(applied_at)ORDER BY (version, phase, seq);ReplacingMergeTree collapses retried-insert duplicates on merge. Reads use
argMax(checksum, applied_at) and surface uniqExact(checksum) so a crashed
mid-write that left two distinct checksums under the same key is flagged as a
hard failure, not silently resolved.
If a migration file’s content changes after it has been applied, the role fails on checksum mismatch. Treat applied migrations as immutable.
Writing migrations
Section titled “Writing migrations”Guidelines for contributors:
IF NOT EXISTSis mandatory on everyCREATE TABLE / VIEW / DICTIONARY. This is what makes idempotent re-apply safe across both fresh installs (where the baseline already created the object) and existing clusters. The validator rejects migrations missing this guard.- Prefer expand-only changes in routine upgrades — add columns/tables, avoid drops, avoid type rewrites.
- Use
Nullablefor new columns — old binaries ignore them, rollback is safe. - Update the baseline schema in the same release: edit
pkg/database/sql/clickhouse/<db>.sqlso a fresh install ends in the same state as an upgraded one. The migration runs both on fresh installs (no-op against the freshly applied baseline) and on upgrades (the actual schema change). - One migration per logical change — easier to track and debug.
- Pick the correct phase:
expand: additive, safe alongside old + new binaries.postdeploy: heavyMODIFY COLUMNrewrites,ALTER TABLE … UPDATE/DELETEmutations, materialized-view rebuilds.contract: drops, renames, post-cleanup.
- Large historical rewrites (long
ALTER … UPDATE, MV rebuilds, replay from Kafka) need release runbooks with progress and verification, not incidental DDL inside a migration.
Example expand migration:
ALTER TABLE periscope.artifact_events ADD COLUMN IF NOT EXISTS filename Nullable(String) AFTER internal_name;
ALTER TABLE periscope.artifact_state_current ADD COLUMN IF NOT EXISTS filename Nullable(String) AFTER internal_name;Replicated by default
Section titled “Replicated by default”The baseline provisions the analytics databases with a Replicated database engine
(ENGINE = Replicated('/clickhouse/databases/<db>/{shard}', '{shard}', '{replica}')) and
Replicated* table engines, backed by a Keeper ensemble. A Replicated database propagates
DDL to every replica automatically, so migrations use plain CREATE/ALTER statements and
must not add ON CLUSTER '{cluster}' — it is unnecessary here and can conflict with the
database’s own DDL replication:
ALTER TABLE periscope.artifact_events ADD COLUMN IF NOT EXISTS filename Nullable(String) AFTER internal_name;Keeper is required in every topology. The baseline creates the Replicated database and
Replicated* tables unconditionally, and those engines cannot exist without Keeper. A singleton
(dev / single-node) therefore still runs a one-node standalone clickhouse-keeper service — a
separate systemd unit (/etc/clickhouse-keeper/keeper_config.xml) that the Ansible role configures
and orders before clickhouse-server, not an in-process/embedded Keeper — and applies the exact same
migration files with a single shard/replica; only the Keeper ensemble size differs from production.
The role’s empty-clickhouse_keeper_nodes “bare” mode creates an ordinary Atomic database with no
Keeper and is not compatible with this always-Replicated analytics baseline; do not use it for
the analytics databases.
Rollback
Section titled “Rollback”- Nullable columns are safe to leave — older service versions simply won’t populate them.
- Don’t drop columns during rollback unless explicitly required.
- Re-deploying an older service version is usually sufficient.
- Do not rely on DDL rollback for normal recovery — keep expand changes compatible, redeploy the previous binary, and leave extra columns in place until the contract release.
Troubleshooting
Section titled “Troubleshooting”“Unknown column” errors:
Code: 16. DB::Exception: Unknown column 'filename' in column listCause: Service deployed before migration was applied. Fix: Apply the migration, then restart the service.
Check ledger contents:
SELECT version, phase, seq, filename, checksum, applied_atFROM periscope._migrationsORDER BY version, phase, seqFORMAT Vertical;Check if a column exists:
SELECT name, type FROM system.columnsWHERE database = 'periscope' AND table = 'artifact_events' AND name = 'filename';