Kubernetes: concurrent migration + functional session stranding
- Upgrade
- 26.0.0 → 26.7.3
- Database
- PostgreSQL 16, single pod (StatefulSet), in-cluster
- Topology
- Deployment replicas: 2; leg 1 maxSurge=2, maxUnavailable=0
- Host
- laptop (16 vCPU, 31 GB RAM)
- Condition
- MaxSurge=2 · and *functional* session stranding
Summary
Two runs on a fresh kind kclab (k8s v1.37.0, kind v0.33.0), 2-replica Keycloak
Deployment, embedded Infinispan on KC_CACHE_STACK=kubernetes:
-
Concurrent migration (maxSurge=2) — the changelog lock serializes it. Two new 26.7.3 pods start at the same instant against a 26.0.0 schema. One (
jgvgr) winsDATABASECHANGELOGLOCK, runs the Liquibase changesets and the realm migrators (26.1.0 → 26.6.x); the other (ncrj6) logsUpdating databaseand then makes no migration progress — it blocks on the lock and, once the winner releases it, finds 0 changesets and boots. Both reach ready; no corruption. This re-shows, under real k8s concurrency, the lock serialization the Compose records1-s2-lock-failure-modes.mdestablished. -
Functional session stranding did NOT reproduce as predicted. A refresh token issued by a 26.0.0 pod (Infinispan 15) was accepted (HTTP 200, same
sid) by a 26.7.3 pod (Infinispan 16) after the old pods were gone. This refines the prior record's "a request routed to the other side of the partition finds nothing": the online session survived the cache partition and the termination of its owning pods. There is noUSER_SESSIONtable in 26.7.3 (onlyauth_session,root_auth_session,offline_*), so the recovery is not an obvious DB fallback — the mechanism is not yet pinned and is the follow-up below.
Environment
| Field | Value |
|---|---|
| Keycloak from → to | 26.0.0 → 26.7.3 |
| Database | PostgreSQL 16, single pod (StatefulSet), in-cluster |
| Cache | embedded Infinispan, KC_CACHE_STACK=kubernetes (DNS_PING) |
| Cluster | kind kclab, k8s v1.37.0 |
| Topology | Deployment replicas: 2; leg 1 maxSurge=2, maxUnavailable=0 |
| Host | laptop (16 vCPU, 31 GB RAM) |
| JVM heap | -Xms256m -Xmx1g |
Results
Leg 1 — concurrent migration timeline (pod logs)
| t | event |
|---|---|
| 21:04:43,967 | new pod jgvgr Updating database (wins the changelog lock) |
| 21:04:46,454 | new pod ncrj6 Updating database (blocks on the lock) |
| 21:04:47,539 | jgvgr Migrating older model to 26.1.0, then 26.2.0 … 26.6.2 |
| 21:04:52,897 | ncrj6 resumes Infinispan startup (lock released, 0 changesets) |
| 21:04:55,567 | ncrj6 Bootstrap completed |
| end | both pods 1/1 ready, migration_model 26.7.3 |
Leg 2 — session stranding (HTTP-level)
session issued on 26.0.0 pod (Infinispan 15) → refresh_token captured
rolling update 26.0.0 → 26.7.3, old pods terminated
refresh on 26.7.3 pod (Infinispan 16) → HTTP 200, new access_token, SAME sid
The refresh succeeded with the same session id, i.e. the session was found despite the cache partition and the death of the pods that held it.
Findings
-
The changelog lock is the concurrency control for parallel migrators. Two pods migrating the same schema at once do not corrupt it; one serialises the other. The loser's Liquibase phase is a no-op (0 changesets) after the lock wait. The lock is held across the realm migrator phase too, not just the changesets (the winner's
Migrating older model …lines sit under the lock). -
The cache partition is real at the JGroups level but its functional, session-loss consequence is NOT as the earlier record stated. Online sessions survive across the Infinispan 15 → 16 boundary in the refresh flow, with no
USER_SESSIONtable to explain a DB fallback. The prior claim "a request routed to the other side of the partition finds nothing" is therefore too strong — it was inferred from theJGRP000006 cookielog line, not measured at the HTTP layer. The exact recovery mechanism (cache-loader persistence into a table that isn'tUSER_SESSION, or stateless re-hydration of the refresh token) is unpinned. -
kind discovery race persists. Both pods again came up as 1-member clusters on first boot (
no members discovered after 2001 ms); deleting one pod re-triggered DNS_PING and formed the 2-member cluster. Same as the 2026-08-31 record — verify the baseline before relying on a kind run.
What is still not tested
- The mechanism behind finding 2 — where the session lived that the new pod
found it. Candidates to rule out: (a) a session cache-loader writing to a
table other than
USER_SESSION; (b) the refresh flow re-creating the session from the signed refresh token without a cache hit. Pin by: issue a session,kubectl delete podthe ENTIRE cluster (both pods), restart, refresh. - External (remote) Infinispan — untouched; separate setup.
Verification
| Claim | Evidence |
|---|---|
| Two new pods migrate concurrently under maxSurge=2 | 2 new pods Running at t+15s, both log Updating database ☑ |
| The lock serialises them | winner logs realm migrators; loser logs 0 changesets ☑ |
| No corruption, both ready | migration_model 26.7.3, both 1/1 ☑ |
| Session survives the partition (functional) | refresh on new pod → HTTP 200, same sid ☑ |
No USER_SESSION table in 26.7.3 | information_schema: only auth_session/root_auth_session/offline_* ☑ |