Skip to content

Commit 012c143

Browse files
committed
PYTHON-5846 Address PR review comments on telemetry refactor
- Fix race condition in _CmapTelemetry: remove shared _conn_created_start and _checkout_start slots; connection_ready now takes creation_time from AsyncConnection.creation_time, checkout_started returns the start time and checkout_succeeded/failed accept it as a parameter - Move SRV monitor failure log into _telemetry.py as log_srv_monitor_failure - Add _log_retry helper to _ClientConnectionRetryable to deduplicate log_command_retry call sites - Fix _ServerSelectionTelemetry.failed to accept live topology_description at failure time instead of using the stale snapshot from construction
1 parent 2746386 commit 012c143

9 files changed

Lines changed: 104 additions & 88 deletions

File tree

pymongo/_telemetry.py

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,7 @@ class _CmapTelemetry:
204204

205205
__slots__ = (
206206
"_address",
207-
"_checkout_start",
208207
"_client_id",
209-
"_conn_created_start",
210208
"_listeners",
211209
"_log",
212210
"_publish",
@@ -283,22 +281,19 @@ def pool_closed(self) -> None:
283281
self._listeners.publish_pool_closed(self._address)
284282

285283
def connection_created(self, conn_id: int) -> None:
286-
# Always record start time: logging or publishing may be enabled by the time
287-
# connection_ready is called to compute the duration.
288-
self._conn_created_start = time.monotonic()
289284
# Log before publishing to prevent potential listener preemption in tests.
290285
if self._should_log:
291286
self._emit_log(_ConnectionStatusMessage.CONN_CREATED, driverConnectionId=conn_id)
292287
if self._should_publish:
293288
assert self._listeners is not None
294289
self._listeners.publish_connection_created(self._address, conn_id)
295290

296-
def connection_ready(self, conn_id: int) -> None:
291+
def connection_ready(self, conn_id: int, creation_time: float) -> None:
297292
should_log = self._should_log
298293
should_publish = self._should_publish
299294
if not should_log and not should_publish:
300295
return
301-
duration = max(0.0, time.monotonic() - self._conn_created_start)
296+
duration = max(0.0, time.monotonic() - creation_time)
302297
# Log before publishing to prevent potential listener preemption in tests.
303298
if should_log:
304299
self._emit_log(
@@ -324,24 +319,22 @@ def connection_closed(self, conn_id: int, reason: str) -> None:
324319
error=reason,
325320
)
326321

327-
def checkout_started(self) -> None:
328-
should_log = self._should_log
329-
should_publish = self._should_publish
330-
# Always record start time: logging or publishing may be enabled by the time
331-
# checkout_succeeded or checkout_failed is called to compute the duration.
332-
self._checkout_start = time.monotonic()
333-
if should_publish:
322+
def checkout_started(self) -> float:
323+
"""Emit the checkout started event/log and return the start time for duration tracking."""
324+
start = time.monotonic()
325+
if self._should_publish:
334326
assert self._listeners is not None
335327
self._listeners.publish_connection_check_out_started(self._address)
336-
if should_log:
328+
if self._should_log:
337329
self._emit_log(_ConnectionStatusMessage.CHECKOUT_STARTED)
330+
return start
338331

339-
def checkout_succeeded(self, conn_id: int) -> None:
332+
def checkout_succeeded(self, conn_id: int, start: float) -> None:
340333
should_log = self._should_log
341334
should_publish = self._should_publish
342335
if not should_log and not should_publish:
343336
return
344-
duration = max(0.0, time.monotonic() - self._checkout_start)
337+
duration = max(0.0, time.monotonic() - start)
345338
if should_publish:
346339
assert self._listeners is not None
347340
self._listeners.publish_connection_checked_out(self._address, conn_id, duration)
@@ -352,12 +345,12 @@ def checkout_succeeded(self, conn_id: int) -> None:
352345
durationMS=duration * 1000,
353346
)
354347

355-
def checkout_failed(self, reason: str, error: str) -> None:
348+
def checkout_failed(self, reason: str, error: str, start: float) -> None:
356349
should_log = self._should_log
357350
should_publish = self._should_publish
358351
if not should_log and not should_publish:
359352
return
360-
duration = max(0.0, time.monotonic() - self._checkout_start)
353+
duration = max(0.0, time.monotonic() - start)
361354
if should_publish:
362355
assert self._listeners is not None
363356
self._listeners.publish_connection_check_out_failed(self._address, error, duration)
@@ -632,43 +625,62 @@ def __init__(
632625
# logging level is stable for its lifetime.
633626
self._should_log = _SERVER_SELECTION_LOGGER.isEnabledFor(logging.DEBUG)
634627

635-
def _emit_log(self, message: _ServerSelectionStatusMessage, **extra: Any) -> None:
628+
def _emit_log(
629+
self, message: _ServerSelectionStatusMessage, topology_description: Any, **extra: Any
630+
) -> None:
636631
_debug_log(
637632
_SERVER_SELECTION_LOGGER,
638633
message=message,
639634
clientId=self._topology_id,
640635
selector=self._selector,
641636
operation=self._operation,
642637
operationId=self._operation_id,
643-
topologyDescription=self._topology_description,
638+
topologyDescription=topology_description,
644639
**extra,
645640
)
646641

647642
def started(self) -> None:
648643
"""Emit the server selection STARTED log entry."""
649644
if self._should_log:
650-
self._emit_log(_ServerSelectionStatusMessage.STARTED)
645+
self._emit_log(_ServerSelectionStatusMessage.STARTED, self._topology_description)
651646

652647
def waiting(self, remaining_time_ms: int) -> None:
653648
"""Emit the server selection WAITING log entry."""
654649
if self._should_log:
655-
self._emit_log(_ServerSelectionStatusMessage.WAITING, remainingTimeMS=remaining_time_ms)
650+
self._emit_log(
651+
_ServerSelectionStatusMessage.WAITING,
652+
self._topology_description,
653+
remainingTimeMS=remaining_time_ms,
654+
)
656655

657-
def failed(self, failure: str) -> None:
658-
"""Emit the server selection FAILED log entry."""
656+
def failed(self, failure: str, topology_description: Any = None) -> None:
657+
"""Emit the server selection FAILED log entry with the current topology description."""
659658
if self._should_log:
660-
self._emit_log(_ServerSelectionStatusMessage.FAILED, failure=failure)
659+
self._emit_log(
660+
_ServerSelectionStatusMessage.FAILED,
661+
topology_description
662+
if topology_description is not None
663+
else self._topology_description,
664+
failure=failure,
665+
)
661666

662667
def succeeded(self, server_host: str, server_port: Optional[int]) -> None:
663668
"""Emit the server selection SUCCEEDED log entry."""
664669
if self._should_log:
665670
self._emit_log(
666671
_ServerSelectionStatusMessage.SUCCEEDED,
672+
self._topology_description,
667673
serverHost=server_host,
668674
serverPort=server_port,
669675
)
670676

671677

678+
def log_srv_monitor_failure(failure: Exception) -> None:
679+
"""Emit a log entry when the SRV monitor fails to poll DNS records."""
680+
if _SDAM_LOGGER.isEnabledFor(logging.DEBUG):
681+
_debug_log(_SDAM_LOGGER, message="SRV monitor check failed", failure=repr(failure))
682+
683+
672684
def log_command_retry(
673685
topology_id: Any,
674686
command_name: str,

pymongo/asynchronous/mongo_client.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2990,6 +2990,15 @@ async def _get_server(self) -> Server:
29902990
operation_id=self._operation_id,
29912991
)
29922992

2993+
def _log_retry(self, is_write: bool) -> None:
2994+
log_command_retry(
2995+
self._client._topology_id,
2996+
self._operation,
2997+
self._operation_id,
2998+
self._attempt_number,
2999+
is_write,
3000+
)
3001+
29933002
async def _write(self) -> T:
29943003
"""Wrapper method for write-type retryable client executions
29953004
@@ -3013,13 +3022,7 @@ async def _write(self) -> T:
30133022
self._check_last_error()
30143023
self._retryable = False
30153024
if self._retrying:
3016-
log_command_retry(
3017-
self._client._topology_id,
3018-
self._operation,
3019-
self._operation_id,
3020-
self._attempt_number,
3021-
is_write=True,
3022-
)
3025+
self._log_retry(is_write=True)
30233026
return await self._func(self._session, conn, self._retryable) # type: ignore
30243027
except PyMongoError as exc:
30253028
if not self._retryable:
@@ -3042,13 +3045,7 @@ async def _read(self) -> T:
30423045
if self._retrying and not self._retryable and not self._always_retryable:
30433046
self._check_last_error()
30443047
if self._retrying:
3045-
log_command_retry(
3046-
self._client._topology_id,
3047-
self._operation,
3048-
self._operation_id,
3049-
self._attempt_number,
3050-
is_write=False,
3051-
)
3048+
self._log_retry(is_write=False)
30523049
return await self._func(self._session, self._server, conn, read_pref) # type: ignore
30533050

30543051

pymongo/asynchronous/monitor.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,11 @@
2424

2525
from pymongo import common, periodic_executor
2626
from pymongo._csot import MovingMinimum
27-
from pymongo._telemetry import _HeartbeatTelemetry
27+
from pymongo._telemetry import _HeartbeatTelemetry, log_srv_monitor_failure
2828
from pymongo.asynchronous.srv_resolver import _SrvResolver
2929
from pymongo.errors import NetworkTimeout, _OperationCancelled
3030
from pymongo.hello import Hello
3131
from pymongo.lock import _async_create_lock
32-
from pymongo.logger import _SDAM_LOGGER, _debug_log
3332
from pymongo.periodic_executor import _shutdown_executors
3433
from pymongo.pool_options import _is_faas
3534
from pymongo.read_preferences import MovingAverage
@@ -385,7 +384,7 @@ async def _get_seedlist(self) -> Optional[list[tuple[str, Any]]]:
385384
# - SRV records must be rescanned every heartbeatFrequencyMS
386385
# - Topology must be left unchanged
387386
self.request_check()
388-
_debug_log(_SDAM_LOGGER, message="SRV monitor check failed", failure=repr(exc))
387+
log_srv_monitor_failure(exc)
389388
return None
390389
else:
391390
self._executor.update_interval(max(ttl, common.MIN_SRV_RESCAN_INTERVAL))

pymongo/asynchronous/pool.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def __init__(
128128
self.id = id
129129
self.is_sdam = is_sdam
130130
self.closed = False
131+
self.creation_time = time.monotonic()
131132
self.last_checkin_time = time.monotonic()
132133
self.performed_handshake = False
133134
self.is_writable: bool = False
@@ -466,7 +467,7 @@ async def authenticate(self, reauthenticate: bool = False) -> None:
466467

467468
await auth.authenticate(creds, self, reauthenticate=reauthenticate)
468469
self.ready = True
469-
self._telemetry.connection_ready(self.id)
470+
self._telemetry.connection_ready(self.id, self.creation_time)
470471

471472
def validate_session(
472473
self, client: Optional[AsyncMongoClient[Any]], session: Optional[AsyncClientSession]
@@ -976,11 +977,11 @@ async def checkout(
976977
977978
:param handler: A _MongoClientErrorHandler.
978979
"""
979-
self._telemetry.checkout_started()
980+
start = self._telemetry.checkout_started()
980981

981-
conn = await self._get_conn(handler=handler)
982+
conn = await self._get_conn(handler=handler, checkout_start=start)
982983

983-
self._telemetry.checkout_succeeded(conn.id)
984+
self._telemetry.checkout_succeeded(conn.id, start)
984985
try:
985986
async with self.lock:
986987
self.active_contexts.add(conn.cancel_context)
@@ -1011,12 +1012,13 @@ async def checkout(
10111012
elif conn.active:
10121013
await self.checkin(conn)
10131014

1014-
def _raise_if_not_ready(self, emit_event: bool) -> None:
1015+
def _raise_if_not_ready(self, emit_event: bool, checkout_start: float) -> None:
10151016
if self.state != PoolState.READY:
10161017
if emit_event:
10171018
self._telemetry.checkout_failed(
10181019
"An error occurred while trying to establish a new connection",
10191020
ConnectionCheckOutFailedReason.CONN_ERROR,
1021+
checkout_start,
10201022
)
10211023

10221024
details = _get_timeout_details(self.opts)
@@ -1025,7 +1027,7 @@ def _raise_if_not_ready(self, emit_event: bool) -> None:
10251027
)
10261028

10271029
async def _get_conn(
1028-
self, handler: Optional[_MongoClientErrorHandler] = None
1030+
self, handler: Optional[_MongoClientErrorHandler] = None, checkout_start: float = 0.0
10291031
) -> AsyncConnection:
10301032
"""Get or create a AsyncConnection. Can raise ConnectionFailure."""
10311033
# We use the pid here to avoid issues with fork / multiprocessing.
@@ -1038,6 +1040,7 @@ async def _get_conn(
10381040
self._telemetry.checkout_failed(
10391041
"Connection pool was closed",
10401042
ConnectionCheckOutFailedReason.POOL_CLOSED,
1043+
checkout_start,
10411044
)
10421045
raise _PoolClosedError(
10431046
"Attempted to check out a connection from closed connection pool"
@@ -1055,16 +1058,16 @@ async def _get_conn(
10551058
deadline = None
10561059

10571060
async with self.size_cond:
1058-
self._raise_if_not_ready(emit_event=True)
1061+
self._raise_if_not_ready(emit_event=True, checkout_start=checkout_start)
10591062
while not (self.requests < self.max_pool_size):
10601063
timeout = deadline - time.monotonic() if deadline else None
10611064
if not await _async_cond_wait(self.size_cond, timeout):
10621065
# Timed out, notify the next thread to ensure a
10631066
# timeout doesn't consume the condition.
10641067
if self.requests < self.max_pool_size:
10651068
self.size_cond.notify()
1066-
self._raise_wait_queue_timeout()
1067-
self._raise_if_not_ready(emit_event=True)
1069+
self._raise_wait_queue_timeout(checkout_start)
1070+
self._raise_if_not_ready(emit_event=True, checkout_start=checkout_start)
10681071
self.requests += 1
10691072

10701073
# We've now acquired the semaphore and must release it on error.
@@ -1079,7 +1082,7 @@ async def _get_conn(
10791082
# CMAP: we MUST wait for either maxConnecting OR for a socket
10801083
# to be checked back into the pool.
10811084
async with self._max_connecting_cond:
1082-
self._raise_if_not_ready(emit_event=False)
1085+
self._raise_if_not_ready(emit_event=False, checkout_start=checkout_start)
10831086
while not (self.conns or self._pending < self._max_connecting):
10841087
timeout = deadline - time.monotonic() if deadline else None
10851088
if not await _async_cond_wait(self._max_connecting_cond, timeout):
@@ -1088,8 +1091,8 @@ async def _get_conn(
10881091
if self.conns or self._pending < self._max_connecting:
10891092
self._max_connecting_cond.notify()
10901093
emitted_event = True
1091-
self._raise_wait_queue_timeout()
1092-
self._raise_if_not_ready(emit_event=False)
1094+
self._raise_wait_queue_timeout(checkout_start)
1095+
self._raise_if_not_ready(emit_event=False, checkout_start=checkout_start)
10931096

10941097
try:
10951098
conn = self.conns.popleft()
@@ -1121,6 +1124,7 @@ async def _get_conn(
11211124
self._telemetry.checkout_failed(
11221125
"An error occurred while trying to establish a new connection",
11231126
ConnectionCheckOutFailedReason.CONN_ERROR,
1127+
checkout_start,
11241128
)
11251129
raise
11261130

@@ -1212,10 +1216,11 @@ async def _perished(self, conn: AsyncConnection) -> bool:
12121216

12131217
return False
12141218

1215-
def _raise_wait_queue_timeout(self) -> NoReturn:
1219+
def _raise_wait_queue_timeout(self, checkout_start: float) -> NoReturn:
12161220
self._telemetry.checkout_failed(
12171221
"Wait queue timeout elapsed without a connection becoming available",
12181222
ConnectionCheckOutFailedReason.TIMEOUT,
1223+
checkout_start,
12191224
)
12201225
timeout = _csot.get_timeout() or self.opts.wait_queue_timeout
12211226
if self.opts.load_balanced:

pymongo/asynchronous/topology.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ async def _select_servers_loop(
296296
while not server_descriptions:
297297
# No suitable servers.
298298
if timeout == 0 or now > end_time:
299-
ss.failed(self._error_message(selector))
299+
ss.failed(self._error_message(selector), self.description)
300300
raise ServerSelectionTimeoutError(
301301
f"{self._error_message(selector)}, Timeout: {timeout}s, Topology Description: {self.description!r}"
302302
)

0 commit comments

Comments
 (0)