The Keycloak Upgrade Ledger

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

2026-08-25 · run 2026-08-25-s8-pgbouncer-transaction-mode

S8: transaction pooling breaks the upgrade, and this is what half-migrated looks like

Upgrade
26.0.0 → 26.7.1
Scale
1,002 users, 2 realms
Database
PostgreSQL 16.15, single node, container
Topology
single container + pgbouncer sidecar
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
Condition
Pooler in transaction mode

Summary

PgBouncer in transaction pooling mode makes the first start after an upgrade fail, deterministically, and it leaves the database in the inconsistent state this lab has spent all day failing to produce by other means.

Three reproductions, three identical failures, in 16–19 seconds each:

ERROR: Failed to start server in (production) mode
ERROR: Cannot invoke "org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockService
       .waitForLock(org.keycloak.models.dblock.DBLockProvider$Namespace)"
       because "this.lockService" is null

The state it leaves behind is the important part. DATABASECHANGELOG is at 211 rows — the migration completed and committed — while migration_model still reads 26.0.0. The schema is on the new version; the model stamp and the model-level migrations are not. A verification that counts changelog rows would call this a successful upgrade.

The same pooler in session mode upgrades cleanly in 18 seconds. The variable is the pooling mode, not the pooler.

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
Distribution / start modequay.io/keycloak/keycloak official image, start w/ external DB
DatabasePostgreSQL 16.15, single node, container
Postgres tuningstock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0
PoolerPgBouncer 1.23.1 (edoburu/pgbouncer:v1.23.1-p2), default_pool_size=5, max_client_conn=100, auth_type=scram-sha-256
Adverse scenarioS8 — pool_mode is the variable
Dataset scale1,002 users, 2 realms
Seeding methodpartialImport (bin/seed-realm.sh --profile typical)
Fixture profiletypical; provider jars removed so S7's fixtures could not confound this
Topologysingle container + pgbouncer sidecar
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g
Lab changedocker-compose.pgbouncer.yml added; KC_DB_URL made env-driven; restore.sh hardened

Procedure

./bin/restore.sh baseline-26.0.0-typical-1k

# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.dbidentity.yml:docker-compose.pgbouncer.yml
KC_DB_URL=jdbc:postgresql://pgbouncer:6432/keycloak
POOL_MODE=transaction            # or session, for the control

docker compose up -d pgbouncer
sed -i 's/^KC_VERSION=.*/KC_VERSION=26.7.1/' .env
docker compose up -d --force-recreate keycloak

Connectivity through the pooler was confirmed independently before each run (psql postgresql://keycloak:keycloak@127.0.0.1:6432/keycloak -tAc 'select 1'1), so a failure could not be mistaken for the pooler simply not working.

Results

Runpool_modeOutcomeTimemigration_modelDATABASECHANGELOGUsers
Controlsessionready18s26.7.12111,002
Atransactionexit 126.0.02111,002
B (repro)transactionexit 116s26.0.02111,002
C (repro)transactionexit 119s26.0.02111,002
Restart after Atransactionready14s26.7.12111,002
Restart againtransactionready33s26.7.12111,002
Recoverydirect, no poolerready32s26.7.12111,002

Runs B and C started from a freshly restored 26.0.0 baseline (cl=144) each time, and both reached cl=211 before dying. The failure is not a race — it is what happens every time work has to be done through a transaction-pooled connection.

The order of events

21:05:00  container starts
21:05:10  INFO  QuarkusJpaUpdaterProvider — Updating database
          ... all 67 pending changesets apply and COMMIT (144 → 211) ...
21:05:16  ERROR Cannot invoke CustomLockService.waitForLock(...) because "this.lockService" is null
21:05:16  container exits 1

Liquibase's schema update succeeds. What fails is the step after it, which needs Keycloak's DBLockProvider — a session-scoped lock. Transaction pooling hands out a different backend per transaction, so the session-scoped lock service never gets established, and Keycloak dereferences a null.

It recovers on the second start — through the same pooler

This is the part that makes it dangerous rather than merely broken. The restart succeeded in 14 seconds and stamped migration_model to 26.7.1, still through transaction-mode PgBouncer. Under Kubernetes this presents as one CrashLoopBackOff followed by a healthy pod, which most operators will never look at twice.

For those 16–19 seconds and for however long the restart takes, the database has a 26.7.1 schema and a 26.0.0 model stamp.

No stuck lock

databasechangeloglock showed 0 held locks after every failure. Consistent with 2026-08-25-s1-s2-lock-failure-modes: this lab has still never observed a stuck Liquibase lock. The failure here is the absence of a lock service, not a lock that was taken and never released.

Verification

ClaimPrimary sourceChecked
Session mode upgrades cleanly through the poolercontrol run, ready 18s, model 26.7.1
Transaction mode fails the first startthree runs, three exits with the same NPE
Failure is fast, 16–19sdocker inspect StartedAt → FinishedAt on both reproductions
The schema migration commits before the failurecl=144 before start, cl=211 after the failed run
The model stamp does not advancemigration_model reads 26.0.0 after each failure
The pooler itself worksselect 1 through port 6432 before every run
Restart recovers, still pooledready in 14s, model 26.7.1
No held lock at any pointdatabasechangeloglock read directly after each failure
Data survives1,002 users after every run
Whether serving (not upgrading) is safe in transaction modenot tested — this record covers startup and migration only
Whether prepared-statement multiplexing bites separatelynot observed — the lock failure happens first and masks anything later
Other poolers (Odyssey, RDS Proxy, Cloud SQL connectors)not tested
pool_mode=statementnot tested — expected worse, not verified

Publishable extract

We finally reproduced a half-migrated Keycloak database. It took a connection pooler, not a crash.

Earlier the same day we tried two textbook routes to the "upgrade died partway and left the schema half-applied" failure. We killed Keycloak with SIGKILL mid-migration; it completed itself on the next start. We aborted its SQL with a statement_timeout; it refused to apply anything at all. Neither produced the state everyone warns about.

Then we put PgBouncer in transaction pooling mode in front of Postgres — the default mode nearly everywhere, because it is the mode that actually saves connections — and upgraded 26.0.0 → 26.7.1 on a 1,002-user realm. Three times out of three, Keycloak died 16 to 19 seconds in:

ERROR: Cannot invoke "CustomLockService.waitForLock(DBLockProvider$Namespace)"
       because "this.lockService" is null

Keycloak's DBLockProvider needs a session-scoped lock. Transaction pooling gives you a different backend for every transaction, so the lock service is never established and the server dereferences a null.

Here is the state it left, and why it matters more than the crash:

DATABASECHANGELOG211 rows — the full migration, committed
migration_model26.0.0 — unchanged

The schema moved. The version stamp did not. If your upgrade verification counts changelog rows or diffs the schema, it will tell you this upgrade succeeded. The only column that knows the truth is migration_model.

Two more things worth your time. The same pooler in session mode upgraded cleanly in 18 seconds — the pooler is not the problem, the pooling mode is. And the second start succeeded, in 14 seconds, still through transaction-mode PgBouncer. Under Kubernetes that is one CrashLoopBackOff followed by a healthy pod, which nobody investigates.

Before your window: ask what pool_mode your pooler runs, and point Keycloak at the database directly for the upgrade. Afterwards, check select version from migration_model order by update_time desc limit 1 — not the changelog.

← All runs