Skip to main content

Bilko Demo Event-Loop Freeze — Root Cause, Fix & Hardening (2026-06-08, MC #103134)

Bilko Demo Event-Loop Freeze — Root Cause, Fix & Hardening (2026-06-08, MC #103134)

1. Incident Summary

Date: 2026-06-08
Service: bilko-api-demo (Cloud Run, europe-north1, tribal-sign-487920-k0)
Live revision at incident: bilko-api-demo-00139-b26 (git sha c8dfb6c)
MC: #103134 (child of #103132)
Detected by: CEO (Alem Basic) — no automated alerts existed

Symptom: POST /api/v1/auth/login and GET /api/v1/health intermittently returned HTTP 504 with server-side latency of exactly 59.999 s (Cloud Run hard timeout). Observed failure rate: 17–25% of requests. Both endpoints froze simultaneously on both Cloud Run instances. A manual container restart did not fix the issue.

Why restart did not help: The blocking code paths are baked into the deployed image (c8dfb6c). The freeze re-triggers the moment any user navigates to Reports or Billing/Usage, which re-executes the same bare JDBC calls on the Netty event-loop thread.


2. Root Cause — Causal Chain

Source: 5-agent synthesis (petter-synthesis.md) + source-verified app-layer audit (dev-applayer.md) + data/storage audit (devops2-data.md).

2.1 Ktor Netty Thread Pool Exhaustion (Primary)

Ktor runs on Netty. On a 1-vCPU Cloud Run container with no callGroupSize set (main branch pre-fix), Netty defaults to approximately 2 call-handling threads. With containerConcurrency=8, Cloud Run routes up to 8 simultaneous requests into the container, all competing for those 2 threads.

Any route that executes a bare orgTransaction() call — not wrapped in dbQuery{} — runs its JDBC work directly on the Netty call thread, blocking it for the duration of the DB round-trip (10–100 ms typical, up to 30 s if HikariCP must wait for a pool slot). With only 2 call threads and 8 concurrent slots, two simultaneous requests to a bare-orgTransaction route exhaust both call threads. The event loop is frozen.

Confirmed bare orgTransaction sites on main at incident time:

FileLine(s)Route
ReportRoutes.kt26GET /reports (country lookup)
ReportRoutes.kt240GET /reports/kpo (country pre-check)
ReportRoutes.kt264GET /reports/kpo/export/pdf (country pre-check)
ReportRoutes.kt285GET /reports/kpo/export/xlsx (country pre-check)
BillingRoutes.kt214GET /billing/usage (plan tier + invoice count)
MarketRoutes.kt2 sitesresolveMarketPlugin call sites
ResourceAccessFilter.ktmultiplePer-request security filter
Authentication.ktimpersonation pathPer impersonation request

2.2 PermissionService.loadFromDb — New RBAC Blocking Call (Critical, Entra cutover)

The Entra External ID cutover (MC #103140) introduced RBAC via requirePermission(), which is called on every authenticated request across all 17 route files. On first call per role (4 roles: owner/admin/accountant/viewer), resolve() calls loadFromDb():

// BEFORE fix — blocking on Netty event-loop thread
private fun loadFromDb(role: String): Set<String> {
    return transaction {  // bare JDBC on Netty thread
        RolePermissionsTable.selectAll()...
    }
}

This fires on every cold-start / scale-out event for every authenticated route. Without the fix, the Entra cutover would have immediately re-introduced a freeze worse than the original: 4 role cache-misses per instance restart, each running blocking JDBC on the event loop. Source: /tmp/evidence-103134/rebase-on-entra.md.

2.3 HikariCP connectionTimeout=30,000 ms (Secondary Amplifier)

When the Dispatchers.IO pool workers (correctly dispatched DB calls) hit HikariCP pool pressure — 10 connections shared across 8 concurrent slots — a pool-borrow wait can last up to 30 s per request. Two stacked pool-borrow waits = 60 s = Cloud Run hard kill. This amplifier remains even after the bare-orgTransaction calls are fixed; it is addressed separately by reducing connectionTimeout to 5 s (P0.3).

2.4 Why /health Freezes (Not a DB Problem)

HealthRoutes.kt lines 10–21 are confirmed DB-independent (zero blocking calls, pure in-memory response). The health freeze is not because health touches the DB — it is because both Netty call threads are occupied by blocked orgTransaction callers. Health cannot be scheduled. The TCP startup probe continues to pass because the JVM still has port 4001 bound; only HTTP scheduling is stalled.

2.5 Why Login Freezes (Login Is the Victim, Not the Cause)

AuthRoutes.kt line 150 correctly wraps login in dbQuery{}. Login itself is not a blocking caller. Login freezes because it cannot obtain a call thread to execute — both threads are consumed by the bare-orgTransaction routes.

2.6 Tertiary — Absent Server-Side DB Timeouts

No statement_timeout, idle_in_transaction_session_timeout, or lock_timeout on Cloud SQL (confirmed: databaseFlags null). A single slow query (vacuum contention on db-f1-micro, missing index on a growing table) holds a pool connection indefinitely, compounding pool pressure.

2.7 gcsfuse Is Not a Cause of Auth/Health Freezes

gcsfuse GC timestamps do not align with freeze onsets (GC at 06:35:04 UTC, freeze at 06:34:52 and 06:35:43). Login does no file I/O. gcsfuse FUSE blocking I/O affects receipt endpoints only; it is a separate hygiene issue and was also offloaded to Dispatchers.IO in the fix.


3. Fix Applied (MC #103134)

Branch: fix/demo-freeze-on-entra-103134, commit 5252182, rebased on origin/main @ b21b366 (Entra cutover). Merged as PR #279 → main 839ee7f. Source: /tmp/evidence-103134/rebase-on-entra.md.

Code Fixes

FileChange
routes/ReportRoutes.kt (4 sites)Bare orgTransaction → wrapped in dbQuery{}
routes/BillingRoutes.kt (line 214)Bare orgTransaction in /billing/usage → dbQuery{}
routes/MarketRoutes.kt (2 sites)resolveMarketPlugin call sites → dbQuery{}
plugins/ResourceAccessFilter.ktfetchResourceOwner + logSecurityViolation → suspend + withContext(IO); Thread.sleep → delay
plugins/Authentication.ktImpersonation transaction → withContext(Dispatchers.IO)
services/PermissionService.kt (new finding)loadFromDb() → runBlocking { withContext(Dispatchers.IO) { ... } } — RBAC per-request DB call offloaded
services/ReceiptService.ktreadLocalDocument + persistLocalIfEnabled → suspend + withContext(IO)
routes/ExpenseRoutes.ktreadLocalDocument moved out of dbQuery{} (now suspend-safe)
plugins/Database.ktconnectionTimeout 30,000 → 5,000 ms
routes/HealthRoutes.ktAdded GET /api/v1/health/deep — DB-touching endpoint with withTimeout 3 s + Dispatchers.IO + SELECT 1

Infrastructure Fixes (cloudbuild-stage.yaml + cloudbuild.yaml)

ChangeValue
Memory512 Mi → 1 Gi
Liveness probehttpGet /api/v1/health/deep, port 4001, initialDelaySeconds 30, periodSeconds 30, failureThreshold 2, timeoutSeconds 8
Startup probehttpGet /api/v1/health (was TCP), port 4001, periodSeconds 10, failureThreshold 3, timeoutSeconds 5
containerConcurrency80 → 8 (in cloudbuild-demo-api.yaml; applied to stage + demo templates)

Build and Test Results

./gradlew build -x detekt → BUILD SUCCESSFUL in 12s
Tests: 2561 passed, 1 skipped (EntraLiveCiamTokenWp5Test — stale token, assumeTrue guard), 0 failures

PermissionService Cache Note

The 4-role cache (one entry per role after first load) bounds future DB hits. Pre-warming the cache at JVM startup is recommended as a follow-on polish task (task #7), but is not a blocker for the freeze fix: the IO offload alone prevents the event-loop block on cold misses.


4. Industry-Standard Scorecard

Condensed from petter-synthesis.md §2. Full details in audit reports.

Application Layer

ComponentAssessmentFinding
Ktor/Netty callGroupSizeDEVIATES (pre-fix)No callGroupSize set on main. Defaults to ~2 call threads on 1 vCPU. Fix branch sets callGroupSize=32; must be confirmed active in Cloud Run (env var KTOR_CALL_GROUP_SIZE=32 or production application.yaml profile).
dbQuery{} disciplineDEVIATES → FIXED5 confirmed bare orgTransaction sites + PermissionService.loadFromDb all wrapped in this fix. 97 raw transaction{} calls in repo; remaining ones are safely nested inside outer dbQuery{} or on non-hot paths.
Login path (AuthRoutes.kt:150)STANDARDCorrectly wraps in dbQuery{}. Login was the victim of thread starvation, not a cause.
BCrypt dispatcherDEVIATES (minor)BCrypt (CPU-bound, 250–400 ms) runs on Dispatchers.IO (I/O-bound). Not the freeze cause but degrades throughput. Move to Dispatchers.Default is a follow-on (P1.3).
HikariCP connectionTimeoutDEVIATES → FIXED30,000 ms → 5,000 ms. Pool-borrow wait now fails fast with 503 in 5 s.
Login 3-transaction pool pressureDEVIATESAuthService.login() makes 3 separate orgTransaction{} calls (3 pool borrows per login). Follow-on: consolidate to 1 (P1.1).
Server-side RequestTimeoutDEVIATESNo Ktor RequestTimeout plugin. Stuck handlers are killed by Cloud Run at 60 s, not the app at 10 s. Follow-on: P1.5.
/health endpointSTANDARDDB-independent, correct. Preserved as-is for startup probe.
/health/deep endpointADDEDNew DB-touching endpoint (SELECT 1 via HikariCP, 3 s internal timeout). Returns 200 {db:up} or 503 {db:down}.
gcsfuse (ReceiptService)DEVIATES → FIXED (hygiene)Blocking POSIX I/O on FUSE mount offloaded to withContext(IO). Not the auth/health freeze cause, but blocks safe concurrency > 1–8 without this fix.

Infrastructure Layer

ComponentAssessmentFinding
containerConcurrencyDEVIATES8 with blocking event-loop paths present. Correct value is 1 until all blocking paths are confirmed on Dispatchers.IO. Prior safe state (v0.2.33) was concurrency=1. Fix branch applies concurrency=8 in cloudbuild templates (improvement from 80 default; full concurrency=1 revert deferred pending gcsfuse→SDK swap per P1.2).
minScale/maxScaleDEVIATESmin=2, max=2. Pinned — no relief valve. With maxScale=2 and both instances holding frozen slots, no new instance can absorb traffic. Follow-on: maxScale=5 paired with pool=2 (P2.2).
HikariCP pool mathFRAGILEpool=10, maxScale=2 = 20 connections vs ~22 usable on db-f1-micro. Within bounds by 2 connections only. Any scale-up to 3 instances without reducing pool causes exhaustion.
Cloud SQL databaseFlagsDEVIATESZero flags. No statement_timeout, no idle_in_transaction_session_timeout, no lock_timeout. Follow-on: MC #103133 (blocked on GCP-write channel MC #103149).
Cloud SQL tierDEVIATESdb-f1-micro: max_connections=25, shared CPU, VACUUM contention risk. Follow-on recommendation: db-g1-small minimum for customer-facing demo.
Liveness probeABSENT → ADDEDHTTP liveness on /health/deep added. Without it, frozen containers ran for the full incident duration with no auto-recovery.
Startup probeDEVIATES → FIXEDWas TCP period=240s failureThreshold=1 timeout=240s. Now HTTP GET /health period=10s failureThreshold=3 timeout=5s.
Cloud Monitoring alertsDEVIATES (critical)0 policies (confirmed: gcloud monitoring policies list = 0 items). Incident was CEO-detected. Follow-on: P2.1 (5 alert policies).
MemoryDEVIATES → FIXED512 Mi → 1 Gi. JVM + HikariCP + gcsfuse daemon on 512 Mi was tight; GC pressure is a secondary freeze vector.
Cloud SQL public IPDEVIATESipv4Enabled=true. No unauthorized networks found (mitigated), but public attack surface exists. Follow-on: private IP + VPC (P2 hardening).
CPU throttling / startup-cpu-boost / gen2STANDARDAll correct. No change needed.

5. Deploy Path and Cross-Session Coordination

Source: /tmp/alai/p2p-pairing-evidence/john-7fedd67f-freeze-fix-coordination-20260608.md

This fix was developed in session 7fedd67f and rebased on top of the Entra External ID cutover branch (b21b366). The coordination agreement between the freeze-fix session and the Entra/RBAC workstream (MC #103080, Phase 4) is:

  1. Step 1 (complete): PR #279 merged fix/demo-freeze-on-entra-103134 → main (839ee7f). Stage auto-deploy triggered (bilko-stage-auto-deploy: push to ^main$ → cloudbuild-stage.yaml). Stage revision bilko-api-stage-00531-cub deployed.
  2. Step 2 (pending): Demo and production semver tag is owned by the Entra/RBAC workstream (MC #103080). That single semver tag vX.Y.Z ships Entra + RBAC + freeze fix together, via bilko-main-deploy trigger (^v.*$ → cloudbuild.yaml, 8 gates + approval). Neither session tags unilaterally.

Deploy pipeline mechanics (verified):

  • bilko-stage-auto-deploy: triggered by push to branch matching ^main$, runs cloudbuild-stage.yaml
  • bilko-main-deploy: triggered by semver tag matching ^v.*$, runs cloudbuild.yaml (demo, 8 gates + approval required)
  • cloudbuild-demo-api.yaml: DEAD CODE — orphaned trigger deleted 2026-05-21. Do not reference or invoke.

6. Proveo Stage Validation — PASS

Source: /tmp/evidence-103134/proveo-stage-verdict.json + /tmp/evidence-103134/proveo-stage-validation.md
Timestamp: 2026-06-08T09:54:10Z
Revision: bilko-api-stage-00531-cub (git sha 839ee7f)

CheckResultDetail
#1 /health x30 sequentialPASS0/30 failures, max latency 170 ms
#1b /health x20 concurrentPASS0/20 failures, max latency 279 ms
#2 /health/deep x15 (DB-touching)PASSEndpoint present, db:up, all under 200 ms
#3 Concurrency starvation test (6 waves x20 = 120 requests)PASS0 non-200, 0x 504@60s. Wave 1 had 2/20 requests at ~10.8 s (HTTP 200, pool warm-up artifact, not freeze). Waves 2–6: zero over 2 s. Original [email protected] pattern NOT observed.
#4 Auth pathPASSPOST /auth/login → 410 (legacy retired); POST /auth/entra/session (invalid) → 400; GET /invoices (no auth) → 401; GET /contacts (no auth) → 401. Entra + RBAC enforced.
#5 Probe configPASSlivenessProbe httpGet /health/deep present; startupProbe httpGet /health present; memory 1Gi; git sha 839ee7f confirmed.

Overall verdict: PASS. The event-loop freeze fix is confirmed on stage. The original [email protected] symptom is not reproduced under sustained concurrent load.

Caveat: Wave 1 had 2 requests at ~10.8 s (HTTP 200, not 504) — confirmed as connection pool warm-up artifact on first concurrent burst. Not a freeze regression. Waves 2–6 (100 requests) were all under 2 s.


7. Follow-Up Tasks

MCItemStatus
#103133Cloud SQL statement_timeout + idle_in_transaction_session_timeout + lock_timeout (gcloud sql instances patch)Blocked on GCP-write channel MC #103149
#103148Gate wc bug (claim-gate stale-transcript count issue)Open
#103173Claim-gate stale-transcript loopOpen
#103138This documentation page (WS-D)Complete (this page)
Task #7PermissionService pre-warm cache at JVM startup (recommended polish)Deferred
P1.1Consolidate AuthService.login() 3 transactions into 1 (reduce pool borrows per login from 3 to 1)Deferred — daytime review
P1.2Replace gcsfuse with GCS Storage SDK in ReceiptService (unlock safe concurrency > 8)Deferred
P1.5Install Ktor RequestTimeout plugin (force-kill stuck handlers at 10 s, return 503)Deferred
P2.1Create 5 Cloud Monitoring alert policies (5xx rate, p99 latency, instance ceiling, Cloud SQL connections, /health/deep uptime)Deferred — no blocking dependency
P2.2Scaling fix: containerConcurrency=1, maxScale=5, pool=2 (requires P1.2 gcsfuse→SDK first)Deferred
P2.6Add k6 event-loop-freeze regression gate to CI (tests/k6/event-loop-freeze.js)Deferred — spec in test-observability.md §4 B5

8. Evidence References

  • /tmp/alai/bilko-infra-team/petter-synthesis.md — 5-agent root-cause synthesis
  • /tmp/alai/bilko-infra-team/devops1-cloudrun.md — Cloud Run compute/scaling audit
  • /tmp/alai/bilko-infra-team/devops2-data.md — Cloud SQL / HikariCP / gcsfuse / networking audit
  • /tmp/alai/bilko-infra-team/dev-applayer.md — Application layer audit (CodeCraft)
  • /tmp/alai/bilko-infra-team/test-observability.md — Observability gaps + Proveo validation plan
  • /tmp/evidence-103134/rebase-on-entra.md — Fix branch rebase on Entra main, PermissionService finding, build/test results
  • /tmp/evidence-103134/proveo-stage-verdict.json — Stage validation machine-readable verdict
  • /tmp/evidence-103134/proveo-stage-validation.md — Stage validation human-readable report
  • /tmp/alai/p2p-pairing-evidence/john-7fedd67f-freeze-fix-coordination-20260608.md — Cross-session coordination with Entra workstream