Conversation
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. Signed-off-by: joshvanl <me@joshvanl.dev>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
All reviewed changes are covered by regression tests with no unresolved issues.
Review effort: Balanced
Findings: None
What changed in this PR
Fixes stale actor-state reads after reentrant saves by invalidating clean entries in the default state tracker.
Changes:
- Invalidates affected default-tracker entries after scoped saves.
- Adds regression coverage for reentrant state updates.
| File | Description |
|---|---|
test/Dapr.Actors.Test/ActorStateManagerTest.cs |
Verifies persisted state is reloaded after reentrant updates. |
src/Dapr.Actors/Runtime/ActorStateManager.cs |
Invalidates stale default-tracker entries after reentrant saves. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
WhitWaldo
left a comment
There was a problem hiding this comment.
This looks fine - can you please also add an e2e test that validates this works as expected. This project uses the older e2e testing projects, so setting up the real-world harness will likely mean touching on Dapr.E2E.Test.Actors, Dapr.E2E.Test.App.ReentrantActor and actual tests in this project.
|
Thanks for this — confirmed it fixes the reminder-relay staleness bug in our app, including a deliberately-constructed A→B→A reentrant deadlock scenario we rely on One concern worth flagging: the fix is correct, but eviction means the next Would a refresh-in-place instead of an evict-and-reload avoid that entirely? By the time private void InvalidateDefaultTracker(IEnumerable<ActorStateChange> stateChanges)
{
foreach (var stateChange in stateChanges)
{
if (this.defaultTracker.TryGetValue(stateChange.StateName, out var stateMetadata) &&
stateMetadata.ChangeKind == StateChangeKind.None)
{
if (stateChange.ChangeKind == StateChangeKind.Remove)
{
this.defaultTracker.Remove(stateChange.StateName);
}
else
{
this.defaultTracker[stateChange.StateName] =
StateMetadata.Create(stateChange.Value, StateChangeKind.None, ttlExpireTime: stateChange.TTLExpireTime);
}
}
}
}Given Dapr's single-writer-per-actor-instance guarantee, I don't see how the backend's copy could diverge from what was just written — unless some state store transforms/normalizes a value server-side on write, in which case reload-and-trust-the-backend is the safer choice and eviction is correct as-is. Curious if that's a known consideration, or if refresh-in-place is worth a follow-up. |
Signed-off-by: joshvanl <me@joshvanl.dev>
…rant save Alternative to dapr#1908's evict-and-reload: by the time SyncDefaultTracker runs, SaveStateAsync has already confirmed the write succeeded, so the value is known-current. Refresh the default tracker's clean copy of each written key in place with that value instead of dropping it - same correctness guarantee (a later activation/reminder/timer read never sees stale data), without forcing that read to round-trip the state store for a value already held in-process. Removals still evict, since there's nothing to refresh in place with. Relies on Dapr's single-writer-per-actor-instance guarantee: no other caller can have mutated this key between the confirmed save and now. Adds a regression test proving the round trip is actually avoided (asserts the runtime is called exactly as many times as the pre-existing, unrelated ContainsStateAsync check in SetStateAsync requires - not once more for the default-tracker read that follows), plus a test confirming Remove still evicts rather than refreshes. Signed-off-by: Oliver Tomlinson <oliverjamestomlinson@gmail.com>
* Actors: invalidate the default state tracker after a reentrant save 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. Signed-off-by: joshvanl <me@joshvanl.dev> * Actors: refresh default tracker in place instead of evicting on reentrant save Alternative to #1908's evict-and-reload: by the time SyncDefaultTracker runs, SaveStateAsync has already confirmed the write succeeded, so the value is known-current. Refresh the default tracker's clean copy of each written key in place with that value instead of dropping it - same correctness guarantee (a later activation/reminder/timer read never sees stale data), without forcing that read to round-trip the state store for a value already held in-process. Removals still evict, since there's nothing to refresh in place with. Relies on Dapr's single-writer-per-actor-instance guarantee: no other caller can have mutated this key between the confirmed save and now. Adds a regression test proving the round trip is actually avoided (asserts the runtime is called exactly as many times as the pre-existing, unrelated ContainsStateAsync check in SetStateAsync requires - not once more for the default-tracker read that follows), plus a test confirming Remove still evicts rather than refreshes. Signed-off-by: Oliver Tomlinson <oliverjamestomlinson@gmail.com> --------- Signed-off-by: joshvanl <me@joshvanl.dev> Signed-off-by: Oliver Tomlinson <oliverjamestomlinson@gmail.com> Co-authored-by: joshvanl <me@joshvanl.dev>
|
Superseded by #1912 |
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