fix(memory): preserve DaprSession created_at across writes - #4213
Conversation
DaprSession.add_items rewrites the whole metadata document on every call and sets created_at to the current clock, so a session's persisted created_at always equals updated_at and its age is unrecoverable after the second turn. Read the stored created_at first and keep it when it is present, matching RedisSession, which uses hsetnx, and MongoDBSession, which uses setOnInsert.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e016c25169
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The metadata save was unconditional while the messages save used etags, so two processes appending to the same new session could both read no metadata, each pick their own now, and let the later save overwrite created_at. Carry the metadata etag through the read and save with first_write concurrency, retrying through the existing conflict handler. Also switch the created_at tests to a string monkeypatch target, which clears four mypy attr-defined errors on the module's re-exported time attribute.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c7eb25343
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The messages key is committed before the metadata key, so exhausting the metadata retry budget raised from add_items() for a batch that was already stored. A caller acting on that error by retrying would append the same items a second time. Treat the post-commit metadata refresh as best effort and log a warning when it gives up, since it is derived bookkeeping rather than conversation state.
|
Two Codex findings here. I took the first and I am pushing back on the second. Not failing after the batch is committed. This one is right and it is my bug, fixed in d4f4d3b. The messages key is saved first, so once the metadata loop runs the caller's items are already in the session. Exhausting the metadata retry budget raised out of The post-commit metadata refresh is now best effort and logs a warning instead of raising. I also moved There is a regression test. On the previous commit it fails with Tying The concern is real but bounded. Both writers are appending to the same brand new session, so the window between one committing messages and the other creating metadata is the concurrent request window, and Fixing it properly means making the creation stamp part of the same commit that wins the messages key, and those are two separate keys. The Dapr state API used here writes them independently, so getting that guarantee needs a transaction across both keys, which is a much larger change to this backend than the bug justifies. Anything short of that just moves which of the two racers wins. The existing |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4f4d3ba55
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The warning put the caller-supplied session id and the provider exception straight on the LogRecord, so a sidecar error carrying tenant or backend detail was logged even with the SDK data flags enabled. Route it through log_model_and_tool_action_warning with a fixed message and the session id supplied as diagnostic context instead.
|
The P1 is right, and it was mine from the previous commit. Fixed in d829b2f. My warning interpolated the caller-supplied It now goes through log_model_and_tool_action_warning(
logger,
"DaprSession stored the new items but could not update the session metadata",
error,
diagnostic_extra=lambda: {"session_id": self.session_id},
)
The regression test now asserts the absence as well as the presence, that the record contains "could not update" but contains neither the session id nor the exception text. It fails on d4f4d3b against the captured log above and passes here. The file is at 48 passed with ruff, ruff format and mypy clean. |
seratch
left a comment
There was a problem hiding this comment.
Thanks for the contribution. The sequential created_at bug is valid, and preserving an existing value with ETag-guarded updates is the right direction.
Before merging, please narrow the concurrency guarantee. Dapr specifies that writes without an ETag use last-write-wins, even when first-write is requested. The new test instead makes FakeDaprClient reject a missing-ETag write after another writer creates the key, so it passes under behavior the real Dapr API does not provide.
Please request first_write only when a real metadata ETag was read, replace the missing-metadata race test with a stale non-null ETag retry test, and update the comments accordingly. The sequential preservation and post-commit best-effort handling can remain unchanged.
TResponseInputItem is a union of TypedDicts, most of which have no content key, so subscripting it fails mypy. Match the .get style the rest of this file uses.
Dapr treats a write with no etag as last-write-wins even when first-write concurrency is requested, so asking for it on the create claimed a race guarantee the store does not provide. Request it only once metadata exists. The missing-metadata race test relied on the fake rejecting an etag-less write, which real Dapr accepts. Replace it with a stale non-null etag retry, which is the guarantee that actually holds and the case that stops a later append from resetting an established created_at.
|
You are right, and this one is worth stating plainly: my test was passing because of the fake, not because of anything Dapr guarantees. Fixed in 7c30e9a.
Three changes:
options=self._get_state_options(
concurrency=(Concurrency.first_write if metadata_etag is not None else None)
),The comment no longer claims the create is protected. It says the guard applies once metadata exists, that two concurrent creates can both succeed with the loser's
On verification, two things worth being precise about. The new test passes on both the old and new commit, because the stale-etag path is unchanged, so there is no fail-before to show for it. The change that does have one is the removal: I re-ran the old race test against the corrected source and it now fails with The sequential preservation and the post-commit best-effort handling are unchanged. 48 passed, with ruff, ruff format and mypy clean over both the source and the test. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c30e9af4c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
seratch
left a comment
There was a problem hiding this comment.
Thanks for the update. The previous missing-ETag race-test issue is addressed correctly, but one representation mismatch remains.
The Dapr Python SDK returns an empty string for a missing state ETag, not None. As a result, metadata_etag is not None still treats the create path as having a real ETag and requests first_write, contrary to the new contract and comments. The stale-ETag test does not cover this branch and, as noted, also passes on the previous commit.
Please normalize response.etag or None in _read_created_at, make the fake return "" for missing ETags, and add an assertion that metadata creation does not request first_write. Please also describe concurrent ETag-less creation simply as last-write-wins, without the timing bound or claiming every later write is guarded.
The Dapr SDK reports a missing state etag as an empty string, so the create path still satisfied 'metadata_etag is not None' and asked for first_write, which is the guarantee the previous commit said it would stop claiming. Normalize response.etag to None, make the fake report the empty string the way the SDK does, and assert that creation sends no etag and leaves concurrency unspecified while the following update is etag guarded. Also drop the timing bound and the every-later-write claim from the comment.
|
Good catch, and it is worse than a documentation mismatch: the guard was not actually doing anything on the create. Fixed in b62d510. All four points:
One thing I got wrong while writing that test and want to flag rather than bury: I first asserted the create concurrency was The comment no longer carries the timing bound or the every-later-write claim. It now says concurrent etag-less creation is last-write-wins and stops there. Separately, Codex left a P2 on this same read that I do not think I should act on unilaterally. Under the default Verification: 49 passed, with ruff, ruff format and mypy clean over both the source and the test. |
|
Ready for another look when you have a moment. All four points from your last review are in at One thing you may not see from the PR page: no checks have run on this head. The Tests workflow is sitting at |
seratch
left a comment
There was a problem hiding this comment.
Thanks for the update. The ETag changes now match Dapr's contract: missing ETags remain last-write-wins, while stale real ETags are retried against the latest metadata.
One merge blocker remains. Please remove or narrow the new claim that DAPR_CONSISTENCY_STRONG makes rapid or multi-writer history and timestamps exact. The Dapr Python async client currently sends state_metadata only as request metadata and does not set GetStateRequest.consistency, so this setting does not provide the stated read guarantee. Keep this PR scoped to preserving created_at when stored metadata is observed; the read-consistency plumbing should be handled separately.
Please also expand the metadata-failure warning test to inspect the complete LogRecord under model-redacted, tool-redacted, and fully diagnostic modes, while continuing to assert that the committed items remain visible. After those focused changes and green CI, this should be ready for another review.
The async client builds GetStateRequest with state_metadata passed as request metadata and never sets the consistency field, so the strong level does not deliver the read guarantee the docstring described. Expand the metadata give-up warning coverage to inspect the whole LogRecord under model-redacted, tool-redacted and fully diagnostic modes, and assert in each that the committed items are still there.
|
Both done in You were right about the consistency claim, and I checked the SDK rather than take it on trust. The warning test is now parametrized over model-redacted, tool-redacted and fully diagnostic. It asserts the committed item is still readable in all three, and inspects the record rather than the rendered string, since the session id rides in |
DaprSession.add_items rewrites the whole metadata document on every call and sets created_at to the current clock, so a session's persisted created_at always equals updated_at and its age is unrecoverable after the second turn.
Every other backend sets it once: RedisSession uses hsetnx, MongoDBSession uses setOnInsert, and the SQL backends insert the row once. This reads the stored created_at first and keeps it when present.
The regression test is a direct port of test_add_items_preserves_created_at_metadata from the Redis suite, and it fails on main with created_at == "2000" instead of "1000". The full Dapr suite is green at 46 passed, along with ruff and mypy.
This is a fresh take on #3236, which was closed with "If the approach is still useful, happy to revisit with a new PR".