Skip to content

Commit 0d87375

Browse files
authored
Merge branch 'main' into PYTHON-5931
2 parents 23eb52b + 81d8ab9 commit 0d87375

24 files changed

Lines changed: 688 additions & 48 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

.github/workflows/dist.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161

6262
- name: Set up QEMU
6363
if: runner.os == 'Linux'
64-
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
64+
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
6565
with:
6666
# setup-qemu-action by default uses `tonistiigi/binfmt:latest` image,
6767
# which is out of date. This causes seg faults during build.

.github/workflows/test-python.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
persist-credentials: false
3131
- uses: ./.github/actions/set-uv-exclude-newer
3232
- name: Install uv
33-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
33+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
3434
with:
3535
enable-cache: true
3636
python-version: "3.10"
@@ -83,7 +83,7 @@ jobs:
8383
persist-credentials: false
8484
- uses: ./.github/actions/set-uv-exclude-newer
8585
- name: Install uv
86-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
86+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
8787
with:
8888
enable-cache: true
8989
python-version: ${{ matrix.python-version }}
@@ -106,7 +106,7 @@ jobs:
106106
persist-credentials: false
107107
- uses: ./.github/actions/set-uv-exclude-newer
108108
- name: Install uv
109-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
109+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
110110
with:
111111
enable-cache: true
112112
python-version: "3.10"
@@ -135,7 +135,7 @@ jobs:
135135
persist-credentials: false
136136
- uses: ./.github/actions/set-uv-exclude-newer
137137
- name: Install uv
138-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
138+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
139139
with:
140140
enable-cache: true
141141
python-version: "3.10"
@@ -161,7 +161,7 @@ jobs:
161161
persist-credentials: false
162162
- uses: ./.github/actions/set-uv-exclude-newer
163163
- name: Install uv
164-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
164+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
165165
with:
166166
enable-cache: true
167167
python-version: "3.10"
@@ -184,7 +184,7 @@ jobs:
184184
persist-credentials: false
185185
- uses: ./.github/actions/set-uv-exclude-newer
186186
- name: Install uv
187-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
187+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
188188
with:
189189
enable-cache: true
190190
python-version: "${{matrix.python}}"
@@ -206,7 +206,7 @@ jobs:
206206
persist-credentials: false
207207
- uses: ./.github/actions/set-uv-exclude-newer
208208
- name: Install uv
209-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
209+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
210210
with:
211211
enable-cache: true
212212
python-version: "3.10"
@@ -297,7 +297,7 @@ jobs:
297297
persist-credentials: false
298298
- uses: ./.github/actions/set-uv-exclude-newer
299299
- name: Install uv
300-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
300+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
301301
with:
302302
python-version: "3.9"
303303
- id: setup-mongodb

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:

0 commit comments

Comments
 (0)