Skip to content

Eliminate multi-assignment churn in the connection pool#1075

Merged
Kludex merged 3 commits into
mainfrom
pool-single-assignment
Jul 23, 2026
Merged

Eliminate multi-assignment churn in the connection pool#1075
Kludex merged 3 commits into
mainfrom
pool-single-assignment

Conversation

@Kludex

@Kludex Kludex commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

Investigating #1074: with many requests queued, throughput collapses. Profiling showed _assign_requests_to_connections at ~70% of runtime, called ~14x per request instead of 2.

The cause: assignment does not change an HTTP/1.1 connection's state, so a newly idle connection was handed to every queued request (and re-picked by later passes until the winner sent on it). Every loser woke, failed with ConnectionNotAvailable, re-queued, and triggered another full O(n) assignment pass - quadratic churn at high queue depth. #974 fixed the per-pass cost but intentionally preserved this behavior; this PR removes it, porting the "Bug 2" half of encode/httpcore#1035.

Changes

  • Assign each idle HTTP/1.1 connection to exactly one queued request: pop it from the availability snapshot within a pass, and exclude reserved-idle connections when building the snapshot.
  • Add can_multiplex() to the connection interface (False by default, True for established HTTP/2) so multiplexing connections are exempt and h2 bursts keep multiplexing.
  • Stop scanning the request queue once nothing can be assigned, and skip expiry probes and surplus-keepalive eviction for reserved connections - the latter could previously close a connection just handed to a request.
  • Regression tests: exactly 2 assignment passes per request (fails on main with 32 for 10 requests), and a warm-idle h2 burst must be fully assigned within one pass.

Benchmarks

1000 concurrent GETs against a local uvicorn server:

scenario main this PR
default pool 5.07s 0.33s
max_connections=1 100s 0.53s
sequential / low concurrency 0.55s unchanged

Parallel is now faster than sequential (0.33s vs 0.55s), confirmed on the discussion author's own benchmark. Incremental reservation bookkeeping to remove the last O(n)-per-pass term follows in #1076.

Verification

  • Full suite passes with 100% coverage; mypy/ruff/unasync clean.
  • HTTP/2 verified against hypercorn over TLS and real services: cold and warm bursts multiplex on a single connection, unchanged.
  • Concurrent streaming (sha256-verified), cancellation storms, keepalive expiry, PoolTimeout, and threaded sync Client all behave as before.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Assignment does not change an HTTP/1.1 connection's state, so a newly
idle connection was handed to every queued request in one pass and
re-picked by later passes until the winner sent on it. Every loser woke
up, failed with ConnectionNotAvailable, re-entered the queue, and
triggered another full O(n) assignment pass - quadratic churn at high
queue depth.

Drop a connection from the availability snapshot once assigned, and
exclude idle connections already reserved by an in-flight request when
building the snapshot. 1000 concurrent requests against a local server
drop from 5.1s to 0.9s with the default pool, and from 100s to 1.1s
with max_connections=1. HTTP/2 multiplexing is unaffected: an active h2
connection is not idle, so it stays available to additional streams.
@github-actions

Copy link
Copy Markdown

Docs preview:

@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 10.25%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 14 untouched benchmarks
⏩ 7 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation test_bench_line_decoder 115.3 µs 128.5 µs -10.25%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing pool-single-assignment (0ed00a0) with main (c4e73cf)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a3e1a2bbc2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/httpcore2/httpcore2/_sync/connection_pool.py Outdated
Comment thread src/httpcore2/httpcore2/_async/connection_pool.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/httpcore2/httpcore2/_sync/connection_pool.py Outdated
An idle HTTP/2 connection can serve further requests while reserved, so
treating it like HTTP/1.1 could leave a queued burst waiting for the
next pool event instead of multiplexing. Add can_multiplex() to the
connection interface (False by default, True for established HTTP/2)
and only apply the reserved-idle exclusion to connections that cannot
multiplex.

@11kkw 11kkw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-assignment fix itself looks sound. I left one follow-up performance concern inline.

Comment thread src/httpcore2/httpcore2/_async/connection_pool.py
Each assignment pass walked every in-flight request even when the pool
was saturated, and re-probed reserved idle connections for expiry with
an is_readable socket check on every interleaved pass. Break out of the
assignment loop once no connection is available and no new one may be
created, and skip expiry checks and surplus-keepalive eviction for
connections reserved by an assigned request - they were health-checked
at assignment time, and evicting them would hand the winning request a
closed connection.

1000 unbounded concurrent requests against a local server now complete
in 0.41s versus 0.51s sequential, compared to 0.93s before this change
and 5.1s before #1075.
@Kludex

Kludex commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@11kkw Thanks for the thorough verification. 0ed00a0 implements your point 3 plus reservation-aware sweeping: the assignment loop now breaks once nothing can be assigned, and reserved idle connections skip the has_expired() socket probe and surplus-keepalive eviction (the latter could previously close a connection just handed to a request). With that, 1000 unbounded concurrent requests complete in 0.41s vs 0.51s sequential on my machine - the remaining aggregate scan cost is O(N x connections) from the per-pass cleanup sweep. Points 1/2/4 (incremental reservation state and a pending queue per origin) are a larger redesign - I'd take that as a follow-up PR.

@Kludex

Kludex commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@11kkw ignore the above, it was my coding agent. Sorry.

@Kludex Kludex changed the title Assign each released connection to a single queued request Eliminate multi-assignment churn in the connection pool Jul 23, 2026
@Kludex

Kludex commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@11kkw I'd appreciate a double check from your side here if you have time. 🙏

@11kkw 11kkw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double-checked the latest changes locally. LGTM, thanks! 👍

@Kludex
Kludex merged commit b082502 into main Jul 23, 2026
14 of 15 checks passed
@Kludex
Kludex deleted the pool-single-assignment branch July 23, 2026 11:53
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.

2 participants