Skip to content

Commit e90cc41

Browse files
authored
PYTHON-5672 Refactor connection checkout to reduce layers (#2893)
1 parent bf1c577 commit e90cc41

15 files changed

Lines changed: 719 additions & 275 deletions

pymongo/asynchronous/mongo_client.py

Lines changed: 169 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
import weakref
4343
from collections import defaultdict
4444
from collections.abc import AsyncGenerator, Collection, Coroutine, Mapping, MutableMapping, Sequence
45-
from contextlib import AbstractAsyncContextManager
4645
from typing import (
4746
TYPE_CHECKING,
4847
Any,
@@ -141,7 +140,7 @@
141140
from pymongo.asynchronous.client_session import AsyncClientSession, _ServerSession
142141
from pymongo.asynchronous.cursor_base import _ConnectionManager
143142
from pymongo.asynchronous.encryption import _Encrypter
144-
from pymongo.asynchronous.pool import AsyncConnection
143+
from pymongo.asynchronous.pool import AsyncConnection, _PoolCheckout
145144
from pymongo.asynchronous.server import Server
146145
from pymongo.read_concern import ReadConcern
147146
from pymongo.response import Response
@@ -1789,39 +1788,8 @@ async def _get_topology(self) -> Topology:
17891788
self._opened = True
17901789
return self._topology
17911790

1792-
@contextlib.asynccontextmanager
1793-
async def _checkout(
1794-
self, server: Server, session: Optional[AsyncClientSession]
1795-
) -> AsyncGenerator[AsyncConnection, None]:
1796-
in_txn = session and session.in_transaction
1797-
async with _MongoClientErrorHandler(self, server, session) as err_handler:
1798-
# Reuse the pinned connection, if it exists.
1799-
if in_txn and session and session._pinned_connection:
1800-
err_handler.contribute_socket(session._pinned_connection)
1801-
yield session._pinned_connection
1802-
return
1803-
async with await server.checkout(handler=err_handler) as conn:
1804-
# Pin this session to the selected server or connection.
1805-
if (
1806-
in_txn
1807-
and session
1808-
and server.description.server_type
1809-
in (
1810-
SERVER_TYPE.Mongos,
1811-
SERVER_TYPE.LoadBalancer,
1812-
)
1813-
):
1814-
session._pin(server, conn)
1815-
err_handler.contribute_socket(conn)
1816-
if (
1817-
self._encrypter
1818-
and not self._encrypter._bypass_auto_encryption
1819-
and conn.max_wire_version < 8
1820-
):
1821-
raise ConfigurationError(
1822-
"Auto-encryption requires a minimum MongoDB version of 4.2"
1823-
)
1824-
yield conn
1791+
def _checkout(self, server: Server, session: Optional[AsyncClientSession]) -> _ClientCheckout:
1792+
return _ClientCheckout(self, server, session)
18251793

18261794
async def _select_server(
18271795
self,
@@ -1874,41 +1842,22 @@ async def _select_server(
18741842

18751843
async def _conn_for_writes(
18761844
self, session: Optional[AsyncClientSession], operation: str
1877-
) -> AbstractAsyncContextManager[AsyncConnection]:
1845+
) -> _ClientCheckout:
18781846
server = await self._select_server(writable_server_selector, session, operation)
18791847
return self._checkout(server, session)
18801848

1881-
@contextlib.asynccontextmanager
1882-
async def _conn_from_server(
1849+
def _conn_from_server(
18831850
self, read_preference: _ServerMode, server: Server, session: Optional[AsyncClientSession]
1884-
) -> AsyncGenerator[tuple[AsyncConnection, _ServerMode], None]:
1851+
) -> _ClientReadCheckout:
18851852
assert read_preference is not None, "read_preference must not be None"
1886-
# Get a connection for a server matching the read preference, and yield
1887-
# conn with the effective read preference. The Server Selection
1888-
# Spec says not to send any $readPreference to standalones and to
1889-
# always send primaryPreferred when directly connected to a repl set
1890-
# member.
1891-
# Thread safe: if the type is single it cannot change.
1892-
# NOTE: We already opened the Topology when selecting a server so there's no need
1893-
# to call _get_topology() again.
1894-
single = self._topology.description.topology_type == TOPOLOGY_TYPE.Single
1895-
async with self._checkout(server, session) as conn:
1896-
if single:
1897-
if conn.is_repl and not (session and session.in_transaction):
1898-
# Use primary preferred to ensure any repl set member
1899-
# can handle the request.
1900-
read_preference = ReadPreference.PRIMARY_PREFERRED
1901-
elif conn.is_standalone:
1902-
# Don't send read preference to standalones.
1903-
read_preference = ReadPreference.PRIMARY
1904-
yield conn, read_preference
1853+
return _ClientReadCheckout(self, server, session, read_preference)
19051854

19061855
async def _conn_for_reads(
19071856
self,
19081857
read_preference: _ServerMode,
19091858
session: Optional[AsyncClientSession],
19101859
operation: str,
1911-
) -> AbstractAsyncContextManager[tuple[AsyncConnection, _ServerMode]]:
1860+
) -> _ClientReadCheckout:
19121861
assert read_preference is not None, "read_preference must not be None"
19131862
server = await self._select_server(read_preference, session, operation)
19141863
return self._conn_from_server(read_preference, server, session)
@@ -1937,8 +1886,12 @@ async def _run_operation(
19371886
)
19381887

19391888
async with operation.conn_mgr._lock:
1940-
async with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type]
1941-
err_handler.contribute_socket(operation.conn_mgr.conn)
1889+
async with _ClientCheckout.for_existing_conn(
1890+
self,
1891+
server,
1892+
operation.session, # type: ignore[arg-type]
1893+
operation.conn_mgr.conn,
1894+
):
19421895
return await run_with_conn(
19431896
operation.conn_mgr.conn, operation, operation.read_preference
19441897
)
@@ -2670,10 +2623,16 @@ def _add_retryable_write_error(exc: PyMongoError, max_wire_version: int, is_mong
26702623
exc_to_check._add_error_label("RetryableWriteError")
26712624

26722625

2673-
class _MongoClientErrorHandler:
2674-
"""Handle errors raised when executing an operation."""
2626+
class _ClientCheckout:
2627+
"""Context manager for checking out a connection from the pool.
2628+
2629+
Handles pool checkout, SDAM error handling, and session pinning.
2630+
"""
26752631

26762632
__slots__ = (
2633+
"_existing_conn",
2634+
"_pool_checkout",
2635+
"_server",
26772636
"client",
26782637
"completed_handshake",
26792638
"handled",
@@ -2707,9 +2666,12 @@ def __init__(
27072666
self.completed_handshake = False
27082667
self.service_id: Optional[ObjectId] = None
27092668
self.handled = False
2669+
self._existing_conn: Optional[AsyncConnection] = None
2670+
self._pool_checkout: Optional[_PoolCheckout] = None
2671+
self._server = server
27102672

27112673
def contribute_socket(self, conn: AsyncConnection, completed_handshake: bool = True) -> None:
2712-
"""Provide socket information to the error handler."""
2674+
"""Record connection metadata needed for SDAM error handling."""
27132675
self.max_wire_version = conn.max_wire_version
27142676
self.sock_generation = conn.generation
27152677
self.service_id = conn.service_id
@@ -2744,21 +2706,156 @@ async def handle(
27442706
assert self.client._topology is not None
27452707
await self.client._topology.handle_error(self.server_address, err_ctx)
27462708

2747-
async def __aenter__(self) -> _MongoClientErrorHandler:
2748-
return self
2709+
async def __aenter__(self) -> AsyncConnection:
2710+
if self._existing_conn is not None:
2711+
return self._existing_conn
2712+
server = self._server
2713+
session = self.session
2714+
in_txn = session and session.in_transaction
2715+
# Reuse the pinned connection, if it exists.
2716+
if in_txn and session and session._pinned_connection:
2717+
self.contribute_socket(session._pinned_connection)
2718+
return session._pinned_connection
2719+
pool_checkout = server.pool.checkout(self)
2720+
try:
2721+
conn = await pool_checkout.__aenter__()
2722+
except BaseException as exc:
2723+
# __aenter__ raised — pool already cleaned up internally.
2724+
# Run SDAM error handling so the topology learns about the failure.
2725+
await self.handle(type(exc), exc)
2726+
raise
2727+
self._pool_checkout = pool_checkout
2728+
try:
2729+
# Pin this session to the selected server or connection.
2730+
if (
2731+
in_txn
2732+
and session
2733+
and server.description.server_type
2734+
in (
2735+
SERVER_TYPE.Mongos,
2736+
SERVER_TYPE.LoadBalancer,
2737+
)
2738+
):
2739+
session._pin(server, conn)
2740+
self.contribute_socket(conn)
2741+
if (
2742+
self.client._encrypter
2743+
and not self.client._encrypter._bypass_auto_encryption
2744+
and conn.max_wire_version < 8
2745+
):
2746+
raise ConfigurationError(
2747+
"Auto-encryption requires a minimum MongoDB version of 4.2"
2748+
)
2749+
except BaseException as exc:
2750+
try:
2751+
await self.handle(type(exc), exc)
2752+
finally:
2753+
# Reset before checkin so pool.checkin() does not decrement ntxns.
2754+
conn.pinned_txn = False
2755+
conn.pinned_cursor = False
2756+
# Clear stale session pin state so future ops don't reuse it.
2757+
if session and session.in_transaction:
2758+
await session._unpin()
2759+
await pool_checkout.__aexit__(type(exc), exc, exc.__traceback__)
2760+
self._pool_checkout = None
2761+
raise
2762+
return conn
27492763

27502764
async def __aexit__(
27512765
self,
2752-
exc_type: Optional[type[Exception]],
2753-
exc_val: Optional[Exception],
2766+
exc_type: Optional[type[BaseException]],
2767+
exc_val: Optional[BaseException],
27542768
exc_tb: Optional[TracebackType],
27552769
) -> None:
2756-
return await self.handle(exc_type, exc_val)
2770+
# Perform SDAM error handling while the connection is still checked out.
2771+
try:
2772+
await self.handle(exc_type, exc_val)
2773+
finally:
2774+
if self._pool_checkout is not None:
2775+
await self._pool_checkout.__aexit__(exc_type, exc_val, exc_tb)
2776+
2777+
@classmethod
2778+
def for_existing_conn(
2779+
cls,
2780+
client: AsyncMongoClient, # type: ignore[type-arg]
2781+
server: Server,
2782+
session: Optional[AsyncClientSession],
2783+
conn: AsyncConnection,
2784+
) -> _ClientCheckout:
2785+
"""Return a _ClientCheckout for an already-checked-out connection.
2786+
2787+
Used when SDAM error handling is needed around an existing connection
2788+
without performing a new pool checkout (e.g. re-running a getMore).
2789+
"""
2790+
checkout = cls(client, server, session)
2791+
checkout.contribute_socket(conn)
2792+
checkout._existing_conn = conn
2793+
return checkout
2794+
2795+
2796+
class _ClientReadCheckout(_ClientCheckout):
2797+
"""Context manager for read operations.
2798+
2799+
Extends _ClientCheckout to apply the single-topology read preference
2800+
adjustment and return the effective read preference alongside the connection.
2801+
"""
2802+
2803+
__slots__ = ("_read_preference",)
2804+
2805+
def __init__(
2806+
self,
2807+
client: AsyncMongoClient, # type: ignore[type-arg]
2808+
server: Server,
2809+
session: Optional[AsyncClientSession],
2810+
read_preference: _ServerMode,
2811+
) -> None:
2812+
super().__init__(client, server, session)
2813+
self._read_preference: _ServerMode = read_preference
2814+
2815+
async def __aenter__(self) -> tuple[AsyncConnection, _ServerMode]: # type: ignore[override]
2816+
conn = await super().__aenter__()
2817+
# The Server Selection Spec says not to send any $readPreference to
2818+
# standalones and to always send primaryPreferred when directly
2819+
# connected to a replica set member.
2820+
# Thread safe: topology type cannot change once set to Single.
2821+
read_pref = self._read_preference
2822+
single = self.client._topology.description.topology_type == TOPOLOGY_TYPE.Single
2823+
if single:
2824+
if conn.is_repl and not (self.session and self.session.in_transaction):
2825+
read_pref = ReadPreference.PRIMARY_PREFERRED
2826+
elif conn.is_standalone:
2827+
read_pref = ReadPreference.PRIMARY
2828+
return conn, read_pref
27572829

27582830

27592831
class _ClientConnectionRetryable(Generic[T]):
27602832
"""Responsible for executing retryable connections on read or write operations"""
27612833

2834+
__slots__ = (
2835+
"_address",
2836+
"_always_retryable",
2837+
"_attempt_number",
2838+
"_base_backoff_ms",
2839+
"_bulk",
2840+
"_client",
2841+
"_deprioritized_servers",
2842+
"_func",
2843+
"_is_aggregate_write",
2844+
"_is_read",
2845+
"_is_run_command",
2846+
"_last_error",
2847+
"_max_retries",
2848+
"_operation",
2849+
"_operation_id",
2850+
"_read_pref",
2851+
"_retry_policy",
2852+
"_retryable",
2853+
"_retrying",
2854+
"_server",
2855+
"_server_selector",
2856+
"_session",
2857+
)
2858+
27622859
def __init__(
27632860
self,
27642861
mongo_client: AsyncMongoClient, # type: ignore[type-arg]
@@ -2791,7 +2888,7 @@ def __init__(
27912888
)
27922889
self._address = address
27932890
self._server: Server = None # type: ignore
2794-
self._deprioritized_servers: list[Server] = []
2891+
self._deprioritized_servers: Optional[list[Server]] = None
27952892
self._operation = operation
27962893
# Only generate an operation id when APM/logging is enabled
27972894
if operation_id is None and (
@@ -2946,6 +3043,8 @@ async def run(self) -> T:
29463043
self._client.topology_description.topology_type_name == "Sharded"
29473044
or (overloaded and self._client.options.enable_overload_retargeting)
29483045
):
3046+
if self._deprioritized_servers is None:
3047+
self._deprioritized_servers = []
29493048
self._deprioritized_servers.append(self._server)
29503049

29513050
self._always_retryable = always_retryable

0 commit comments

Comments
 (0)