PYTHON-5903 Retry attempts should share a single stable operation_id#2901
PYTHON-5903 Retry attempts should share a single stable operation_id#2901shrey-mongo wants to merge 14 commits into
Conversation
247b26f to
682dbac
Compare
|
Please fill out the pull request template completely. |
There was a problem hiding this comment.
Pull request overview
This PR ensures command-monitoring operation_id remains stable across retry attempts for retryable reads/writes and relevant cursor operations, improving APM correlation for a single logical operation.
Changes:
- Mint a single per-operation
operation_idin the retryable-operation wrapper and stamp it onto the checked-out connection for each attempt. - Thread the connection’s
op_idthrough pool/server command execution intocommand_runnerso published command-monitoring events use it. - Add sync + async integration tests that force multiple retries via
failCommandand assert all related command events share oneoperation_id.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
tools/synchro.py |
Registers the new test file in the synchro conversion list. |
test/test_operation_id_retry.py |
Sync integration coverage asserting operation_id stability across retries (and no retry for non-retryable multi-writes). |
test/asynchronous/test_operation_id_retry.py |
Async equivalent of the retry operation_id stability tests. |
pymongo/synchronous/server.py |
Passes conn.op_id into cursor command execution for APM publishing. |
pymongo/synchronous/pool.py |
Adds Connection.op_id, forwards it into run_command, and clears it on check-in. |
pymongo/synchronous/mongo_client.py |
Mints/stabilizes per-operation ids across retries and stamps them on checked-out connections. |
pymongo/synchronous/command_runner.py |
Accepts/threads op_id through run_command / run_cursor_command into _run_command. |
pymongo/asynchronous/server.py |
Async counterpart: passes conn.op_id for cursor command execution. |
pymongo/asynchronous/pool.py |
Async counterpart: adds/forwards/clears op_id on connections. |
pymongo/asynchronous/mongo_client.py |
Async counterpart: stable per-operation id and per-attempt stamping onto connections. |
pymongo/asynchronous/command_runner.py |
Async counterpart: threads op_id through command execution to APM event publishing. |
|
Updated description to include full template |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
There are merge conflicts, please resolve. |
5546477 to
75f4e21
Compare
|
I fixed the merge conflict and the typing error that was causing the workflow to fail |
| func=func, | ||
| ) | ||
|
|
||
| def require_failCommand_appName(self, func): |
There was a problem hiding this comment.
What's the purpose of this change?
There was a problem hiding this comment.
It was needed to fix an untyped-decorator mypy error that was occurring in my test. This matches the signature of require_failCommand_fail_point.
There was a problem hiding this comment.
You don't need both the require_failCommand_fail_point and require_failCommand_appName annotations, the latter is a strict subset of the former.
1707aa0 to
63312b0
Compare
| async with operation.conn_mgr._lock: | ||
| async with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type] | ||
| err_handler.contribute_socket(operation.conn_mgr.conn) | ||
| operation.conn_mgr.conn.op_id = _randint() |
There was a problem hiding this comment.
This will assign a new op_id to every call here even though nothing retryable goes through this path.
There was a problem hiding this comment.
Good point, I changed it to conn.op_id = None so each get More falls back to its own request_id.
… into shrey-mongo/PYTHON-5903
| yield session._pinned_connection | ||
| return | ||
| async with await server.checkout(handler=err_handler) as conn: | ||
| conn.op_id = None # Only retryable read/write logic sets an op_id. |
There was a problem hiding this comment.
This check should be moved to before the if in_txn block to prevent a pinned connection from re-using an old op_id.
NoahStapp
left a comment
There was a problem hiding this comment.
What do you think about using a ContextVar for the operationId instead of storing it on the connection? That would remove the current friction between a per-operation object being stored on a cross-operation object and simplify the cleanup significantly.
We already use this pattern in _csot.py and elsewhere for exactly the reason it might be useful here: to avoid threading through the entire call stack while still centralizing cleanup.
Something like this in _ClientConnectionRetryable.run:
token = _op_id.OP_ID.set(self._operation_id)
try:
return await self._run()
finally:
_op_id.OP_ID.reset(token)
And then replacing the current _handle_reauth block with this:
token = _op_id.OP_ID.set(None)
try:
await conn.authenticate(reauthenticate=True)
finally:
_op_id.OP_ID.reset(token)
The other cleanups could then be removed since they don't interact with _ClientConnectionRetryable.run() at all.
|
Implemented the I set the token in |
… into shrey-mongo/PYTHON-5903 # Conflicts: # pymongo/asynchronous/mongo_client.py # pymongo/synchronous/mongo_client.py
… into shrey-mongo/PYTHON-5903
NoahStapp
left a comment
There was a problem hiding this comment.
OP_ID needs to be cleared inside AsyncPeriodicExecutor._run(), just as _csot.reset_all() is to prevent bleeding of op ids between async tasks.
Also look at _csot.py for an example of how to consolidate the OP_ID set/unset logic into a potential context manager.
| codec_options=codec_options, | ||
| user_fields=user_fields, | ||
| orig=orig, | ||
| op_id=_op_id.OP_ID.get(), |
There was a problem hiding this comment.
We can consolidate these explicit passes of op_id to a new block within _run_command:
if op_id is None:
op_id = _op_id.OP_ID.get()
| if self._retrying: | ||
| self._log_retry(is_write=True) | ||
| return await self._func(self._session, conn, self._retryable) # type: ignore | ||
| # Publish this op's id on every command of every attempt. |
There was a problem hiding this comment.
| # Publish this op's id on every command of every attempt. | |
| # One operation id across all attempts of this operation. |
| if self._retrying: | ||
| self._log_retry(is_write=False) | ||
| return await self._func(self._session, self._server, conn, read_pref) # type: ignore | ||
| # Publish this op's id on every command of every attempt. |
There was a problem hiding this comment.
| # Publish this op's id on every command of every attempt. | |
| # One operation id across all attempts of this operation. |
|
|
||
|
|
||
| class TestOperationIdRetry(AsyncIntegrationTest): | ||
| RETRIES = 2 # fail this many attempts; the (RETRIES + 1)th succeeds. |
There was a problem hiding this comment.
Is this comment adding something non-obvious to a reader of the code?
| """Force ``times`` closeConnection failures of ``command_name``, run | ||
| ``action``, and return its ``(started, failed, succeeded)`` events.""" |
There was a problem hiding this comment.
| """Force ``times`` closeConnection failures of ``command_name``, run | |
| ``action``, and return its ``(started, failed, succeeded)`` events.""" | |
| """Set a failpoint for the given command and return the corresponding events published during its execution.""" |
| await self.coll.insert_many([{"_id": i, "x": i % 3} for i in range(5)]) | ||
| await self.coll.create_index("x") | ||
|
|
||
| async def _run_under_failpoint(self, command_name, action, times, expected_error=None): |
There was a problem hiding this comment.
| async def _run_under_failpoint(self, command_name, action, times, expected_error=None): | |
| async def _run_under_failpoint(self, name, f, times, expected_error=None): |
| ("listIndexes", lambda c: _list_indexes(c)), | ||
| ] | ||
|
|
||
| _COMMANDS = {name for name, _ in _RETRYABLE_WRITES + _RETRYABLE_READS} |
There was a problem hiding this comment.
If this isn't going to be reused, can it be defined inline at its use instead?
| for command_name, action in _RETRYABLE_WRITES: | ||
| with self.subTest(command=command_name): | ||
| await self._check_stable_operation_id(command_name, action, self.RETRIES) | ||
|
|
There was a problem hiding this comment.
| for command_name, action in _RETRYABLE_WRITES: | |
| with self.subTest(command=command_name): | |
| await self._check_stable_operation_id(command_name, action, self.RETRIES) | |
| for name, f in _RETRYABLE_WRITES: | |
| with self.subTest(command=name): | |
| await self._check_stable_operation_id(name, f, self.RETRIES) | |
There was a problem hiding this comment.
We use the name, f convention for similar tests elsewhere.
| for command_name, action in _RETRYABLE_READS: | ||
| with self.subTest(command=command_name): | ||
| await self._check_stable_operation_id(command_name, action, self.RETRIES) |
There was a problem hiding this comment.
| for command_name, action in _RETRYABLE_READS: | |
| with self.subTest(command=command_name): | |
| await self._check_stable_operation_id(command_name, action, self.RETRIES) | |
| for name, f in _RETRYABLE_READS: | |
| with self.subTest(command=name): | |
| await self._check_stable_operation_id(name, f, self.RETRIES) |
| async def test_non_retryable_write_is_not_retried(self): | ||
| # Multi-document writes are not retryable: a single network error must | ||
| # surface immediately, with exactly one attempt. | ||
| for command_name, action in [ |
There was a problem hiding this comment.
| for command_name, action in [ | |
| for name, f in [ |
… into shrey-mongo/PYTHON-5903
| await self._check_stable_operation_id(name, f, self.RETRIES) | ||
|
|
||
| @async_client_context.require_no_standalone | ||
| async def test_non_retryable_write_is_not_retried(self): |
There was a problem hiding this comment.
This isn't testing any new behavior added in this PR, can we remove it?
PYTHON-5903: Retry attempts should share a single stable operation_id
Changes in this PR
Command-monitoring events carry an
operation_idto correlate the events of one logical operation. For single-document CRUD and cursor operations, each retry attempt got a differentoperation_id(it defaulted to the per-attemptrequest_id), so consumers couldn't group a retried operation's events. Bulk writes already shared one across batches._ClientConnectionRetryablemints theoperation_idonce per logical operation.pymongo/_op_id.pymodule holds anOP_IDContextVarand an_OpIdContextcontext manager._write/_readenter it around each attempt's command execution, so every attempt publishes the same id; on exit the previous value is restored.command_runner._run_commandfalls back toOP_ID.get()when no explicitop_idis passed (bulk write pass their own), and threads it to thepublish_command_*events and command logs.Noneand keep falling back torequest_id._handle_reauthsuppresses the in-flight id (_OpIdContext(None)) while re-authenticating, then restores it for the retried command.AsyncPeriodicExecutor._run()resetsOP_IDalongside_csot.reset_all()so executor tasks don't inherit an operation's id from their spawning context.Test Plan
test/asynchronous/test_operation_id_retry.pyforces multiple retries via afailCommandfailpoint across a read/write matrix and asserts all command events share oneoperation_id; verifies non-retryable multi-writes don't retry.test_async_contextvars_reset.pyto assertOP_IDis reset inside executor tasks, alongside the CSOT vars.Checklist
Checklist for Author
Checklist for Reviewer