Skip to content

Commit ab9be3e

Browse files
PYTHON-5903 Retry attempts should share a single stable operation_id (#2901)
Co-authored-by: Jeffrey 'Alex' Clark <aclark@aclark.net>
1 parent 1a527ee commit ab9be3e

15 files changed

Lines changed: 503 additions & 33 deletions

doc/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ Changes in Version 4.18.0
88
to the same server, avoiding a full handshake on each new connection.
99
Session resumption is supported on all Python versions for synchronous clients
1010
and on Python 3.11+ for async clients.
11+
- Command monitoring events and command log messages for a single logical
12+
operation now share one stable ``operation_id`` across all of its retry
13+
attempts, so consumers can correlate a retried operation's events. As a
14+
result, ``operation_id`` is no longer equal to the per-attempt ``request_id``
15+
for these operations.
1116

1217
Changes in Version 4.17.0 (2026/04/20)
1318
--------------------------------------

pymongo/_op_id.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2026-present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you
4+
# may not use this file except in compliance with the License. You
5+
# may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12+
# implied. See the License for the specific language governing
13+
# permissions and limitations under the License.
14+
15+
"""Internal helpers for the APM operation id.
16+
17+
The retryable read/write logic sets OP_ID for the duration of each attempt so
18+
that every attempt of one logical operation publishes the same operation_id.
19+
Commands run outside that scope (handshake, auth, killCursors, pinned-cursor
20+
getMores) read the default None and fall back to their request_id.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from contextlib import AbstractContextManager
26+
from contextvars import ContextVar
27+
from typing import Any, Optional
28+
29+
OP_ID: ContextVar[Optional[int]] = ContextVar("OP_ID", default=None)
30+
31+
32+
def reset() -> None:
33+
OP_ID.set(None)
34+
35+
36+
class _OpIdContext(AbstractContextManager[Any]):
37+
"""Set OP_ID for the duration of a with block."""
38+
39+
def __init__(self, op_id: Optional[int]):
40+
self._op_id = op_id
41+
42+
def __enter__(self) -> None:
43+
self._token = OP_ID.set(self._op_id)
44+
45+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
46+
OP_ID.reset(self._token)

pymongo/_telemetry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _emit_log(self, message: _CommandStatusMessage, **extra: Any) -> None:
111111
commandName=self._name,
112112
databaseName=self._dbname,
113113
requestId=self._request_id,
114-
operationId=self._request_id,
114+
operationId=self._op_id if self._op_id is not None else self._request_id,
115115
driverConnectionId=self._conn.id,
116116
serverConnectionId=self._conn.server_connection_id,
117117
serverHost=self._conn.address[0],

pymongo/asynchronous/command_runner.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
)
4848

4949
from bson import _decode_all_selective
50-
from pymongo import _csot, helpers_shared, message
50+
from pymongo import _csot, _op_id, helpers_shared, message
5151
from pymongo._telemetry import _CommandTelemetry
5252
from pymongo.compression_support import _NO_COMPRESSION
5353
from pymongo.errors import NotPrimaryError, OperationFailure
@@ -128,7 +128,8 @@ async def _run_command(
128128
:param orig: The command document published in the ``STARTED`` APM event;
129129
defaults to ``cmd`` (differs only when the wire command was mutated,
130130
e.g. with a read preference or after encryption).
131-
:param op_id: The APM operation id; defaults to ``request_id``.
131+
:param op_id: The APM operation id; defaults to the ``OP_ID`` contextvar,
132+
then ``request_id``.
132133
:param command_name: The command name for the ``SUCCEEDED``/``FAILED`` APM
133134
events; defaults to the first key of ``cmd``.
134135
:param check: Raise OperationFailure on a command error.
@@ -158,6 +159,8 @@ async def _run_command(
158159
command_name = name
159160
if orig is None:
160161
orig = cmd
162+
if op_id is None:
163+
op_id = _op_id.OP_ID.get()
161164

162165
telemetry = _CommandTelemetry(topology_id, conn, listeners, cmd, dbname, request_id, op_id)
163166
telemetry.started(orig, ensure_db)

pymongo/asynchronous/encryption.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from bson.codec_options import CodecOptions
5656
from bson.errors import BSONError
5757
from bson.raw_bson import DEFAULT_RAW_BSON_OPTIONS, RawBSONDocument, _inflate_bson
58-
from pymongo import _csot
58+
from pymongo import _csot, _op_id
5959
from pymongo.asynchronous.collection import AsyncCollection
6060
from pymongo.asynchronous.cursor import AsyncCursor
6161
from pymongo.asynchronous.database import AsyncDatabase
@@ -468,7 +468,9 @@ async def encrypt(
468468
self._check_closed()
469469
encoded_cmd = _dict_to_bson(cmd, False, codec_options)
470470
with _wrap_encryption_errors():
471-
encrypted_cmd = await self._auto_encrypter.encrypt(database, encoded_cmd)
471+
# Don't let encryption's sub-commands inherit the in-flight op's id.
472+
with _op_id._OpIdContext(None):
473+
encrypted_cmd = await self._auto_encrypter.encrypt(database, encoded_cmd)
472474
# TODO: PYTHON-1922 avoid decoding the encrypted_cmd.
473475
return _inflate_bson(encrypted_cmd, DEFAULT_RAW_BSON_OPTIONS)
474476

@@ -481,7 +483,9 @@ async def decrypt(self, response: bytes | memoryview) -> Optional[bytes]:
481483
"""
482484
self._check_closed()
483485
with _wrap_encryption_errors():
484-
return cast(bytes, await self._auto_encrypter.decrypt(response))
486+
# Don't let decryption's sub-commands inherit the in-flight op's id.
487+
with _op_id._OpIdContext(None):
488+
return cast(bytes, await self._auto_encrypter.decrypt(response))
485489

486490
def _check_closed(self) -> None:
487491
if self._closed:

pymongo/asynchronous/helpers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
cast,
3131
)
3232

33-
from pymongo import _csot
33+
from pymongo import _csot, _op_id
3434
from pymongo.common import MAX_ADAPTIVE_RETRIES
3535
from pymongo.errors import (
3636
OperationFailure,
@@ -68,7 +68,9 @@ async def inner(*args: Any, **kwargs: Any) -> Any:
6868
conn = arg.conn # type: ignore[assignment]
6969
break
7070
if conn:
71-
await conn.authenticate(reauthenticate=True)
71+
# Don't let reauth's auth commands inherit the in-flight op's id.
72+
with _op_id._OpIdContext(None):
73+
await conn.authenticate(reauthenticate=True)
7274
else:
7375
raise
7476
return await func(*args, **kwargs)

pymongo/asynchronous/mongo_client.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
from bson.codec_options import DEFAULT_CODEC_OPTIONS, CodecOptions, TypeRegistry
5858
from bson.timestamp import Timestamp
59-
from pymongo import _csot, common, helpers_shared, periodic_executor
59+
from pymongo import _csot, _op_id, common, helpers_shared, periodic_executor
6060
from pymongo._telemetry import log_command_retry
6161
from pymongo.asynchronous import client_session, database, uri_parser
6262
from pymongo.asynchronous.change_stream import AsyncChangeStream, AsyncClusterChangeStream
@@ -94,7 +94,7 @@
9494
_log_client_error,
9595
_log_or_warn,
9696
)
97-
from pymongo.message import _CursorAddress, _GetMore, _Query
97+
from pymongo.message import _CursorAddress, _GetMore, _Query, _randint
9898
from pymongo.monitoring import ConnectionClosedReason, _EventListeners
9999
from pymongo.operations import (
100100
DeleteMany,
@@ -1836,6 +1836,8 @@ async def _select_server(
18361836
be pinned to a mongos server address.
18371837
- `address` (optional): Address when sending a message
18381838
to a specific server, used for getMore.
1839+
- `operation_id` (optional): Stable operation id shared across retries,
1840+
used for command monitoring.
18391841
"""
18401842
try:
18411843
topology = await self._get_topology()
@@ -2011,6 +2013,7 @@ async def _retry_internal(
20112013
:param retryable: If the operation should be retried once, defaults to None
20122014
:param is_run_command: If this is a runCommand operation, defaults to False
20132015
:param is_aggregate_write: If this is a aggregate operation with a write, defaults to False.
2016+
:param operation_id: Stable operation id shared across retries, defaults to None
20142017
20152018
:return: Output of the calling func()
20162019
"""
@@ -2057,6 +2060,7 @@ async def _retryable_read(
20572060
(may not always be supported even if supplied), defaults to False
20582061
:param is_run_command: If this is a runCommand operation, defaults to False.
20592062
:param is_aggregate_write: If this is a aggregate operation with a write, defaults to False.
2063+
:param operation_id: Stable operation id shared across retries, defaults to None
20602064
"""
20612065

20622066
# Ensure that the client supports retrying on reads and there is no session in
@@ -2100,6 +2104,7 @@ async def _retryable_write(
21002104
:param session: Client session we will use to execute write operation
21012105
:param operation: The name of the operation that the server is being selected for
21022106
:param bulk: bulk abstraction to execute operations in bulk, defaults to None
2107+
:param operation_id: Stable operation id shared across retries, defaults to None
21032108
"""
21042109
async with self._tmp_session(session) as s:
21052110
return await self._retry_with_session(retryable, func, s, bulk, operation, operation_id)
@@ -2783,7 +2788,7 @@ def __init__(
27832788
self._server: Server = None # type: ignore
27842789
self._deprioritized_servers: list[Server] = []
27852790
self._operation = operation
2786-
self._operation_id = operation_id
2791+
self._operation_id = operation_id if operation_id is not None else _randint()
27872792
self._attempt_number = 0
27882793
self._is_run_command = is_run_command
27892794
self._is_aggregate_write = is_aggregate_write
@@ -3012,7 +3017,9 @@ async def _write(self) -> T:
30123017
self._retryable = False
30133018
if self._retrying:
30143019
self._log_retry(is_write=True)
3015-
return await self._func(self._session, conn, self._retryable) # type: ignore
3020+
# One operation id across all attempts of this operation.
3021+
with _op_id._OpIdContext(self._operation_id):
3022+
return await self._func(self._session, conn, self._retryable) # type: ignore
30163023
except PyMongoError as exc:
30173024
if not self._retryable:
30183025
raise
@@ -3035,7 +3042,9 @@ async def _read(self) -> T:
30353042
self._check_last_error()
30363043
if self._retrying:
30373044
self._log_retry(is_write=False)
3038-
return await self._func(self._session, self._server, conn, read_pref) # type: ignore
3045+
# One operation id across all attempts of this operation.
3046+
with _op_id._OpIdContext(self._operation_id):
3047+
return await self._func(self._session, self._server, conn, read_pref) # type: ignore
30393048

30403049

30413050
def _after_fork_child() -> None:

pymongo/periodic_executor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import weakref
2424
from typing import Any, Optional
2525

26-
from pymongo import _csot
26+
from pymongo import _csot, _op_id
2727
from pymongo._asyncio_task import create_task
2828
from pymongo.lock import _create_lock
2929

@@ -94,8 +94,9 @@ def skip_sleep(self) -> None:
9494
self._skip_sleep = True
9595

9696
async def _run(self) -> None:
97-
# The CSOT contextvars must be cleared inside the executor task before execution begins
97+
# The CSOT and op id contextvars must be cleared inside the executor task before execution begins
9898
_csot.reset_all()
99+
_op_id.reset()
99100
while not self._stopped:
100101
if self._task and self._task.cancelling(): # type: ignore[unused-ignore, attr-defined]
101102
raise asyncio.CancelledError

pymongo/synchronous/command_runner.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
)
4848

4949
from bson import _decode_all_selective
50-
from pymongo import _csot, helpers_shared, message
50+
from pymongo import _csot, _op_id, helpers_shared, message
5151
from pymongo._telemetry import _CommandTelemetry
5252
from pymongo.compression_support import _NO_COMPRESSION
5353
from pymongo.errors import NotPrimaryError, OperationFailure
@@ -128,7 +128,8 @@ def _run_command(
128128
:param orig: The command document published in the ``STARTED`` APM event;
129129
defaults to ``cmd`` (differs only when the wire command was mutated,
130130
e.g. with a read preference or after encryption).
131-
:param op_id: The APM operation id; defaults to ``request_id``.
131+
:param op_id: The APM operation id; defaults to the ``OP_ID`` contextvar,
132+
then ``request_id``.
132133
:param command_name: The command name for the ``SUCCEEDED``/``FAILED`` APM
133134
events; defaults to the first key of ``cmd``.
134135
:param check: Raise OperationFailure on a command error.
@@ -158,6 +159,8 @@ def _run_command(
158159
command_name = name
159160
if orig is None:
160161
orig = cmd
162+
if op_id is None:
163+
op_id = _op_id.OP_ID.get()
161164

162165
telemetry = _CommandTelemetry(topology_id, conn, listeners, cmd, dbname, request_id, op_id)
163166
telemetry.started(orig, ensure_db)

pymongo/synchronous/encryption.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
from bson.codec_options import CodecOptions
5555
from bson.errors import BSONError
5656
from bson.raw_bson import DEFAULT_RAW_BSON_OPTIONS, RawBSONDocument, _inflate_bson
57-
from pymongo import _csot
57+
from pymongo import _csot, _op_id
5858
from pymongo.common import CONNECT_TIMEOUT
5959
from pymongo.daemon import _spawn_daemon
6060
from pymongo.encryption_options import (
@@ -465,7 +465,9 @@ def encrypt(
465465
self._check_closed()
466466
encoded_cmd = _dict_to_bson(cmd, False, codec_options)
467467
with _wrap_encryption_errors():
468-
encrypted_cmd = self._auto_encrypter.encrypt(database, encoded_cmd)
468+
# Don't let encryption's sub-commands inherit the in-flight op's id.
469+
with _op_id._OpIdContext(None):
470+
encrypted_cmd = self._auto_encrypter.encrypt(database, encoded_cmd)
469471
# TODO: PYTHON-1922 avoid decoding the encrypted_cmd.
470472
return _inflate_bson(encrypted_cmd, DEFAULT_RAW_BSON_OPTIONS)
471473

@@ -478,7 +480,9 @@ def decrypt(self, response: bytes | memoryview) -> Optional[bytes]:
478480
"""
479481
self._check_closed()
480482
with _wrap_encryption_errors():
481-
return cast(bytes, self._auto_encrypter.decrypt(response))
483+
# Don't let decryption's sub-commands inherit the in-flight op's id.
484+
with _op_id._OpIdContext(None):
485+
return cast(bytes, self._auto_encrypter.decrypt(response))
482486

483487
def _check_closed(self) -> None:
484488
if self._closed:

0 commit comments

Comments
 (0)