Skip to main content
← Back to list
01Issue
FeatureClosedSwamp Club
AssigneesNone

Relationships

#861 feat(clickhouse): idempotent DDL migration path for running prod (#859 deliverable 2)

Opened by keeb · 6/27/2026

Deferred deliverable 2 of #859. The clickhouse-init-schema ConfigMap only runs on an empty data dir, so schema changes don't apply to a running prod ClickHouse. Build an idempotent, versioned DDL migration mechanism — e.g. a content-hash-suffixed one-shot Job that applies CREATE/ALTER ... IF NOT EXISTS to the live server, plus a migrator NetworkPolicy — so epic #847 Units 4-5 (score_grants + materialized views) can ship schema to prod. Include the MATERIALIZE COLUMN backfill story for new MATERIALIZED columns. The approved plan (v2) on #859's lifecycle has the full design.

02Bog Flow
OPENTRIAGEDIN PROGRESSCLOSED

Closed

6/27/2026, 3:53:23 PM

No activity in this phase yet.

03Sludge Pulse
Editable. Press Enter to edit.

keeb commented 6/27/2026, 3:44:43 PM

Built and proven against live prod (clickhouse-v2). The clickhouse-migrations model is an idempotent, content-hash-versioned one-shot Job that applies ordered NNN-*.sql (CREATE/ALTER ... IF NOT EXISTS) to the running server at clickhouse.clickhouse.svc:9000, with a migrator NetworkPolicy and pinned to the dedicated clickhouse node pool.

Proof: first apply created swamp.schema_migrations on the live server; bumping the content hash (171cc398 -> a9f55555) spawned a new Job that applied an additive ALTER; events kept ingesting untouched. Units 4-5 just drop their DDL into a new 00N-*.sql + bump the hash.

PR: https://github.com/swamp-club/giga-swamp/pull/27 (applied by hand, not CI — by design).

keeb commented 6/27/2026, 3:46:01 PM

How the idempotent DDL migration path works

The problem it solves. The clickhouse-init-schema ConfigMap is mounted at /docker-entrypoint-initdb.d, which ClickHouse only runs on an empty data dir — i.e. first boot. With a retained PVC the data dir is never empty again, so editing that ConfigMap never re-applies. It looks like GitOps-managed schema but is a one-shot bootstrap. This path replaces it with something that applies schema to a running server.

The pieces.

  • Migrations are ordered NNN-*.sql files in a ConfigMap (001-init.sql, 002-..., ...).
  • A one-shot k8s Job mounts that ConfigMap, iterates the files in sorted order, and pipes each into clickhouse-client --host clickhouse.clickhouse.svc.cluster.local --port 9000 — i.e. against the live server, not initdb. backoffLimit retries until the server is Ready.
  • A migrator NetworkPolicy admits the component=migrator pod to reach the server (the default policy only admits telemetry/grafana). Pinned to the dedicated clickhouse node pool.

Why it's idempotent. Every statement is CREATE ... IF NOT EXISTS / ALTER ... ADD COLUMN IF NOT EXISTS. The Job always re-runs the whole set (001..00N), not just the newest file — so the already-applied statements are no-ops and only the new DDL takes effect. That means there's no need to track which migrations ran: the DB's current state plus the IF NOT EXISTS guards are the bookkeeping. Re-running is always safe.

The clever bit — re-run only when content changes. Kubernetes Jobs are immutable: once created you can't change the pod template, and re-applying an unchanged Job is a no-op. That's a feature (no pointless re-runs) but also a trap: if you edit the SQL but the Job keeps its name, apply reports success while nothing re-runs (a false-green). So the Job + ConfigMap names carry a content-hash suffix of the migration payload:

cat migrations/*.sql | sha256sum | cut -c1-8
  • Change/add a .sql → new hash → new Job/ConfigMap nameapply creates a brand-new Job → it runs → new DDL lands.
  • No change → same hash → same name → Job already exists → apply is a no-op → nothing re-runs.

So 'idempotent' operates at two levels: the SQL is idempotent (safe to re-run), and the naming makes apply re-run the Job exactly when — and only when — the migration content actually changed.

Backfilling new MATERIALIZED columns. MATERIALIZED columns compute at INSERT time only, so ALTER ... ADD COLUMN IF NOT EXISTS ... MATERIALIZED ... populates new rows but leaves history empty. To backfill, follow it with ALTER TABLE swamp.events MATERIALIZE COLUMN <col> — ClickHouse recomputes the column for existing parts from the in-table properties string. No archive replay needed (that's reserved for full DR).

Proven live (clickhouse-v2). First apply created swamp.schema_migrations on the running server. Bumping the hash 171cc398 -> a9f55555 (adding 002, an additive ALTER) spawned a new Job that applied just the new statement; events kept ingesting untouched. Units 4-5 = drop score_grants + the materialized views into a new 00N-*.sql, bump the hash, apply.

Sign in to post a ripple.