Skip to content

Commit b3f3bb9

Browse files
authored
Merge branch 'main' into fix-redact-aws-session-token-repr
2 parents ec60873 + a54a062 commit b3f3bb9

20 files changed

Lines changed: 677 additions & 37 deletions

.github/pull_request_template.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- Thanks for contributing! -->
22
<!-- Please ensure that the title of the PR is in the following form:
3-
[JIRA TICKET]: Issue Title
3+
PYTHON-XXXX Issue Title
44
55
If you are an external contributor and there is no JIRA ticket associated with your change, then use your best judgement
66
for the PR title. A MongoDB employee will create a JIRA ticket and edit the name and links as appropriate.
@@ -9,7 +9,9 @@ Note on AI Contributions:
99
We only accept pull requests that are authored and submitted by human contributors who fully understand the changes they are proposing.
1010
All contributions must be written and understood by human contributors. Please read about our policy in our contributing guide.
1111
-->
12-
[JIRA TICKET]
12+
<!-- Replace XXXX with the ticket number. If you do not have a ticket (external contributors), you may leave PYTHON-XXXX;
13+
a MongoDB employee will create a JIRA ticket and update the PR title/links as appropriate. -->
14+
PYTHON-XXXX
1315

1416
## Changes in this PR
1517
<!-- What changes did you make to the code? What new APIs (public or private) were added, removed, or edited to generate

doc/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ Changes in Version 4.18.0
1212
AWS session tokens, from the representations of
1313
:class:`~pymongo.synchronous.mongo_client.MongoClient` and
1414
:class:`~pymongo.asynchronous.mongo_client.AsyncMongoClient`.
15+
- Command monitoring events and command log messages for a single logical
16+
operation now share one stable ``operation_id`` across all of its retry
17+
attempts, so consumers can correlate a retried operation's events. As a
18+
result, ``operation_id`` is no longer equal to the per-attempt ``request_id``
19+
for these operations.
1520

1621
Changes in Version 4.17.0 (2026/04/20)
1722
--------------------------------------

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,
@@ -1838,6 +1838,8 @@ async def _select_server(
18381838
be pinned to a mongos server address.
18391839
- `address` (optional): Address when sending a message
18401840
to a specific server, used for getMore.
1841+
- `operation_id` (optional): Stable operation id shared across retries,
1842+
used for command monitoring.
18411843
"""
18421844
try:
18431845
topology = await self._get_topology()
@@ -2013,6 +2015,7 @@ async def _retry_internal(
20132015
:param retryable: If the operation should be retried once, defaults to None
20142016
:param is_run_command: If this is a runCommand operation, defaults to False
20152017
:param is_aggregate_write: If this is a aggregate operation with a write, defaults to False.
2018+
:param operation_id: Stable operation id shared across retries, defaults to None
20162019
20172020
:return: Output of the calling func()
20182021
"""
@@ -2059,6 +2062,7 @@ async def _retryable_read(
20592062
(may not always be supported even if supplied), defaults to False
20602063
:param is_run_command: If this is a runCommand operation, defaults to False.
20612064
:param is_aggregate_write: If this is a aggregate operation with a write, defaults to False.
2065+
:param operation_id: Stable operation id shared across retries, defaults to None
20622066
"""
20632067

20642068
# Ensure that the client supports retrying on reads and there is no session in
@@ -2102,6 +2106,7 @@ async def _retryable_write(
21022106
:param session: Client session we will use to execute write operation
21032107
:param operation: The name of the operation that the server is being selected for
21042108
:param bulk: bulk abstraction to execute operations in bulk, defaults to None
2109+
:param operation_id: Stable operation id shared across retries, defaults to None
21052110
"""
21062111
async with self._tmp_session(session) as s:
21072112
return await self._retry_with_session(retryable, func, s, bulk, operation, operation_id)
@@ -2785,7 +2790,7 @@ def __init__(
27852790
self._server: Server = None # type: ignore
27862791
self._deprioritized_servers: list[Server] = []
27872792
self._operation = operation
2788-
self._operation_id = operation_id
2793+
self._operation_id = operation_id if operation_id is not None else _randint()
27892794
self._attempt_number = 0
27902795
self._is_run_command = is_run_command
27912796
self._is_aggregate_write = is_aggregate_write
@@ -3014,7 +3019,9 @@ async def _write(self) -> T:
30143019
self._retryable = False
30153020
if self._retrying:
30163021
self._log_retry(is_write=True)
3017-
return await self._func(self._session, conn, self._retryable) # type: ignore
3022+
# One operation id across all attempts of this operation.
3023+
with _op_id._OpIdContext(self._operation_id):
3024+
return await self._func(self._session, conn, self._retryable) # type: ignore
30183025
except PyMongoError as exc:
30193026
if not self._retryable:
30203027
raise
@@ -3037,7 +3044,9 @@ async def _read(self) -> T:
30373044
self._check_last_error()
30383045
if self._retrying:
30393046
self._log_retry(is_write=False)
3040-
return await self._func(self._session, self._server, conn, read_pref) # type: ignore
3047+
# One operation id across all attempts of this operation.
3048+
with _op_id._OpIdContext(self._operation_id):
3049+
return await self._func(self._session, self._server, conn, read_pref) # type: ignore
30413050

30423051

30433052
def _after_fork_child() -> None:

pymongo/asynchronous/pool.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,9 @@ async def _hello(
287287
hello.logical_session_timeout_minutes is not None and hello.is_readable
288288
)
289289
self.logical_session_timeout_minutes: Optional[int] = hello.logical_session_timeout_minutes
290-
self.hello_ok = hello.hello_ok
290+
# hello_ok is set from helloOk, which is only returned from ismaster
291+
# don't overwrite this when connection switches to hello
292+
self.hello_ok = self.hello_ok or hello.hello_ok
291293
self.is_repl = hello.server_type in (
292294
SERVER_TYPE.RSPrimary,
293295
SERVER_TYPE.RSSecondary,

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

0 commit comments

Comments
 (0)