Skip to content

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

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

JoshVanL wants to merge 2 commits into
dapr:masterfrom
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.

Signed-off-by: joshvanl <me@joshvanl.dev>
@JoshVanL
JoshVanL requested review from a team as code owners September 22, 2026 18:12
@JoshVanL
JoshVanL requested a balanced review from Copilot and removed request for a team September 22, 2026 18:23

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

🟢 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 WhitWaldo 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.

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.

@olitomlinson

Copy link
Copy Markdown
Contributor

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 ReentrancyConfig for elsewhere.

One concern worth flagging: the fix is correct, but eviction means the next defaultTracker read of that key forces a real round trip to the state store. We haven't measured this in production or under load yet, but tracing through our own actors, we have (at least) one case where this looks like it wouldn't be a one-off cost: an actor with a self-rescheduling reminder that ticks every 1s while there's pending work, alongside an ordinary method call that writes the same key on essentially every invocation. Since reentrancy assigns a fresh Dapr-Reentrancy-Id to every ordinary call once enabled for the type — not just calls that are actually nested/reentrant — that ordinary call always goes through a scoped tracker, so on paper a busy instance of that actor would see close to one extra state-store query per second, for as long as it stays busy. That's a hypothesis from reading the code, not something we've observed yet — we plan to check it under real load. Flagging in case it's already a known tradeoff, or worth a follow-up either way.

Would a refresh-in-place instead of an evict-and-reload avoid that entirely? By the time InvalidateDefaultTracker runs, SaveStateAsync has already confirmed the write succeeded, so the tracker already holds the exact value that's now persisted — seems like it could just update defaultTracker's entry with that value (keeping it clean) instead of dropping it:

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>
olitomlinson added a commit to olitomlinson/dotnet-sdk that referenced this pull request Sep 24, 2026
…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>
WhitWaldo pushed a commit that referenced this pull request Sep 24, 2026
* 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>
@WhitWaldo

Copy link
Copy Markdown
Contributor

Superseded by #1912

@WhitWaldo WhitWaldo closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants