The Keycloak Upgrade Ledger

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

2026-08-26 · run 2026-08-26-s3-revisited-autovacuum-lock-wait

S3 revisited: `statement_timeout` does half-migrate, and autovacuum is why

Upgrade
26.0.0 → 26.7.1
Scale
2,000,003 users · CREDENTIAL 2,000,002 rows / 105,296 pages
Database
PostgreSQL 16, single node, container
Topology
single container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
Condition
statement_timeout mid-flight

Summary

S3 measured correctly and concluded wrongly.

Its measurement stands: no statement in this migration takes meaningful time to execute. We re-measured the statement that matters and got 0.177 ms.

Its conclusion does not. S3 reasoned that because the longest statement was 2.142 ms, no statement_timeout value could land between "too short to start" and "long enough to finish". That inference assumes a statement's duration is its execution time. In Postgres it is not: a statement's duration includes the time it spends waiting for its lock, and statement_timeout is charged for that wait.

At 2,000,003 users, statement_timeout values of 500 ms, 200 ms and 50 ms all leave the database half-migrated — DATABASECHANGELOG at 157 of 211, migration_model still 26.0.0, and the server refusing to start. 1 s and 10 s complete. The band S3 called empty is (500 ms, 1 s].

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
Distribution / start modequay.io/keycloak/keycloak official image, start w/ external DB
DatabasePostgreSQL 16, single node, container
Postgres tuningstock except where a leg names the variable. deadlock_timeout 1s (stock), autovacuum on (stock), statement_timeout set per leg on the keycloak role
Adverse scenarioS3
Dataset scale2,000,003 users · CREDENTIAL 2,000,002 rows / 105,296 pages
Seeding methodseed-sql, restored from baseline-26.0.0-sqlseed-2m
Topologysingle container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
JVM heap-Xms1g -Xmx4g
Instrumentationlog_min_duration_statement=0, log_lock_waits=on on the diagnostic legs

Breakage observed

A statement_timeout below one second half-migrates the database

Verification

ClaimPrimary sourceChecked
500 ms / 200 ms / 50 ms half-migrate at 2Mthree runs, DATABASECHANGELOG and migration_model queried
1 s and 10 s completetwo runs, 211 rows, model 26.7.1
The failing statement is 26.2.0-26106, not the index buildLiquibase exception quoted
That statement executes in well under a millisecondF1 0.177 ms, F2 0.156 ms, F3 0.210 ms, from Postgres' log
The duration is a lock wait, not executionstill waiting for AccessExclusiveLock / acquired … after 100.161 ms
The blocker is an autovacuum worker on CREDENTIALcanceling autovacuum task … automatic vacuum of table "keycloak.public.credential"
The wait length is deadlock_timeoutE2 — 100 ms setting produced a 100.485 ms statement
Turning autovacuum off makes 500 ms survivableF3 — READY
S3's own measurement was of an uncontended runinferred from F1 reproducing 2.142 ms's order of magnitude; the original run cannot be re-inspected
The autovacuum worker predicts the outcometen repetitions, 10/10 agreement, 7 failures
Failure rate immediately after a restore70% (7/10) at statement_timeout=500ms
Whether other version pairs have an equivalent DDL on a large tablenot tested
Whether lock_timeout behaves differently from statement_timeout herenot tested

← All runs