Repository navigation
One busy Run can use every Postgres pool connection through hold_run and block sign-in #2391
Description
Scope after #2381 (slice 2 of #2378). The mechanism this issue describes is gone: the Run Owner (ADR 0018) is the only writer on a Run's Program paths, Program requests answer from owner memory, every chunk commit renews the worker lease, and no Program path holds a connection while it waits. Items 1, 2 and 4 of the suggested fixes are therefore closed by #2381 (PR #2524); the lease-renewal failure closed with it. This issue stays open for item 3 (a configurable Postgres pool size and acquire timeout) and item 5 (pool-acquire timeout logging and pool metrics) only.
Summary
On Postgres, one Run can take every connection in the server's database pool. The connections queue on that Run's program_run_records row lock (repository::hold_run). Every other request that needs the database then waits the full 30 s acquire timeout and fails. That includes sign-in, the device-authorization recovery loop and the gRPC readiness check, for all users and workspaces. The database itself had room to spare: it allowed several times more connections than the server used.
The lease-renewal issue covers Run interruption on SQLite. This issue covers the server-wide impact on Postgres.
Observed
A hosted Coral deployment: one server replica, managed Postgres 18.
- Sign-in to Reef failed. The sign-in redirect chain to the identity provider worked, and every readiness endpoint returned 200 when probed by hand.
- Over roughly twelve hours the server logged only one warning, repeatedly. It came from
coral_app::bootstrap::server:device authorization recovery failedwitherror="pool timed out while waiting for an open connection". The sign-in path logged nothing. - The gRPC readiness probe (
coral.readiness) reportedNOT_SERVINGby the end of that period. - Database connections peaked at exactly 11 in every hour of that period: the 10-connection pool plus the
changesLISTEN connection, and for hours at a time it never dropped below 11. Database CPU, memory and storage were healthy. Maximum read latency rose by an order of magnitude when the pool timeouts began.
pg_stat_activity showed all 11 server connections:
| Connections | State | Wait | Transaction age | Query |
|---|---|---|---|---|
| 8 | active | Lock on tuple / transactionid |
0–4 s | UPDATE "program_run_records" SET "state" = "state" WHERE "workspace_id" = $1 AND "id" = $2 |
| 1 (pid 2339) | active | none | 5 s | SELECT … FROM "provider_records" WHERE "workspace_id" = $1 AND "id" = $2 |
| 1 | active | none | under 1 s | Run scheduler: SELECT MIN("run_deadline"), MIN("request_deadline"), MIN("authority_deadline") … |
| 1 | idle | ClientRead |
none | coral_committed_change_* listener |
pg_blocking_pids showed a single queue. Pid 2339 held the row lock and directly blocked three of the waiters. The other five waited behind those on the tuple lock. Transactions were short (0–5 s), so this is continuous contention on one row, not one hung transaction. The oldest pool connections were 26 minutes old, so the load had lasted at least that long.
I did not identify which Run held the row, or whether it was waiting on approvals.
Why one Run can take the whole pool
connect_postgresincrates/coral-db/src/database.rsbuilds the pool withPgPoolOptions::new(). That uses sqlx's defaults of 10 connections and a 30 s acquire timeout, and no setting changes them.- Every change to a Run starts with
repository::hold_runincrates/coral-run/src/repository.rs. It is an UPDATE that changes nothing but locks the Run's row until the transaction ends. It has 16 call sites, including admission, approval, program output, the worker heartbeat and retention. - Some of those transactions do more reads after taking the lock. For example,
admitincrates/coral-run/src/invocation.rsloads the Run, resolves the Operation and evaluates policy while holding it. Here the lock holder was readingprovider_records, which fits that path. - Each queued transaction holds a pooled connection while it waits. A Run that makes many concurrent calls, or has many approval waiters polling every 50 ms, can hold every connection. Requests that never touch that Run then fail.
Suggested fixes
- Stop one Run from holding the whole pool. Limit how many transactions per Run can wait on
hold_runat once, for example with an in-process semaphore per Run, so extra work waits without holding a connection. - Hold the lock for less time. Do the reads that don't need the lock, such as Operation resolution and policy, before taking
hold_run. Read-snapshot waiters woken bysubscribe_changesremove the approval-polling part of the load. - Make the Postgres pool size and acquire timeout configurable, for example under
[database]. The hosted database allows several times more connections than the pool uses. - Consider a separate small pool, or reserved connections, for authentication and readiness, so sign-in keeps working when Run traffic fills the main pool.
- Log pool-acquire timeouts on the sign-in path. During this incident the only log line came from the device-recovery loop.
Acceptance criteria
- A single Run with many concurrent Invocations or approval waits cannot hold every connection in the pool. Sign-in, readiness and other workspaces' requests keep succeeding.
- The Postgres pool size and acquire timeout are configurable.
- A Postgres load test, for example 30 concurrent Invocations in one Run alongside sign-in and readiness requests, shows no pool-acquire timeouts outside that Run.
-
make rust-checksandmake postgres-testspass.
Diagnosis queries
select pid, state, wait_event_type, wait_event,
now() - xact_start as xact_age, left(query, 200) as query
from pg_stat_activity
where datname = current_database()
order by xact_start nulls last;
select pid, pg_blocking_pids(pid) as blocked_by
from pg_stat_activity
where cardinality(pg_blocking_pids(pid)) > 0;Ported from withcoral/lagoon#390.
Activity
sauldhernandez commentedon Oct 14, 2026
sauldhernandez commented on 2026-09-25 in withcoral/lagoon#390:
Proposed direction: keep
hold_run, take it less often, hold it for less timeThis is a proposal for discussion, based on
mainas of 2026-09-25. In short: keep the per-Run row lock and everything it guarantees, and cut the lock traffic that fills the pool.Deployment update: restarting the server freed the pool, but it was full again within minutes. The load is sustained, not a one-off burst.
Why keep the lock
hold_runmakes all changes to a Run happen one at a time. Four guarantees depend on that:k", and observation moves the cursor tonext_event_sequence - 1when there are no more events.run.ended. No admission and no output is recorded after the Run ends.Alternatives we looked at:
advance_event_sequenceinstead of waiting. Checks that noWHEREclause covers would go stale:ApprovalModule::decidecould recordApprovedon a Run cancelled a moment earlier. The waiting wouldn't disappear either, because the guardedUPDATEs take the same row lock.waitor Reef.Where the lock traffic comes from
approval_readyandselect_credentialsininvocation.rs. Each waiting Invocation opens a write transaction withhold_runevery 50 ms and does several reads under the lock. These polls never append an event. With 20 waiting Invocations, that is about 400 locked transactions a second on one row. This matches the incident: short transactions queued without a break behind a lock holder readingprovider_records.hold_runplus the claim renewal, once a second per running Run.settle_terminationtakes the lock every 10 ms while a Run shuts down.Proposed changes
begin_read_snapshotand takehold_runonly when a decision, policy or account state has changed. Wake onsubscribe_changesinstead of a 50 ms timer, with a slow fallback poll. This removes almost all of the lock traffic.hold_runanUPDATE … SET state = state … RETURNING *and drop the separatefind_exact_in_transactionread at the 13 call sites that do it right after. Every locked transaction gets one round trip shorter. Postgres and SQLite (3.35+) both supportRETURNING.UPDATE.renew_worker_claimalready checks state, termination, deadline, both claim ids and expiry. Thehold_runbefore it adds nothing, and without it the heartbeat needs no explicit transaction.admitresolves the Operation and evaluates policy while holding the Run lock. The lock never protected those rows. Doing those reads first and keeping only the Run-state check and the guarded writes under the lock shortens holds during bursts of admissions. This needs a review, path by path, of which reads must share a snapshot with the Run check.[database]. Todayconnect_postgresuses sqlx's defaults of 10 connections and 30 s. This gives operators headroom and a backstop for bursts.Suggested order: 2, 3 and 5 first (small and independent), then 1, then 4.
Considered but not proposed
begin(), so waiters hold no connection. This would put a hard bound on connections per Run, at the cost of in-process coordination on top of the row lock.FOR SHARE) for paths that only need "the Run is still running". They work only on Postgres and add multixact overhead on a hot row, and once change 1 lands there's little left to run in parallel.Remaining risk
Without a hard bound, a large fan-out (for example
Promise.allover 50 calls) can still fill the pool while the burst lasts. Nothing limits concurrent Invocations per Run today. With short holds this means a delay, not an outage. If we want a hard bound later, the least invasive option is a product limit on concurrent Invocations per Run, enforced where the V8 host starts them.Test plan
Write a Postgres test: one Run with 30 parallel approval waits, alongside sign-in and readiness requests. It passes when no pool-acquire timeouts happen. Record the number of locked transactions per second before and after the change.
Open questions
subscribe_changesfast enough for approval latency, and what should the fallback poll interval be?admitand the dispatch fence, which reads must share a snapshot with the Run-state check?sauldhernandez commentedon Oct 14, 2026
sauldhernandez commented on 2026-09-25 in withcoral/lagoon#390:
Local reproduction on
main(3d03fc67), no fixes appliedThe hypothesis holds. One Run with many parallel approval-gated calls queues on
hold_runand takes over the pool. Once each database round trip costs a few milliseconds, as it does against a managed database, unrelated work waits for seconds and the readiness check fails.Setup
#[ignore]test next toaction_cards_are_bounded_without_losing_the_current_request_countincrates/coral-grpc/tests/grpc/activity_evidence.rs. It uses the sameFixture: realRunModule, real V8, and a production-shaped pool (sqlx defaults, 10 connections), on local Postgres 17.await Promise.all(Array.from({length: N}, () => coral.providers.evidence.read({body: {}}))). The operation is aPOST, so it requires approval by default.database.begin()+ commit every 250 ms, timed;ping()with a 1.5 s budget, likebootstrap/health.rs, every 500 ms;pg_stat_activityandpg_stat_databasesampled every second over a separate direct connection, outside the pool.Results
begin()waithold_runpg_stat_activitymatches the hosted deployment's snapshot. 11 connections are open (the pool plus the LISTEN connection), and 8–9 of them sit inLockwaits onUPDATE "program_run_records" SET "state" = "state".The Run interrupts itself on Postgres
At 101 waiters and +20 ms, the Run ended
interrupted(termination_kind = interrupted). 72 of the 101 calls were admitted and all of them endedrun_terminated. No approval request was created. The worker's heartbeat (hold_runplusrenew_worker_claim, every 1 s) can't get through the contention, and the first failed renewal stops the Program.This makes the starvation come in bursts: pressure builds, the Run is interrupted, the pool drains. That fits the hosted deployment's intermittent
pool timed outwarnings and the pool filling again minutes after a restart, if the caller retries. I haven't checked the hosted deployment for the retrying part. It also means change 3 in the proposal above (the heartbeat as a single guardedUPDATE), together with not stopping on the first transient renewal failure, matters for more than lock traffic.Not reproduced
pool timed out while waiting for an open connection: the longest wait was 26.5 s, just under the 30 s acquire timeout, because the Run interrupted itself first.begin()and the readiness-style ping stand in for it. In the hosted deployment, a failing readiness probe also takes the replica out of the load balancer, which on its own makes login fail intermittently.Repro test (not committed). Add
#[path = "pool_starvation_repro.rs"] mod pool_starvation_repro;at the end ofactivity_evidence.rs.Run on Postgres:
For added latency, run toxiproxy in front of the
coral-test-postgrescontainer, add alatencytoxic ondownstream, and setCORAL_TEST_DATABASE=postgres CORAL_TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:<proxy port>/<db>.Other knobs:
REPRO_RUN_TIMEOUT_MS(default 300000) andREPRO_ADMIT_WAIT_SECS(default 20).sauldhernandez commentedon Oct 14, 2026
sauldhernandez commented on 2026-10-02 in withcoral/lagoon#390:
The load reproduction from this issue is now a checked-in harness (map #2389):
scripts/load.mjsdrivesrun_fan_out_loadincrates/coral-grpc/tests/grpc/load.rsagainst the disposable Postgres (optionally through toxiproxy latency) or SQLite, withbegin, sign-in, readiness and second-Workspace probes and apg_stat_activitysampler outside the pool.make load-test LOAD_ARGS="--calls 30,101 --latency 0,5,20"reproduces the matrix above, including the self-interruption at 101 calls and +20 ms. Baseline numbers formainafter the incident mitigation are on map #2389.test(run): land the Run fan-out load harness with the owner-shape cel…
feat(run): introduce the Run Owner for Program requests (#2524)
feat(run): make the Run Owner survive uncertain commits and lost leas…
feat(run): reshape the Dispatch Fence into one batched check per chun…
feat(run): enforce the Fan-out Limit inside the Run Owner (#2571)
feat(run): route approval decisions and cancellation through the Run …
perf(run): wake parked Invocations through the Run Owner instead of p…
feat(run): recover Runs with an expired-lease sweep and drain them on…