Skip to content

PYTHON-5898 Reduce pool checkout lock overhead and stop mutating TopologyDescription during selection - #16

Closed
blink1073 wants to merge 25 commits into
mainfrom
PYTHON-5898-final
Closed

PYTHON-5898 Reduce pool checkout lock overhead and stop mutating TopologyDescription during selection#16
blink1073 wants to merge 25 commits into
mainfrom
PYTHON-5898-final

Conversation

@blink1073

@blink1073 blink1073 commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Changes in this PR

  • Reduced the number of pool lock acquisitions on the connection checkout fast
    path. The contended path is unchanged.
  • Kept checkout-failed events published while holding the pool lock, preserving
    the ordering guarantee from PYTHON-3519.
  • Fixed client.primary raising IndexError, and client.secondaries and
    client.arbiters returning stale results, after a retryable operation
    deprioritized the primary. Regression from PYTHON-5662.
  • Breaking: removed TopologyDescription.candidate_servers, added in
    4.16.0. Its value depended on which selection call ran last. known_servers
    is the direct replacement and is already public.
  • Fixed a leak, present since PYTHON-2395, where a failed checkout permanently
    incremented a pool's operation_count, so an affected mongos was
    progressively avoided by server selection.

Not pursued: the proposed Topology reader/writer lock

The ticket also proposed giving Topology a reader/writer lock. That was
implemented and then reverted: it made server selection about 30% slower at
concurrency 20 in every case measured, including topology churn.

Measuring the premise directly, under real operations rather than a tight
selection loop, the topology lock turns out not to be contended. Time spent
blocked waiting for it, as a share of aggregate worker time:

concurrency throughput lock wait mean wait per acquire
20 8,052 ops/s 0.016% 0.20 us
50 7,863 ops/s 0.006% 0.18 us
100 7,673 ops/s 0.003% 0.18 us

Contention falls as concurrency rises, and the mean wait per acquire stays at
roughly the cost of an uncontended acquire. Measured against localhost, where
the lock's share of total time is as large as it will ever be.

The ceiling on optimizing this lock is about 0.016% throughput, for any
approach. Suggest recording that on the ticket and dropping the work from its
scope.

Test Plan

Nine new tests. Each was run against the unfixed code and confirmed to fail
there, then against the fix and confirmed to pass:

  • three covering the selection bug, including a threaded one and the
    client.primary, client.secondaries and client.arbiters symptoms
  • four covering the operation_count leak and checkout-failed event behaviour,
    on both the fast and the contended checkout path
  • two pinning the number of pool lock acquisitions a checkout makes, so the
    reduction cannot be undone unnoticed

To confirm the selection change is otherwise behavior-preserving, selection
output was captured before and after across every server-selection and
max-staleness fixture, nine selectors, and every subset of deprioritized
servers. Results are identical apart from the client.primary calls that
previously raised.

Clean runs of the topology, server-selection, pool, connection-monitoring, and
SDAM suites, in both the async and sync flavours. just synchro produces no
drift, just typing reports 0 errors, just lint-manual passes.

Checkout throughput at concurrency 20 is up 6.6%; server selection is unchanged.

Two pre-existing issues surfaced, neither introduced here:

  • Running the topology, pool, and connection-monitoring suites in one pytest
    process produces order-dependent TestPoolMaxSize failures on this branch and
    on unmodified main, a different set each time. All pass in isolation.
  • The CMAP harness's check_events only fails on missing events and silently
    tolerates extra ones, so duplicate events can pass unnoticed.

Checklist

Checklist for Author

  • Did you update the changelog (if necessary)? Four entries under 4.18.0.
  • Is there test coverage? Nine new tests, each confirmed failing pre-fix.
  • Is any followup work tracked in a JIRA ticket? If so, add link(s).
    PYTHON-5982 adds a concurrent checkout benchmark to the performance suite,
    so a regression like the reader/writer lock's would be caught in CI.
    Still to file: update PYTHON-5898 to drop the topology lock work from
    scope; the two test issues above; deprioritization applies only to the
    first attempt of a selection (from PYTHON-5662); and tools/synchro.py's
    "aiter": "iter" replacement is not word-boundary aware, so it silently
    corrupts any identifier or comment containing "waiter".

blink1073 added 25 commits July 30, 2026 08:40
_filter_servers() cached its per-call filtered list on
self._candidate_servers, and apply_selector() read it back through the
candidate_servers property. TopologyDescription is a shared, publicly
exposed *immutable* snapshot, so this left candidate_servers permanently
stale after any selection that deprioritized servers: the cached value
from the last call, not the true set of known servers.

This is a regression from PYTHON-5662 (4.16.0, commit 0cfba49), which
changed Selection.from_topology_description() to default to
candidate_servers instead of known_servers. On current main the stale
cache makes Topology.get_primary() (and client.primary) raise IndexError
on the unguarded selection[0] after any retryable operation deprioritized
the primary, and makes client.secondaries/client.arbiters silently return
stale results. All of these are single-threaded, user-facing symptoms,
not a concurrency issue.

Return the candidate list instead and pass it explicitly to
Selection.from_topology_description(), restoring TopologyDescription to a
genuinely immutable snapshot after construction and restoring
candidate_servers to its pre-4.16.0 value.
Pool._get_conn() used to take self.lock, then self.size_cond, then
self.lock again just to bump operation_count and requests/active_sockets
on an uncontended checkout. self.lock, size_cond, and _max_connecting_cond
all wrap the same underlying mutex, so those three acquisitions were
serializing on one lock for no reason.

On the fast (uncontended) path, do the operation_count, requests, and
active_sockets bookkeeping in a single critical section under self.lock,
falling through to the old size_cond wait loop only when no pool slot is
immediately available. The contended (slow) path is otherwise unchanged.

Also wrap the whole checkout body in try/except so operation_count is
always decremented on failure, fixing a pre-existing leak where a failed
checkout left operation_count incremented forever, skewing server
selection among mongoses toward the affected server.
…failure

Adds test_wait_queue_timeout_does_not_leak_operation_count to
test/test_pooling.py and test/asynchronous/test_pooling.py, covering the
"Merge pool checkout lock acquisitions on the fast path" fix: a checkout
that fails while waiting for a free pool slot (wait queue timeout) must
not leave Pool.operation_count permanently incremented for the failed
attempt.

Each test was verified to fail against the pre-fix code (operation_count
left incremented instead of returning to its prior value), and passes
after the fix.
The lock-merge refactor moved the pool-not-ready and wait-queue-timeout
checks inside the outer try/except (needed for the operation_count leak
fix), but didn't account for the outer except also running for those
exceptions now. That caused a second, incorrect ConnectionCheckOutFailed
event (reason connectionError) to follow the correct one whenever a
checkout failed because the pool was paused/closed or because the wait
queue timed out.

Fix: the three _raise_if_not_ready call sites in the fast/slow path now
pass emit_event=False and let the outer except emit once, and the
wait-queue-timeout branch now sets emitted_event=True before raising,
mirroring the existing max_connecting_cond pattern. Also removed the
redundant slot_acquired flag (identical to requests_incremented at that
point) and folded operation_count's decrement into the size_cond
critical section in the except block, gating notify() on whether a
slot was actually released.

Extends the operation_count leak regression test to also assert on
requests/active_sockets and on the emitted event count/reason, and adds
a second regression test covering the pool-paused checkout-failure
path, both of which fail against the pre-fix code with the double
emission this commit fixes.
… test

The Topology reader/writer lock explored on this branch was benchmarked
~30% slower and fully reverted, but three places still described
apply_selector()/_filter_servers() as running under a "shared read
lock" that no longer exists. Rewrite those docstrings/comments around
the actual, still-true justification: TopologyDescription is a shared,
immutable snapshot, so caching a per-call filtered list on it leaves
the public candidate_servers property stale and can make
Topology.get_primary() raise IndexError.

Also renames TestTopologyDescriptionConcurrency to
TestTopologyDescriptionImmutability to match, adds a regression test
for the concrete get_primary()/IndexError symptom, adds a timeout to
the existing racy test's barrier.wait() so a dead worker can't hang CI,
and documents all three fixes in the changelog.
PYTHON-5662 added candidate_servers to plumb its deprioritization-filtered
list into Selection.from_topology_description(), which took only a
TopologyDescription. The list is now passed explicitly, so the property has
no remaining consumer.

Its documented contract ("servers excluding deprioritized servers") could
not be satisfied anyway: deprioritization is an argument to a single
selection call, so there is no single correct answer for a description that
serves many calls. Selection now defaults to known_servers.
test_apply_selector_does_not_mutate_description asserted on the
candidate_servers property; once that property was removed the assertion
reduced to a tautology and passed against the unfixed code, so it was
carrying no weight. The two remaining tests in the class both fail against
the unfixed code and cover the same ground.

Also drop the remaining references to the removed property, and stop
describing TopologyDescription as immutable while explaining that it used
to be mutated.
…w-path counters

Restore upstream's ordering guarantee (PYTHON-3519) by having the three
size_cond-region readiness checks publish ConnectionCheckOutFailed while
holding the mutex, bracketed so the outer handler does not double-emit.
Fold the slow path's active_sockets increment into the size_cond block,
collapsing the two counter flags into one.
Register a CMAP listener that records pool.lock.locked() at the moment
ConnectionCheckOutFailedEvent fires. Listeners run synchronously inside
the publish call, so this observes directly whether the emitting code
still holds the mutex, pinning the PYTHON-3519 ordering guarantee.
…ions

candidate_servers returned a list of known servers; server_descriptions()
returns a dict of every server including Unknown. known_servers is the
exact analogue and is what selection now falls back to.
The old wording blamed concurrent selection calls, but select_servers(),
get_primary() and _get_replica_set_members() all hold Topology._lock for
the whole call, so those apply_selector() calls never overlap. The actual
hazards are sequential state leakage into later readers of the same
description, and genuinely concurrent reads from external callers such as
topology event listeners and holders of client.topology_description.
Correct the class docstring's rationale to match topology_description.
Surface unexpected worker exceptions (a BrokenBarrierError from the
barrier timeout, say) through errors instead of letting threading print
and discard them, which left errors empty and passed vacuously. Add a
get_secondaries() regression test, since the stale-membership bug the
changelog describes covered client.secondaries/arbiters too.
Set each flag immediately after its own increment, with no statement in
between, matching upstream. A single combined flag left a window where an
interrupt delivered between the two increments and the flag assignment
would leak both counters.
…path

Nothing asserted the number of lock acquisitions, so splitting the merged
counter bookkeeping back apart would have passed every other test in the
file and silently undone the change this ticket is for.
Counterpart to the fast-path test. Folding the slot bookkeeping into the
size_cond critical section removed an acquisition from the contended path,
and nothing asserted it, so splitting it back apart would have gone
unnoticed.
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