Skip to content

server: cap reports, exports and heavy admin reads below the participation path - #3045

Merged
colinmegill merged 4 commits into
edgefrom
connection-budget
Oct 13, 2026
Merged

colinmegill merged 4 commits into
edgefrom
connection-budget

Conversation

@colinmegill

@colinmegill colinmegill commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Observable changes (please rule on these before merge)

No report or export output changes. What changes is how reports and exports acquire database connections, and so their behaviour under load:

  1. Reports, the /api/v3/dataExport CSV routes, the comment-clusters export, conversationStats, the moderator participant query and the owner-gated GET /api/v3/participation stats route now run on a small dedicated connection pool (default 1 connection per server process, so they run one at a time) instead of the shared read pool. The moderator participant query additionally moves off the read-write (vote) pool it used before, where it ran a heavy CTE alongside vote and comment writes — a direct win for the write path. Under heavy concurrent report/export load, a report or export that cannot get one of those connections within the acquire timeout (default 30s) is now rejected with an error, where before it would wait on the shared read pool. That is the point: it keeps that load off the connections votes, comments and the next statement need.
  2. A single report/export SQL statement is now capped by a statement timeout (default 5 minutes). A report or export whose statement runs longer than that would now time out; previously it was unbounded. The default is deliberately generous so today's largest exports still succeed, and streamed exports read their cursor in small batches, so the cap bounds a single runaway statement, not a long stream. Configurable (REPORT_EXPORT_STATEMENT_TIMEOUT_MS).
  3. conversationStats, the moderator participant query and the owner-gated participation-stats route (all admin/owner-only) now share the small export pool, so under concurrent exports they may queue or, past the acquire timeout, fail — admin/owner reads, never the participant-facing path.

I could not measure production export sizes or durations, so the defaults are generous; raise them per deployment if needed. The pool points at the same database as the read pool — no new host, no replica.

Why

The participant-facing path — submitting a vote, submitting a comment, and getting the next statement — must always answer and must never be made to wait or be rejected because of other work. Today the data-export routes stream whole conversations on the shared read pool, which the next-statement call also uses (the math blob read and GET /votes). This moves the heavy, whole-conversation reports, exports and admin/owner scans onto their own small, bounded pool, so those can no longer take the read-pool connections the participation path needs.

One report-family read stays on the read pool by design: the xid report export (xid report route → getXids). It is a paginated, indexed join bounded by LIMIT/OFFSET, not a whole-conversation scan, and getXids is a shared helper also called by the owner participation-stats route, so it was left on the read pool. The accurate guarantee is therefore: the whole-conversation streaming exports and admin/owner scans can never take the read-pool connections; the xid export remains a small, bounded read on the read pool (owner-gated, far below the risk of the public streaming exports this isolates).

What changed

  • A fourth server pool, bounded by REPORT_EXPORT_POOL_MAX (default 1), with an acquire timeout and a per-connection statement_timeout; new queryP_reportExport / stream_queryP_reportExport.
  • The export module (report.ts), the comment-clusters export, conversationStats, the moderator participant query and the owner-gated GET /api/v3/participation stats route (its two whole-conversation group-by scans) route through them. The moderator participant query additionally moves off the read-write (vote) pool. Participant-facing reads are unchanged.
  • Streamed exports now release their connection promptly if the HTTP client disconnects mid-download: the cursor is closed and fetching stops, so an aborted download cannot hold an export connection until the whole conversation has been read. The success-path output is byte-for-byte unchanged.
  • Hardened the shared pooled-query helper: a failed/timed-out connection acquire previously threw a TypeError (it called a release callback the pool does not provide on that path), masking the real error; it now rejects cleanly.
  • New env vars documented in docs/configuration.md, with the per-workload connection budget.

How the participation path stays clear

Votes and comments write on the read-write pool; the next-statement call reads on the read-write pool (candidate selection) and the read pool (math blob, GET /votes). None use the new export pool. The guard server/__tests__/participation-pg/export-budget.pgtest.ts saturates the export pool with slow queries and asserts a vote, a comment and the next-statement call each answer well under budget (~20 ms observed) while a further export is rejected at the acquire timeout. A second guard in the same file shows a streamed export that is disconnected mid-stream frees its connection so a further export is served immediately. Both run in the existing "Postgres Delphi result reads" CI job and are not skippable.

Tests

The report/export unit suites (exportRoutes, emptyConversationConsumers) are updated to the new query methods and pass. The Postgres reads suite (Postgres 17 + migrations) runs 7 suites / 44 tests, including the result-pool concurrency guard and the export-pool guard. With the pool capped at 2 for the test, the export-pool guard reports:

  • saturation: {export_pool_max:2, export_connections_held:2, acquire_timeout_ms:1500, vote_ms:26, comment_ms:20, nextcomment_ms:21, overflow_export_ms:1526, overflow_export_rejected:true}
  • client disconnect: {disconnect_test:true, export_pool_max:2, holders:1, stream_ended_normally:false, probe_ms:6, probe_served:true} — a further export is served in 6 ms after a mid-stream disconnect frees the connection.

🤖 Generated with Claude Code

https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s

colinmegill and others added 4 commits October 12, 2026 22:48
…ed pool

Reports, the data-export CSV routes, the comment-clusters export,
conversationStats and the moderator participant query now use a small,
timeout-bounded connection pool, kept separate from the pools the
participant-facing path uses (vote and comment writes and next-statement
selection on the read-write pool; the math blob read, GET /votes and
participation reads on the read pool). A flood of slow reports or exports can
no longer take the connections votes, comments and the next statement need:
an export that cannot get one of the pool's connections within the acquire
timeout is rejected rather than waiting on, or spilling into, a
participant-facing connection, and each statement on the pool is capped by a
statement timeout.

The pool size, acquire timeout and statement timeout are configurable
(REPORT_EXPORT_POOL_MAX, REPORT_EXPORT_ACQUIRE_TIMEOUT_MS,
REPORT_EXPORT_STATEMENT_TIMEOUT_MS) and documented in docs/configuration.md,
alongside the per-workload connection budget.

Also hardens the shared pooled-query helper: a failed or timed-out connection
acquire previously threw a TypeError that masked the real error, because it
called a release callback the pool does not provide on that path.

A load guard (participation-pg/export-budget.pgtest.ts) saturates the export
pool and proves a vote, a comment and the next-statement call stay prompt
while a further export is rejected. It runs in the existing Postgres
result-reads CI job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…xport pool, free export connections on client disconnect

- Update the export and empty-conversation unit mocks to the dedicated
  report/export query methods, so the senders' existing assertions hold and
  prove the senders read on the report/export pool.
- Run the owner-gated GET /participation whole-conversation group-by scans on
  the report/export pool, the same class as conversationStats; getXids stays on
  the read pool (shared helper, bounded paginated join).
- Release the report/export connection when the HTTP client disconnects
  mid-stream: close the cursor and stop fetching so an aborted download cannot
  hold one of the few export connections. Success-path output is unchanged. Add
  a guard proving a disconnected export frees its connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…t/export methods

The vote-sign export byte test mocks pg-query and exercises the report senders,
which now read on the dedicated report/export pool. Point its mock at
queryP_reportExport / stream_queryP_reportExport so its assertions hold, the same
fix as the other two export unit suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…efault

REPORT_EXPORT_POOL_MAX now defaults to 1, so further exports wait for the
acquire timeout and the primary keeps more room for participation.

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/embedding_spool.py 58 6 90%
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 429 96 78%
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 352 51 86%
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 7 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 310 310 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 25500 7956 69%

@colinmegill
colinmegill merged commit b282981 into edge Oct 13, 2026
28 of 29 checks passed
@colinmegill
colinmegill deleted the connection-budget branch October 13, 2026 08:41
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