Skip to content

Refresh the default actor state tracker in place after a reentrant save - #1237

Open
CasperGN wants to merge 4 commits into
dapr:mainfrom
CasperGN:fix/actor-refresh-default-tracker
Open

CasperGN wants to merge 4 commits into
dapr:mainfrom
CasperGN:fix/actor-refresh-default-tracker

Conversation

@CasperGN

Copy link
Copy Markdown
Contributor

Based on #1227. Merge it after that. Until then this diff also shows #1227's commits; this PR's own commits are 83d0e03 and c3b3387.

Description

With actor reentrancy on, each reentrant call gets its own state tracker, while activation, reminders and timers use the default tracker. #1227 fixes stale reads by dropping the default tracker's clean copies of keys a reentrant call saved. The next default read then has to go back to the state store for those keys.

This PR refreshes those entries in place instead, so the next read from activation, a reminder or a timer is served from cache with no state store round trip.

What changed, in dapr/actor/runtime/state_manager.py (_invalidate_default_tracker is renamed to _refresh_default_tracker):

  • After a reentrant tracker saves, each saved key that the default tracker holds as clean (change kind none) is updated:
  • Default entries with pending changes (add, update or remove) are left alone, as before.
  • The value is stored in the shape a fresh read returns. A new StateProvider.round_trip_state_value serializes the value and deserializes it as object with the provider's own state serializer. No network call is made. For example a tuple (3, 4) is cached as [3, 4], same as a read from the store.
  • Two cases fall back to Actors: invalidate the default state tracker after a reentrant save #1227's eviction:
    • a saved None value, because the provider leaves it out of the write, so a fresh read may find nothing;
    • a custom serializer that fails to decode the value. The write has already committed at that point, so save_state no longer raises after a successful save.

Behaviour change: none for callers, other than fewer state store reads after reentrant saves.

This ports dapr/dotnet-sdk#1912. Thanks to @olitomlinson for pointing out the .NET changes on #1227.

Issue reference

Related to dapr/dapr#10532 (reminders reading stale state with reentrancy) and #1227.

Checklist

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation (not needed; no public API or documented behaviour changes)

Ran: pytest tests/actor (195 passed), ruff check, ruff format --check, mypy (no issues).

🤖 Generated with Claude Code

JoshVanL and others added 4 commits September 22, 2026 15:10
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>
…ant save

Instead of dropping the default tracker's clean copy of a key that a
reentrant call saved, replace it with the saved value and ttl so the next
read from activation, a reminder or a timer is served from cache rather
than costing an extra state store read. Removed keys are still dropped and
entries with pending changes are left alone.

The cached value is passed through the state serializer first, so it has
the same shape a fresh read would return (for example a tuple comes back
as a list). This mirrors dapr/dotnet-sdk#1912.

Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch a fresh read

Two cases fall back to dapr#1227's eviction instead of an in-place refresh:
a saved None value, which the state provider leaves out of the write, and
a state serializer that fails to decode the value after the save has
already committed. The save no longer raises after a successful write.

Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1237      +/-   ##
==========================================
+ Coverage   83.89%   83.93%   +0.03%     
==========================================
  Files         123      123              
  Lines       10265    10284      +19     
==========================================
+ Hits         8612     8632      +20     
+ Misses       1653     1652       -1     

☔ 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.

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.

2 participants