FIX: Prevent connection pool size drift when close races failed open (#746) - #797
RKS (rksharma-owg) wants to merge 12 commits into
Conversation
…icrosoft#746) ### Work Item / Issue Reference > GitHub Issue: microsoft#746 ------------------------------------------------------------------- ### Summary This pull request fixes a race condition in `ConnectionPool` where an interleaved pool `close()` (or `close_pooling()`) during a failing connection open could corrupt `_current_size`, causing the pool to undercount active connections and briefly exceed `max_size` under concurrent load (microsoft#746). **Root Cause:** When a thread fails to open/construct a connection in Phase 3 of `ConnectionPool::acquire()`, its `catch (...)` handler decrements `_current_size` to release the reserved slot. If `ConnectionPool::close()` executed concurrently outside the lock, `close()` already cleared all idle connections and reset `_current_size = 0`. If another thread then acquired and reserved a slot (`_current_size = 1`), the first thread's subsequent decrement wiped out the new thread's reservation instead of its own, causing the counter to drift lower than the true active connection count. **Key Changes:** * Added `uint64_t _generation` counter to `ConnectionPool`, incremented on every `ConnectionPool::close()`. * In `ConnectionPool::acquire()`, captured `reservation_generation = _generation` alongside slot reservations (`++_current_size`). * In Phase 3 error recovery, guarded slot decrements with `if (_generation == reservation_generation && _current_size > 0)` so that stale reservation cleanups from an earlier generation do not cancel newer reservations. * Added regression test `test_pool_size_accounting_race_on_close_interleave` in `tests/test_009_pooling.py` reproducing the exact interleaving and verifying slot bounds are preserved.
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟡 Changes recommended
A critical reservation-accounting race remains, and the header dependency and regression-test issues should be addressed.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes connection-pool reservation drift when pool closure races a failed connection open.
Changes:
- Adds generation tracking to pool reservations and reset handling.
- Guards failed-open cleanup against stale generations.
- Adds subprocess regression coverage and environment handling updates.
Review findings:
connection_pool.cpp— critical, 1 vote: Phase 2 candidate cleanup remains unguarded and can decrement a newer reservation afterclose().connection_pool.h— moderate, 1 vote: Add<cstdint>for a self-containeduint64_tdeclaration.tests/test_009_pooling.py— nit, 3 votes: The test creates a new pool after manager reset and does not exercise the generation fix.
File summaries
| File | Summary |
|---|---|
tests/test_009_pooling.py |
Adds race regression coverage and subprocess environment handling. |
mssql_python/pybind/connection/connection_pool.h |
Adds pool generation state. |
mssql_python/pybind/connection/connection_pool.cpp |
Tracks generations and conditionally releases reservations. |
Review details
Suppressed comments (1)
mssql_python/pybind/connection/connection_pool.h:48
- The new member uses
uint64_t, but this header does not include<cstdint>and instead relies on a transitive include fromconnection.h/pybind11. That makes the header non-self-contained and can break a platform or translation unit when those unrelated includes change; add<cstdint>alongside the other standard-library includes.
uint64_t _generation = 0; // Pool reset generation for reservation attribution (#746)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…fication (microsoft#746) - Expose _TestConnectionPool in ddbc_bindings with current_size and generation accessors. - Update test_pool_size_accounting_race_on_close_interleave to share a single ConnectionPool instance across Thread A, Thread B, and Thread C, verifying that Thread A's failed open cannot cancel Thread B's reservation under a new generation.
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate issues remain in generation handling and test-seam connection lifecycle.
Review details
Suppressed comments (2)
mssql_python/pybind/connection/connection_pool.cpp:298
- This generation check only covers the Phase 3 reservation. A pooled candidate popped in Phase 2 is validated outside the mutex, and its failure still unconditionally decrements
_current_sizeat lines 233-235; ifclose()resets the pool during that validation, a newer reservation can be decremented in the same way. Track the candidate's generation (or otherwise attribute that decrement) as part of this fix.
{
std::lock_guard<std::mutex> lock(_mutex);
if (_generation == reservation_generation && _current_size > 0) {
--_current_size;
}
mssql_python/pybind/ddbc_bindings.cpp:6134
- The test seam drops the
std::shared_ptr<Connection>returned bypool.acquire(). On any successful acquire, theConnectionis destroyed immediately but_current_sizeis never decremented viarelease(), so this exposed class permanently consumes a slot and no longer models the real pool lifecycle. Please provide a way for the seam to retain/release the acquired connection (or otherwise balance the reservation) before using it for successful-acquire tests.
[](ConnectionPool& pool, const std::u16string& connStr,
const py::object& token_factory) {
pool.acquire(connStr, py::dict(), token_factory);
},
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ration (microsoft#746) - Capture candidate_generation when popping a candidate in ConnectionPool::acquire and guard validation failure cleanup against generation mismatches. - Capture release_generation in ConnectionPool::release to guard overflow disconnect decrements. - Add inject_candidate test helper to _TestConnectionPool. - Add regression test test_pool_size_accounting_race_on_candidate_validation_close_interleave.
There was a problem hiding this comment.
🟡 Changes recommended
Invalidated-generation candidates can still bypass capacity accounting and allow connections beyond max_size.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
…nPool (microsoft#746) - Register _TestPooledConnection in ddbc_bindings. - Return acquired Connection from _TestConnectionPool::acquire instead of dropping it. - Expose ConnectionPool::release on _TestConnectionPool so acquired connections can be returned to the pool.
Update on Copilot Review FeedbackAll points from Copilot review #5242055056 have been addressed and verified:
|
There was a problem hiding this comment.
🟡 Changes recommended
Successful old-generation opens after close() can still bypass accurate pool-size accounting.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
…crosoft#746) - Reject candidate reuse when the pool was closed during validation; discard stale candidate and retry acquire under the current generation. - Check generation validity after Phase 3 connect succeeds; if the pool was closed while connecting, discard the connection and retry acquire under the current generation rather than creating an uncounted connection exceeding max_size. - Add regression tests test_pool_size_accounting_race_on_successful_candidate_reuse_close_interleave and test_pool_size_accounting_race_on_successful_open_close_interleave.
Update on Copilot Review Feedback (Iteration 3)All feedback regarding candidate reuse success and connect success racing with
All 4 race regression tests pass cleanly in local verification. |
… release (microsoft#746) - Assign valid_conn and stamp pool origin under _mutex when generation matches to prevent check-then-publish races with close(). - Track originating pool and generation on Connection; reject and disconnect stale connections released after pool.close() or pool recreation to prevent uncounted connections in new pool. - Initialize _lastUsed to steady_clock::now() in Connection constructor and inject_candidate to prevent premature pruning during race tests. - Add regression test test_pool_release_from_stale_generation_does_not_pollute_pool.
Update on Copilot Review Feedback (Iteration 4)All feedback from review #5242178325 has been addressed in commit
All 5 race regression tests pass locally in 0.18s. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical issues affect pool identity safety, capacity enforcement, and concurrent connection removal.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 4
- Review effort level: Lite
Iteration 5 Review Updates (Commit
|
There was a problem hiding this comment.
🟡 Changes recommended
A critical close/open race can still temporarily exceed max_size while an older connection attempt is in flight.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
A critical pool-replacement lifecycle issue remains, and several regression tests do not reliably validate the intended fixes.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
mssql_python/pybind/connection/connection_pool.cpp:475
close()removes the idle connections from_pooland immediately reduces_current_sizeto only_checked_out + _in_flight, but the connections into_closeare not disconnected until afterward. A concurrentacquire()can therefore reserve those newly freed slots and open physical connections while the popped idle handles are still live, temporarily exceedingmax_size(the same invariant this change is intended to preserve). Keep theto_closecapacity reserved until disconnection completes, or otherwise synchronize new reservations with that cleanup.
_current_size = _checked_out + _in_flight;
++_generation;
tests/test_009_pooling.py:1383
- The final count assertions also pass if the Phase 3 generation check is removed: with in-flight capacity retained, the old-generation connection can be published alongside B and the totals still reach 2 without exceeding
max_size. Add an observable assertion (for example, count A's factory calls or identify the returned mock connection) proving that A disconnects its stale result and retries under the new generation.
# 2. OLD-GENERATION OPEN COMPLETES SECOND:
# Thread A finishes connecting outside the lock.
# Under generation fix, Thread A detects reservation generation mismatch (0 != 1),
# disconnects its stale connection, decrements in_flight (1 -> 0) and current_size (2 -> 1).
# Thread B's connection is intact and valid!
# Thread A retries acquire under generation 1 and successfully acquires the freed slot.
release_factory_a.set()
t_a.join(timeout=5.0)
assert len(t_a_error) == 0
assert len(t_a_conn) == 1
assert pool.checked_out == 2
assert pool.in_flight == 0
assert pool.current_size == 2
tests/test_009_pooling.py:1006
- This scenario constructs the pool with
max_size=2, whileclose()now deliberately retains Thread A's in-flight reservation. If the generation check is removed from the Phase 3 failure cleanup, the counter still goes from 2 to 1 (releasing A's own reservation), so this test passes unchanged and does not exercise the claimed generation-attribution fix. Please change the interleave/assertions so the test fails without the guard (or explicitly test the retained-reservation invariant instead).
pool = ddbc_bindings._TestConnectionPool(2, 600)
tests/test_009_pooling.py:1098
- This failure-path regression also uses
max_size=2, so the in-flight reservation retained acrossclose()means an unconditional cleanup decrement releases exactly the popped candidate's capacity (2 -> 1). The test therefore passes even if the generation guard is removed. Add an assertion that distinguishes the old-generation cleanup from a newer reservation, rather than only checking aggregate counts.
pool = ddbc_bindings._TestConnectionPool(2, 600)
tests/test_009_pooling.py:1509
- The release regression only inspects pool counters. If the generation/origin check is removed,
release(conn_1)can put the stale connection back into the idle deque with the same_current_size, and every assertion here still passes because the next acquire simply reuses it. Add an identity or factory-call assertion provingconn_2is a newly opened connection rather than the staleconn_1.
pool.release(conn_1)
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
A critical race can temporarily exceed the configured pool size, and regression tests do not fully validate generation protection.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
Previously missed (1) — in code that hasn't changed since the last review.
tests/test_009_pooling.py:1070
- This test does not distinguish the claimed generation-attributed cleanup from the existing in-flight retention logic: after
close()the counter is 1, Thread B makes it 2, and Thread A's unconditional cleanup reaches 1 whether a generation check is present or not. Consequently it would still pass if the failure-path generation protection were removed, so it cannot regress the original accounting bug as documented. Make the scenario/assertion fail without the intended fix (or update the test documentation to cover only in-flight retention).
mssql_python/pybind/connection/connection_pool.cpp:555
- The current-key replacement does not use the
use_count()guard applied by the sweep at lines 542-543. Thread A can copy this pool while holding_manager_mutexand pause before callingpool->acquire(); Thread B then seescanEvict()true, erases the pool, and creates a replacement. A resumes on the orphaned pool and can open up tomax_sizeconnections while later callers open anothermax_sizeon the replacement. Require that no external pool reference exists before replacing this entry.
if (it != _pools.end() && it->second && it->second->canEvict()) {
mssql_python/pybind/connection/connection_pool.cpp:670
- This final cleanup also erases a pool based only on
canEvict(), even thoughto_closestill holds a reference and an acquirer can hold another reference after the firstclose()returns. That acquirer can continue opening on the erased pool while a later call creates a replacement for the same key, defeating the capacity sharing. Retain the map entry while any external references exist, accounting for the snapshot reference held byto_close.
for (auto it = _pools.begin(); it != _pools.end();) {
if (!it->second || it->second->canEvict()) {
it = _pools.erase(it);
} else {
mssql_python/pybind/connection/connection_pool.cpp:475
close()removes idle connections from_pooland immediately drops their contribution from_current_size, but the physical disconnects run only afterward at lines 477-483. An acquire racing in that window can reserve and open a replacement while the old idle handle is still connected, temporarily exceeding the configured physical-connection limit. Keep these close-in-progress handles accounted for until each disconnect completes, then release their reservations under the mutex.
// Retain reserved capacity for checked-out connections and in-flight opens
// so a new acquire cannot exceed _max_size while old connections or opens
// are still live (#746).
_current_size = _checked_out + _in_flight;
++_generation;
tests/test_009_pooling.py:1101
- This candidate-validation regression has the same coverage gap as the failed-open test: because
close()retains the popped candidate in_in_flight, the unconditional cleanup at the end of validation releases that candidate's own reservation, so thecurrent_size == 1assertion still passes without any generation guard. The test does not actually prove that an old-generation validation failure cannot decrement a newer reservation; make the interleave distinguish those cases or narrow the test's stated contract.
pool = ddbc_bindings._TestConnectionPool(2, 600)
# Inject an expired candidate into the idle pool
pool.inject_candidate("SERVER=dummy_test_746;", 1)
assert pool.current_size == 1
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
Iteration 8 Update: In-Flight Teardown Accounting & Reference-Guarded EvictionPushed commit
|
There was a problem hiding this comment.
🟡 Changes recommended
Three critical findings remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
mssql_python/pybind/connection/connection_pool.cpp:193
- The sibling drain similarly removes connections and decrements
_current_sizebefore adding any in-flight teardown accounting, while the actual disconnects are deferred to Phase 4. If another acquire runs before those siblings are disconnected, it can open into the slots they still occupy and temporarily exceed the configured physical-connection cap. Transition drained siblings into_in_flightand release that capacity only after each disconnect finishes.
if (sibling->currentAccessToken() == stale_token) {
to_disconnect.push_back(sibling);
if (_current_size > 0) {
--_current_size;
}
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Lite
…rialize replacement pool (microsoft#746)
Iteration 9 Update: Phase 1 & Sibling Drain In-Flight Capacity, Serialized Replacement Pool, and GIL-Safe CallbacksPushed commit
|
…shared_ptr for GIL-safe hook (microsoft#746)
Iteration 10 Update: Synchronized Same-Key Pool Replacement, Decoupled GIL-Safe Hooks, and GIL-Released CV WaitsPushed commit
|
|
RKS (@rksharma-owg) thanks for raising the PR! |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
🟡 Changes recommended
Critical concurrency and interpreter-shutdown issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
mssql_python/pybind/connection/connection_pool.cpp:809
- When pooling is disabled, a pre-disable
acquireConnection()can still hold a shared pointer whileclosePools()runs. Becauseuse_count() > 1, this loop retains the pool even whenclose()found no live work; that acquire can then open on the retained pool andreturnConnection()will put the handle back into_pool. Disabled acquisitions never revisit the map, so the physical connection remains idle until process exit. Coordinate preselected acquires with disable, or make releases discard connections and remove the now-empty pool when_acceptingis false.
for (auto it = _pools.begin(); it != _pools.end();) {
if (!it->second || (it->second.use_count() == 1 && it->second->canEvict())) {
it = _pools.erase(it);
} else {
mssql_python/pybind/ddbc_bindings.cpp:6158
- The move-assignment cleanup also unconditionally acquires the GIL. If this holder is destroyed or moved during native pool teardown after Python finalization, this path has the same shutdown crash/hang as the destructor; apply the finalization guard before
py::gil_scoped_acquirehere as well.
PyObjectHolder& operator=(PyObjectHolder&& other) noexcept {
if (this != &other) {
if (ptr) {
py::gil_scoped_acquire gil;
Py_XDECREF(ptr);
}
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
| { | ||
| std::lock_guard<std::mutex> lock(_manager_mutex); | ||
| if (_accepting) { | ||
| auto& pool_ref = _pools[key]; | ||
| if (!pool_ref) { | ||
| pool_ref = std::make_shared<ConnectionPool>(_default_max_size, _default_idle_secs); | ||
| if (_mock_mode) { | ||
| pool_ref->set_mock_mode(true); | ||
| } | ||
| created = true; | ||
| } | ||
| pool = pool_ref; | ||
| } | ||
| closing_guard.remove(key); | ||
| } | ||
| if (!_accepting) { | ||
| return nullptr; |
| ~PyObjectHolder() { | ||
| if (ptr) { | ||
| py::gil_scoped_acquire gil; | ||
| Py_XDECREF(ptr); | ||
| ptr = nullptr; | ||
| } | ||
| } |
Code Coverage Report
Files needing attentionmssql_python.pybind.performance_counter.hpp: 0.7% mssql_python.pybind.logger_bridge.cpp: 57.9% mssql_python.pybind.ddbc_bindings.h: 61.5% mssql_python.pybind.logger_bridge.hpp: 70.8% mssql_python.row.py: 77.6% mssql_python.pybind.ddbc_bindings.cpp: 77.9% mssql_python.pybind.connection.connection.h: 78.5% mssql_python.pybind.connection.connection.cpp: 85.5% mssql_python.logging.py: 86.2% mssql_python.pybind.connection.connection_pool.cpp: 87.4% |
Work Item / Issue Reference
Summary
This pull request addresses issue #746: connection pool can briefly exceed
max_sizewhen pooling is disabled or closed under load.Root Cause
In
ConnectionPool::acquire(), when a thread prepares to open a new connection, it increments_current_sizeunder the mutex to reserve a slot before connecting outside the lock (Phase 3). If connection creation fails, thecatch (...)block decrements_current_sizeto release the reservation.However, if
ConnectionPool::close()(invoked byclose_pooling()or pool disposal) runs while that thread is attempting connection creation outside the lock,close()removes all idle connections and resets_current_size = 0. If a new thread subsequently callsacquire(), it successfully reserves a slot (_current_size = 1). When the first failing thread finally enters itscatch (...)block, its unconditional--_current_sizedecrements the counter to 0, wiping out the new thread's reservation. As a result,_current_sizedrifts below the true number of active connections, allowing the pool to exceedmax_sizeunder concurrent load.Solution
uint64_t _generation = 0;member toConnectionPool.ConnectionPool::close(),++_generation;is executed whenever the pool is reset alongside_current_size = 0.ConnectionPool::acquire(), the current_generationis recorded into a localreservation_generationvariable when reserving a slot (++_current_size).catch (...)block,_current_sizeis decremented only if_generation == reservation_generation, ensuring that a failed connection attempt from a previous pool generation never decrements a reservation made in a newer generation.Testing
test_pool_size_accounting_race_on_close_interleaveintests/test_009_pooling.py. The test simulates the exact interleaving (usingConnectionfactory hooks in a subprocess withmax_size=1) and asserts that a failed open in an older generation cannot erase a new generation's slot reservation or allow subsequent connections beyondmax_size.