The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-08-26 · run 2026-08-26-ha-failover-mid-migration

HA: primary crash mid-migration, promoted replica resumes cleanly

Upgrade
26.0.0 → 26.7.1
Scale
200 users, 2 realms
Database
PostgreSQL 16, primary + streaming (async) replica, containers
Topology
1 primary + 1 replica; Keycloak single container
Host
laptop (Docker 29)
Condition
Primary crash + failover, mid-migration
Elapsed
migration ~4s to the crash point; recovery after repoint ~17s

Summary

First run of the HA/failover line. A streaming replica was stood up behind the primary, the migration was started, the primary was SIGKILLed mid-migration (changeset 26.6.0-45009-broker-link-identity-provider), the replica was promoted, Keycloak was repointed at it, and it resumed and finished cleanly.

Four findings, in the order they matter:

  1. The replica is a consistent, half-migrated snapshot. At the crash the replica had 179 of 211 changesets applied — every committed changeset, with the in-flight one rolled back. Streaming replication does not ship a half-applied transaction, so the failover target is never corrupt.

  2. migration_model is the way to see it. At the crash the replica's changelog said 179 changesets but migration_model still said 26.0.0, because the model stamp is written last. The half-migrated state is therefore detectable, not silent — the same "changelog ≠ model" signal S8 taught us to read, now produced by a crash instead of a pooler.

  3. The changelog lock is NOT a stuck-lock hazard across failover. After the crash the replica's DATABASECHANGELOGLOCK rows were LOCKED=f — the crashed primary's lock acquisition did not persist to the standby. Keycloak re-acquires the lock and resumes rather than waiting out a lock held by a dead host.

  4. Keycloak resumes, it does not restart. Re-pointed at the promoted replica, Keycloak ran changesets 180 → 211, stamped the model 26.7.1, and reached ready with all 202 users intact. Index audit clean (119/119).

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
Distribution / start modequay.io/keycloak/keycloak official image, start w/ external DB
DatabasePostgreSQL 16, primary + streaming (async) replica, containers
Replicationwal_level=replica, streaming via pg_basebackup -X stream, wal_keep_size=64MB
Postgres tuningstock (shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0)
Adverse scenario#4 — primary crash + failover, mid-migration
Dataset scale200 users, 2 realms
Seeding methodpartialImport (bin/seed-realm.sh --profile typical)
Topology1 primary + 1 replica; Keycloak single container
Hostlaptop (Docker 29)
JVM heap-Xms1g -Xmx4g
Elapsed clockmigration ~4s to the crash point; recovery after repoint ~17s

Procedure

# primary + keycloak, then seed + snapshot (replica initialised by hand)
COMPOSE_FILE=docker-compose.yml:docker-compose.ha.yml
docker compose up -d db keycloak
./bin/seed-realm.sh --profile typical --users 200
./bin/snapshot.sh baseline-26.0.0-ha-typical-200
./bin/ha-init-replica.sh          # pg_basebackup + standby.signal + primary_conninfo

# failover: start migration, SIGKILL primary mid-flight, promote, repoint
sed -i 's/^KC_VERSION=.*/KC_VERSION=26.7.1/' .env
docker compose up -d keycloak &    # build + migrate
# poll for "Updating database", sleep 1s, then:
docker compose kill db            # SIGKILL, not a clean stop — a crash
./bin/ha-failover.sh              # pg_ctl promote + KC_DB_URL -> db-replica
./bin/index-audit.sh 26.7.1       # DB_SERVICE=db-replica

Results

The crash point

Keycloak was mid-changeset when the primary died:

ERROR: Migration failed for changeset META-INF/jpa-changelog-26.6.0.xml::
  26.6.0-45009-broker-link-identity-provider::keycloak:
  Reason: ... PSQLException: An I/O error occurred while sending to the backend.

The replica (still a standby) held, at that instant:

Replica stateValue
databasechangelog rows179 (of 211)
migration_model26.0.0
databasechangeloglocktwo rows, both LOCKED=f

After promote + repoint

Promoted replica stateValue
databasechangelog rows211
migration_model26.7.1
user_entity202 (unchanged)
index audit119 correct / 0 missing / 0 wrong shape / 0 unverified

The old primary, restarted by depends_on, crash-recovered to its stale 179-changeset state and was then stopped — the promoted replica is the only primary.

What this teaches

Verification

ClaimEvidence
Replica holds a committed-prefix snapshot after crash179/211 changelog rows, model 26.0.0 ☑
Lock is not stuck after failoverdatabasechangeloglock LOCKED=f
Keycloak resumes on the promoted replicachangelog 211, model 26.7.1, ready ☑
Schema correct after recoveryindex audit 119/119 ☑
Data intactuser_entity 202 before and after ☑
Streaming works end-to-endpg_stat_replication streaming, lag ~24µs ☑

← All runs