Skip to content

Participant join: never return a connection holding a lock to the pool - #3040

Merged
colinmegill merged 2 commits into
edgefrom
join-lock-leak
Oct 12, 2026
Merged

colinmegill merged 2 commits into
edgefrom
join-lock-leak

Conversation

@colinmegill

@colinmegill colinmegill commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Observable changes

None for participants beyond no longer stalling. In detail:

  1. A join that waits more than 5 s on a lock now fails instead of waiting with no limit. The participant insert sets lock_timeout = '5s'. Past that, Postgres raises 55P03 and the route returns its usual error. That applies to every join path: votes and comments (through ensureParticipant and the comment route), and POST /api/v3/participants and POST /api/v3/joinWithInvite (through joinConversation). joinConversation retries a failed join up to ten times; it now stops on a lock timeout. Otherwise those two routes would have answered after about 50 s. Before this change the join waited until the lock holder's connection closed. That was 10 s of pool idle time, or never if the connection stayed busy, and the join held a read-write connection the whole time. The participant's browser gives up at 10 s either way.
  2. A new participant who loses the pid race now gets their participant on the same request. There is one retry inside the participant insert, on a new connection. Before, the comment route answered 500, because it has no retry of its own. The vote and participationInit routes retried in ensureParticipant and then either succeeded or stalled (see below).
  3. One new info log line per pid collision: participant pid collision; retrying once. This makes the collision rate countable.
  4. The CSV import worker opens a new connection for each 1000-row batch instead of reusing a pooled one. Imported data is unchanged.

No schema change. The pid_auto trigger is untouched.

The defect

pid_auto (BEFORE INSERT on participants) takes pg_advisory_lock(873791983, zid), a session lock. pid_auto_unlock (AFTER INSERT) gives it back at the end of the INSERT statement, before COMMIT. Two joiners racing in one conversation can therefore both compute MAX(pid) + 1 with the same value: the second gets the lock as soon as the first's INSERT ends, while the first's row is not yet committed. The second INSERT then fails on (zid, pid), its AFTER trigger never runs, and the session keeps the lock through ROLLBACK.

addParticipant then called client.release(), which returned that connection to the pool still holding the lock. After that, every later joiner of the conversation that landed on a different connection, on this server or any other, blocked inside pg_advisory_lock:

  • no lock_timeout;
  • no statement_timeout;
  • no pool acquire timeout.

Each blocked joiner holds one of the server's 12 read-write connections. A join burst at the start of a conversation can take all of them, and then every vote on that server waits too.

The fix

  • addParticipant closes (release(true)) any connection that saw an error, instead of pooling it. The lock ends with the session. This covers a lost pid race, an existing (zid, uid) (the BEFORE trigger has already taken the lock then too) and a lock timeout.
  • The participant transaction sets lock_timeout = '5s' (SET LOCAL, so pooled connections keep their defaults). This bounds every lock wait on the path: the advisory lock, the conversations row, and a concurrent same-user insert.
  • On a pid collision (23505 with no row for this uid), the insert is retried once on a new connection. The winner's row is committed by then, so the retry gets the next pid. A second collision still reaches the callers' existing retry loops, unchanged.
  • joinConversation's ten-attempt retry chain stops on SQLSTATE 55P03 (lock timeout): another attempt would only wait out the same timeout again. Every other error is retried as before.
  • The CSV import worker (flushBatchToDb) always closes its batch connection. Its INSERT … ON CONFLICT (zid, uid) DO NOTHING fires the BEFORE trigger for rows it then skips, and the AFTER trigger never runs for them. So the lock stays held even when the batch commits, whenever a batch re-proposes an existing participant. This was checked on Postgres 17: lock count 0 after a first batch, and still held after a second batch with two skipped rows.

How the participation path stays clear

  • A failed join can no longer leave a lock that other joiners wait on.
  • Any lock wait during a join is bounded at about 5 s on every join path. A lock held by something else costs each joiner who arrives while it is held one error after about 5 s, during which that joiner holds one read-write connection. A burst of joiners in that window can still occupy a server's connections for up to about 5 s. Before, they were held with no limit. Servers still running the previous release during a rolling deploy will keep leaking until they are replaced.
  • Votes from existing participants never take this lock. They were affected only through pool exhaustion, and this change removes that cause.

Evidence

Branch rebased onto edge 25e9ea52d (includes PR #3039). Everything below was run locally (arm64, Docker) on that tree.

Guard test: server/__tests__/participation-pg/join-lock.pgtest.ts, run by scripts/test-delphi-result-reads.sh, in the style of the result-read concurrency guard. It is generated data against a real, migrated Postgres 17 through the real join code:

  • Joiner A holds its transaction open between INSERT and COMMIT; that gap is one network round trip in production.
  • Joiner B goes through addParticipant and loses the race.
  • Joiner C goes through a second server process: its own module instance, so its own pools.
  • A held-lock test holds the participant lock from a separate session and checks that a join is answered. It runs for both join paths: addParticipant (votes, comments) and joinConversation (POST /api/v3/participants, /api/v3/joinWithInvite).

On the previous code (fd33de4ef), the race and held-lock tests fail:

{"lost_race":"error 23505","winner_pid":0,"sessions_holding_lock_after":1,"later_join_ms":">3000 (waiting)","later_join_finished_ms":10015}
{"held_lock_join":">8000 (waiting)","held_lock_join_ms":null,"finished_after_release_ms":8017}

C waited until B's leaked connection hit the pool's 10 s idle timeout. The held-lock join never answered.

With the first commit only, the joinConversation case still fails, because its ten-attempt retry keeps waiting:

{"path":"joinConversation (POST /api/v3/participants, /api/v3/joinWithInvite)","held_lock_join":">8000 (waiting)","held_lock_join_ms":null,"finished_after_release_ms":8015}

With both commits, all three pass:

{"lost_race":"pid 1","winner_pid":0,"sessions_holding_lock_after":0,"later_join_ms":10,"later_join_finished_ms":10}
{"path":"addParticipant (votes, comments)","held_lock_join":"error 55P03","held_lock_join_ms":5035,"finished_after_release_ms":5035}
{"path":"joinConversation (POST /api/v3/participants, /api/v3/joinWithInvite)","held_lock_join":"error 55P03","held_lock_join_ms":5056,"finished_after_release_ms":5056}

Reads job. scripts/test-delphi-result-reads.sh --ci: 6 suites and 42 tests pass, the new guard and the result-read concurrency guard included.

Server jest job. Run with the "Server Integration Tests" job's steps: migrations applied and checked, npm run contract:check, node src/db/migrations.cjs, and the job's exact npm test -- --ci --coverage --maxWorkers=2 with its environment block. The test stack's host ports were moved through a compose override, with the simulator issuer and JWKS following the moved port. Result: 123 suites passed (1 skipped), 1768 tests passed (4 skipped), including concurrent-same-participant, importProcessorBoundaries and the vote-path recordings. npm run lint and tsc --noEmit are clean.

Other session-level advisory locks (git grep pg_advisory_lock, pg_try_advisory_lock)

Where What Status
pid_auto via addParticipant (server/src/participant.ts) raw pooled client fixed here
pid_auto via the CSV import worker (server/src/workers/import-processor.ts) raw pooled client; leaks on a failed batch and on skipped ON CONFLICT rows fixed here (same pattern, one line)
pid_auto via treevite acceptance (server/src/invites/treevites.ts) pg.queryP autocommit; a failed query's connection is already closed (release(err) in server/src/db/pg-query.ts) safe, no change
tid_auto on comments (000000_initial.sql), same session-lock shape every comment insert in server/src goes through pg.queryP, which closes a failed connection safe today, no change
delphi/scripts/math_poller.py per-label admission lock on a dedicated, unpooled connection, held for the process by design not this pattern
delphi/polismath/tools/engine_rebuild.py offline single-writer lock on a pooled SQLAlchemy connection, held for the run by design; the engine is disposed right after not this pattern; never on the participation path
queue-rs/polis-migrate single-applier lock on its own connection, released explicitly not this pattern
delphi/polismath/poller/coordinator_bridge.py test-only fault latch on a dedicated connection, unlocked and closed not this pattern
migrations 000021-000030, ci/probe_box/provision_login.py pg_advisory_xact_lock (transaction-scoped) not this pattern
tests and harnesses (delphi/tests/… including delphi/tests/dynamo_removal/math_proof.py, queue-rs/polis-migrate/tests/prove.py, server/postgres/migrations/down/test_000021_down.py) deliberate holders on dedicated connections test-only
server/shadow/snapshot-pool.cjs (and its test) a refusal list that forbids these functions not a lock
scripts/after_install.sh a comment not a lock

Not in this PR

The root cause is the trigger itself: a session lock released before COMMIT. Making pid_auto take pg_advisory_xact_lock, with no AFTER unlock, would stop duplicate pids altogether. That is a schema change and needs its own discussion. tid_auto has the same shape.

The treevite participant insert (server/src/invites/treevites.ts, autocommit through pg.queryP) cannot leak the lock, but it has no lock_timeout. It can still wait with no limit on a lock held elsewhere, for example by a server on the previous release during a rolling deploy.

🤖 Generated with Claude Code

https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s

colinmegill and others added 2 commits October 12, 2026 13:20
The pid_auto trigger takes a session advisory lock per conversation and
gives it back only after an INSERT that succeeds. A participant INSERT that
failed after taking it (two joiners handed the same pid, an existing
(zid, uid), a lock timeout) kept the lock through ROLLBACK, and
addParticipant returned that connection to the pool. Every later joiner of
the conversation on another connection, on any server, then waited with no
timeout while holding a read-write connection.

- addParticipant closes a connection that saw any error instead of pooling it.
- The participant transaction sets lock_timeout to 5 s, so no join waits
  indefinitely on a lock held elsewhere.
- A joiner that loses the pid race retries once on a new connection, so it
  still gets its participant.
- The CSV import worker closes its batch connection: its
  ON CONFLICT DO NOTHING participant insert leaves the lock held for every
  skipped row, even when the batch commits.
- Guard test server/__tests__/participation-pg/join-lock.pgtest.ts (run by
  scripts/test-delphi-result-reads.sh): fails on the previous code, passes now.

No schema change; the trigger is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
joinConversation (POST /api/v3/participants, /api/v3/joinWithInvite)
retried any failed join up to ten times, so a participant lock held
elsewhere cost those routes ten lock timeouts, about 50 s, holding a
read-write connection throughout. A lock timeout (SQLSTATE 55P03) now ends
the chain: another attempt would only wait out the same timeout. Every
other error is retried as before.

The held-lock guard now covers both join paths, addParticipant and
joinConversation; the joinConversation case fails without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
@github-actions

Copy link
Copy Markdown

Delphi Coverage Report

File Stmts Miss Cover
polismath/init.py 2 0 100%
polismath/benchmarks/bench_pca.py 128 107 16%
polismath/benchmarks/bench_repness.py 81 65 20%
polismath/benchmarks/bench_update_votes.py 38 28 26%
polismath/benchmarks/benchmark_utils.py 34 0 100%
polismath/components/init.py 1 0 100%
polismath/components/config.py 165 70 58%
polismath/conversation/init.py 2 0 100%
polismath/conversation/conversation.py 1291 264 80%
polismath/conversation/group_vote_counts.py 38 2 95%
polismath/conversation/manager.py 131 42 68%
polismath/database/init.py 1 0 100%
polismath/database/dynamodb.py 392 176 55%
polismath/database/postgres.py 414 95 77%
polismath/delphi_storage/init.py 1 0 100%
polismath/delphi_storage/codec.py 295 22 93%
polismath/delphi_storage/golden_corpus.py 60 9 85%
polismath/delphi_storage/import_report.py 51 3 94%
polismath/delphi_storage/legacy_import.py 171 16 91%
polismath/delphi_storage/postgres.py 49 22 55%
polismath/delphi_storage/production_import.py 419 96 77%
polismath/delphi_storage/resource.py 179 32 82%
polismath/delphi_storage/scan_pacing.py 77 0 100%
polismath/delphi_storage/writer.py 158 17 89%
polismath/empty_output.py 29 2 93%
polismath/engine_adapter.py 317 265 16%
polismath/job_child/init.py 331 50 85%
polismath/job_child/census.py 101 8 92%
polismath/pca_kmeans_rep/init.py 5 0 100%
polismath/pca_kmeans_rep/clusters.py 257 38 85%
polismath/pca_kmeans_rep/corr.py 98 17 83%
polismath/pca_kmeans_rep/group_k_smoother.py 28 0 100%
polismath/pca_kmeans_rep/legacy_kmeans.py 197 8 96%
polismath/pca_kmeans_rep/pca.py 162 19 88%
polismath/pca_kmeans_rep/repness.py 226 5 98%
polismath/poller/init.py 4 0 100%
polismath/poller/admission.py 318 15 95%
polismath/poller/backfill.py 936 32 97%
polismath/poller/capacity.py 485 27 94%
polismath/poller/capacity_queue.py 248 13 95%
polismath/poller/coordinator_bridge.py 317 317 0%
polismath/poller/math_writer.py 113 3 97%
polismath/poller/promotion.py 112 4 96%
polismath/poller/readiness.py 368 13 96%
polismath/poller/rebuild_child.py 133 5 96%
polismath/poller/service.py 665 52 92%
polismath/poller/worker_pool.py 140 0 100%
polismath/queue/executor.py 223 111 50%
polismath/regression/init.py 4 0 100%
polismath/regression/clojure_comparer.py 188 20 89%
polismath/regression/comparer.py 891 640 28%
polismath/regression/datasets.py 135 27 80%
polismath/regression/recorder.py 36 27 25%
polismath/regression/utils.py 130 85 35%
polismath/replay/init.py 2 0 100%
polismath/replay/attribution_capture.py 49 0 100%
polismath/replay/axis_continuity.py 347 24 93%
polismath/replay/certify.py 999 59 94%
polismath/replay/crosslang.py 128 2 98%
polismath/replay/decision_ties.py 55 8 85%
polismath/replay/diagnostics.py 50 7 86%
polismath/replay/driver.py 73 1 99%
polismath/replay/dsn_admission.py 36 1 97%
polismath/replay/equiv_query.py 10 0 100%
polismath/replay/event_ingress.py 95 1 99%
polismath/replay/fixture_bundle.py 815 62 92%
polismath/replay/fixture_config.py 266 11 96%
polismath/replay/fixture_extract.py 285 12 96%
polismath/replay/fixture_generate.py 103 1 99%
polismath/replay/fixture_samples.py 157 14 91%
polismath/replay/fixture_selection.py 119 0 100%
polismath/replay/fixture_survey.py 147 11 93%
polismath/replay/legacy_pca.py 29 17 41%
polismath/replay/polarity.py 319 25 92%
polismath/replay/poller_equiv.py 776 99 87%
polismath/replay/prodclone.py 193 2 99%
polismath/replay/real_data.py 146 10 93%
polismath/replay/schedule.py 205 10 95%
polismath/replay/served_math.py 159 4 97%
polismath/replay/shard_bench.py 189 65 66%
polismath/replay/stagecompare.py 890 161 82%
polismath/replay/stages.py 256 40 84%
polismath/replay/stepcompare.py 86 1 99%
polismath/replay/store.py 120 11 91%
polismath/replay/tie_capture.py 107 19 82%
polismath/replay/types.py 71 6 92%
polismath/run_math_pipeline.py 284 93 67%
polismath/tools/engine_rebuild.py 230 11 95%
polismath/types.py 22 0 100%
polismath/utils/cli_flags.py 15 0 100%
polismath/utils/clj_hash.py 44 0 100%
polismath/utils/env_flags.py 13 0 100%
polismath/utils/general.py 63 39 38%
polismath/utils/output_profile.py 66 2 97%
polismath/utils/serialization.py 10 5 50%
polismath/utils/vote_convention.py 159 5 97%
umap_narrative/501_calculate_comment_extremity.py 113 38 66%
umap_narrative/502_calculate_priorities.py 136 103 24%
umap_narrative/700_datamapplot_for_layer.py 502 502 0%
umap_narrative/701_static_datamapplot_for_layer.py 311 311 0%
umap_narrative/702_consensus_divisive_datamapplot.py 435 275 37%
umap_narrative/801_narrative_report_batch.py 666 455 32%
umap_narrative/803_check_batch_status.py 268 148 45%
umap_narrative/llm_factory_constructor/init.py 2 0 100%
umap_narrative/llm_factory_constructor/model_provider.py 234 107 54%
umap_narrative/narrative_data.py 178 65 63%
umap_narrative/numerical_stages.py 54 25 54%
umap_narrative/polismath_commentgraph/init.py 1 0 100%
umap_narrative/polismath_commentgraph/cli.py 268 268 0%
umap_narrative/polismath_commentgraph/core/init.py 3 3 0%
umap_narrative/polismath_commentgraph/core/clustering.py 108 108 0%
umap_narrative/polismath_commentgraph/core/embedding.py 104 104 0%
umap_narrative/polismath_commentgraph/lambda_handler.py 219 219 0%
umap_narrative/polismath_commentgraph/schemas/init.py 2 0 100%
umap_narrative/polismath_commentgraph/schemas/dynamo_models.py 160 9 94%
umap_narrative/polismath_commentgraph/tests/conftest.py 17 17 0%
umap_narrative/polismath_commentgraph/tests/test_clustering.py 74 74 0%
umap_narrative/polismath_commentgraph/tests/test_embedding.py 55 55 0%
umap_narrative/polismath_commentgraph/tests/test_storage.py 87 87 0%
umap_narrative/polismath_commentgraph/utils/init.py 9 7 22%
umap_narrative/polismath_commentgraph/utils/converter.py 283 237 16%
umap_narrative/polismath_commentgraph/utils/group_data.py 332 289 13%
umap_narrative/polismath_commentgraph/utils/storage.py 581 466 20%
umap_narrative/reset_conversation.py 162 53 67%
umap_narrative/run_pipeline.py 337 207 39%
umap_narrative/topic_naming.py 148 11 93%
Total 25412 7951 69%

@colinmegill
colinmegill merged commit 91551fc into edge Oct 12, 2026
17 of 18 checks passed
@colinmegill
colinmegill deleted the join-lock-leak branch October 12, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant