fix(memory): preserve DaprSession created_at across writes - #4213
fix(memory): preserve DaprSession created_at across writes#4213adityasingh2400 wants to merge 9 commits into
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".
| response = await self._dapr_client.get_state( | ||
| store_name=self._state_store_name, | ||
| key=self._metadata_key, | ||
| state_metadata=self._get_read_metadata(), |
There was a problem hiding this comment.
Read creation metadata consistently before rewriting it
When the session uses the default DAPR_CONSISTENCY_EVENTUAL, an append immediately following the first metadata save can read a stale absence here. _read_created_at() then returns no timestamp or etag, so add_items() generates a new created_at and performs an unguarded last-write-wins save, recreating the timestamp-reset bug even for sequential writes. Ensure this preservation read has read-after-write consistency or use conditional creation semantics; the in-memory test client currently cannot reproduce an eventually consistent missing read.
AGENTS.md reference: AGENTS.md:L134-L134
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is a real window, but I do not think a code branch is the right answer, so I have documented it instead. Reasoning, in case you disagree.
The read at this line already carries the session's configured level through _get_read_metadata(). Under DAPR_CONSISTENCY_EVENTUAL a stale read is the behavior the caller asked for, not a gap in the guard. Neither remedy in the comment is available to me here. Forcing this one read to strong would silently override an explicit constructor argument, and strong reads are a per-component capability that not every state store offers. Dapr has no create-if-absent primitive, and a write without an etag is documented as last-write-wins even when first_write is requested, which is exactly what the comment above the save already says.
I did consider one narrowing fix: add_items already holds existing_messages from the messages read, so a missing created_at combined with a non-empty history could be treated as a stale read and the metadata write skipped. I rejected it. The messages read one step earlier runs at the same consistency level, so if the metadata read is stale that read is likely stale too, and a stale one there has already caused something worse than a reset timestamp through the last-write-wins save on the messages key. Guarding only the metadata would give a false assurance about a store the caller configured to be eventually consistent, and by your own note the in-memory client cannot exercise the path, so it would be untestable machinery on top of that.
The remedy that actually works is DAPR_CONSISTENCY_STRONG, and nothing was pointing users at it. The consistency docstring now says the eventual level can read back a stale conversation or a stale created_at, and to use strong when a session is appended to from more than one place, or in quick succession, and the history and timestamps have to be exact.
make lint, make typecheck clean, 56 passed across the two Dapr test files.
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. |
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".