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 6 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✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| "data": { | ||
| "failCommands": [command_name], | ||
| "closeConnection": True, | ||
| "appName": _APP_NAME, |
There was a problem hiding this comment.
This class needs to add the @async_client_context.require_failCommand_appName annotation to safely use appName.
There was a problem hiding this comment.
This needs to be updated to use the new operation_id, if present.
|
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
|
|
||
|
|
||
| class TestOperationIdRetry(AsyncIntegrationTest): | ||
| RETRIES = 5 # fail this many attempts; the (RETRIES + 1)th succeeds. |
There was a problem hiding this comment.
Why do we need 5 instead of 2 here? Does 5 provide additional test coverage or safety?
There was a problem hiding this comment.
You're right, no added benefit, dropped to 2
| """Force ``retries`` retries of ``action`` and assert every command | ||
| event for ``command_name`` shares one integer operation_id.""" | ||
| listener = AllowListEventListener(command_name) | ||
| client = await self.async_rs_or_single_client(event_listeners=[listener], appname=_APP_NAME) |
There was a problem hiding this comment.
Do we need to create a new client for every single operation, or can they all share a client set up in asyncSetUp?
There was a problem hiding this comment.
Fixed, the client and listener are created once in asyncSetUp and shared across all operations
| with self.subTest(command=command_name): | ||
| listener = AllowListEventListener(command_name) | ||
| client = await self.async_rs_or_single_client( | ||
| event_listeners=[listener], appname=_APP_NAME | ||
| ) | ||
| coll = client.pymongo_test.test_operation_id_retry | ||
| await self._seed(coll) | ||
| listener.reset() | ||
|
|
||
| fail_point = { | ||
| "mode": {"times": 1}, | ||
| "data": { | ||
| "failCommands": [command_name], | ||
| "closeConnection": True, | ||
| "appName": _APP_NAME, | ||
| }, | ||
| } | ||
| async with self.fail_point(fail_point): | ||
| with self.assertRaises(ConnectionFailure): | ||
| await action(coll) |
There was a problem hiding this comment.
This looks very similar to what _check_stable_operation_id does, can we refactor them to reduce code duplication?
There was a problem hiding this comment.
Refactored, both tests now call a shared _run_under_failpoint helper
| 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.
| db, coll = namespace.split(".", 1) | ||
| spec = {"killCursors": coll, "cursors": cursor_ids} | ||
| # killCursors is its own op; drop any op_id left on a pinned cursor conn. | ||
| conn.op_id = None |
There was a problem hiding this comment.
A cleaner way of doing this might be to set conn.op_id = None in AsyncMongoClient._checkout to cover the edge cases and ensure that only the retryable logic sets a new op_id.
There was a problem hiding this comment.
Done. added conn.op_id = None on checkout and removed the clear from checkin so every checkout starts at None and only the retryable read/write logic sets a real op_id.
| more_to_come: bool = False, | ||
| unpack_res: Optional[Callable[..., Any]] = None, | ||
| cursor_id: Optional[int] = None, | ||
| op_id: Optional[int] = None, |
There was a problem hiding this comment.
Is this (and the one for run_command) ever used, since an op_id is set by the retryable read/write code in mongo_client.py?
There was a problem hiding this comment.
You're right, removed the op_id param and they read from conn.op_id directly now.
| user_fields=user_fields, | ||
| exhaust_allowed=exhaust_allowed, | ||
| write_concern=write_concern, | ||
| op_id=self.op_id, |
There was a problem hiding this comment.
We don't need to pass this since run_command is already passed the connection itself that owns op_id.
… into shrey-mongo/PYTHON-5903
| prev_op_id = conn.op_id | ||
| conn.op_id = None | ||
| await conn.authenticate(reauthenticate=True) | ||
| conn.op_id = prev_op_id |
There was a problem hiding this comment.
This should use a try-except-finally block to ensure the restore of the previous op_id always occurs.
| 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.
| # Each operation, paired with the wire command it issues and an awaitable action. | ||
| # These are all retryable; a stable operation_id must span every retry attempt. |
There was a problem hiding this comment.
This comment isn't needed: it describes a known standard pattern in the codebase.
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 and stamps it on the checked-out connection (conn.op_id) on every attemptConnectiongains anop_idattribute, reset on check-in.pool.commandandserver.run_operationsourceop_idfrom the connection and pass it tocommand_runner.run_command/run_cursor_command, which thread it through to thepublish_command_*events.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.Checklist
Checklist for Author
Checklist for Reviewer