Skip to content

Actors: invalidate the default state tracker after a reentrant save - #1227

Open
JoshVanL wants to merge 2 commits into
dapr:mainfrom
JoshVanL:actors-fix-state
Open

JoshVanL wants to merge 2 commits into
dapr:mainfrom
JoshVanL:actors-fix-state

Conversation

@JoshVanL

Copy link
Copy Markdown
Contributor

With reentrancy enabled, each dispatched method call gets its own state change tracker, but activation, reminders and timers run on the default tracker because no reentrancy id reaches them. A key read during activation stays cached there with change kind none forever, while method calls write the same key through their own trackers.

A reminder callback that later reads that key is served the stale activation value. An app that skips its write because the value looks unchanged loses that write silently: nothing is logged anywhere, because no write is ever issued.

Drop the default tracker's clean copies of keys written through a reentrancy-scoped tracker, so the next read reloads them from the runtime.

Reported in dapr/dapr#10532, where a reminder callback's read-modify-write of an actor state key never persisted while the identical write from an ordinary method call did, and only with reentrancy enabled.

Should be backported.

cc @olitomlinson

With reentrancy enabled, each dispatched method call gets its own
state change tracker, but activation, reminders and timers run on the
default tracker because no reentrancy id reaches them. A key read
during activation stays cached there with change kind none forever,
while method calls write the same key through their own trackers.

A reminder callback that later reads that key is served the stale
activation value. An app that skips its write because the value looks
unchanged loses that write silently: nothing is logged anywhere,
because no write is ever issued.

Drop the default tracker's clean copies of keys written through a
reentrancy-scoped tracker, so the next read reloads them from the
runtime.

Reported in dapr/dapr#10532, where a reminder callback's
read-modify-write of an actor state key never persisted while the
identical write from an ordinary method call did, and only with
reentrancy enabled.

Should be backported.

Signed-off-by: joshvanl <me@joshvanl.dev>
@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.90%. Comparing base (fb229bc) to head (f32a656).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1227      +/-   ##
==========================================
+ Coverage   83.89%   83.90%   +0.01%     
==========================================
  Files         123      123              
  Lines       10265    10272       +7     
==========================================
+ Hits         8612     8619       +7     
  Misses       1653     1653              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A concurrent default-tracker read can reinsert stale state after invalidation.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Fixes stale actor-state caching after reentrant saves by invalidating affected default-tracker entries.

Changes:

  • Invalidates clean default-tracker entries after scoped saves.
  • Adds regression coverage for reentrant cache invalidation.
File Description
tests/​actor/​test_state_manager.py Tests reentrant save cache invalidation.
dapr/​actor/​runtime/​state_manager.py Invalidates default-tracker entries; a critical concurrent-read race remains.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread dapr/actor/runtime/state_manager.py

@CasperGN CasperGN left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The fix is in the right place: save_state is the only point where a reentrancy-scoped write lands, and dropping only the default tracker's clean copies is the right call, since a dirty default entry is a pending write that shouldn't be discarded. tests/actor passes (190), and the new test fails if the invalidation is removed.

None of these block:

  1. Test gaps. Two cases worth pinning: a reentrant remove (the next default read should report the key missing), and a default entry that is dirty (not none), which should survive the invalidation.
  2. Context cleanup in the test. reentrancy_ctx.set(...) and set_state_context(...) are undone only if the middle of the test succeeds. If an assertion fails there, the context var stays set for later tests in the same thread. Resetting with the token in a try/finally would keep a failure local.
  3. Scope note, for a follow-up if at all. The same staleness exists one level up. In a reentrant chain A → B → A, if the outer A call reads a key before calling out, and the nested A call writes and saves it, the outer call's own tracker still serves the old value when it reads again. This PR doesn't cover that, and it isn't reachable from save_state the same way, so it's fine to leave out here.

@olitomlinson

Copy link
Copy Markdown
Contributor

@JoshVanL @CasperGN hey just a heads up that this fix, although correct, also introduces a performance regression (an extra data refresh from the state store).

There is an alternative solution, which doesn't incur the performance regression, which has been adopted by the dotnet SDK here dapr/dotnet-sdk#1912


During this work, we also found several other Actor optimisation opportunities in the dotnet SDK, so it might be work assessing the python Actor implementation for these opportunities too!

dapr/dotnet-sdk#1913
dapr/dotnet-sdk#1914
and a bug fix that emerged from the interaction of the previous optimisations dapr/dotnet-sdk#1916

@CasperGN

Copy link
Copy Markdown
Contributor

@JoshVanL @CasperGN hey just a heads up that this fix, although correct, also introduces a performance regression (an extra data refresh from the state store).

There is an alternative solution, which doesn't incur the performance regression, which has been adopted by the dotnet SDK here dapr/dotnet-sdk#1912

During this work, we also found several other Actor optimisation opportunities in the dotnet SDK, so it might be work assessing the python Actor implementation for these opportunities too!

dapr/dotnet-sdk#1913 dapr/dotnet-sdk#1914 and a bug fix that emerged from the interaction of the previous optimisations dapr/dotnet-sdk#1916

@olitomlinson - thank you for this detail! I've run through the dotnet PRs and with the help of my friend ported through #1237, #1238 and #1239 - they're on top of this so should be good to run all 4 through and then cut a release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants