Skip to content

Improving actor write performance by skipping runtime "exists" checks on state writes - #1914

Merged
WhitWaldo merged 3 commits into
masterfrom
cache-perf-update
Sep 24, 2026
Merged

WhitWaldo merged 3 commits into
masterfrom
cache-perf-update

Conversation

@WhitWaldo

Copy link
Copy Markdown
Contributor

Description

Setting a value for a key always sets it as an update (effectively an upsert) in the actor state. This means that when a delete happens, the delete is always sent to the runtime even if the key has never existed there (e.g. added to state and then removed before the actor turn completes). In all other respects, caching operates the same but without the check to see if the key already exists in the runtime when setting the value.

I added ample tests to validate that this does not negatively impact the current implementation and expectations.

Great find @olitomlinson !

Issue reference

We strive to have all PR being opened based on an issue, where the problem or feature have been discussed prior to implementation.

Please reference the issue this PR will close: #1910

Checklist

Please make sure you've completed the relevant tasks for this PR, out of the following list:

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation

… upsert) in the actor state. This means that when a delete happens, the delete is always sent to the runtime even if the key has never existed there (e.g. added to state and then removed before the actor turn completes). In all other respects, caching operates the same but without the check to see if the key already exists in the runtime when setting the value.

Signed-off-by: Whit Waldo <whit.waldo@innovian.net>
@WhitWaldo WhitWaldo added this to the v1.18.x - SDK Patches milestone Sep 24, 2026
@WhitWaldo WhitWaldo self-assigned this Sep 24, 2026
@WhitWaldo
WhitWaldo requested review from a team as code owners September 24, 2026 11:38
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.25%. Comparing base (88aaeef) to head (50f9ed5).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1914      +/-   ##
==========================================
+ Coverage   64.19%   64.25%   +0.05%     
==========================================
  Files         358      358              
  Lines       10508    10506       -2     
  Branches     1258     1256       -2     
==========================================
+ Hits         6746     6751       +5     
+ Misses       3426     3422       -4     
+ Partials      336      333       -3     
Flag Coverage Δ
net10.0 64.22% <100.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

@WhitWaldo
WhitWaldo merged commit fb365b6 into master Sep 24, 2026
596 of 597 checks passed
@WhitWaldo
WhitWaldo deleted the cache-perf-update branch September 24, 2026 14:51
CasperGN added a commit to CasperGN/python-sdk that referenced this pull request Sep 25, 2026
set_state and set_state_ttl read the state store for a key that was not in
the tracker only to choose between add and update, but the state provider
saves both as the same upsert. The key is now recorded as an update without
that read.

Update rather than add is deliberate: the key may already exist in the
store, so a remove later in the same turn has to send a delete instead of
dropping the pending entry. Deleting a key that turns out not to exist is
harmless; skipping the delete of one that does is not.

Keys the manager knows are absent keep the add kind, so add-then-remove
still sends nothing: try_add_state still reads the store and fails when the
key exists, and a cached "not found" entry still turns into an add.
try_remove_state still reads the store to report whether anything was
removed. A reentrant save refreshes the default tracker the same way
whatever kind the saved change had.

Ports dapr/dotnet-sdk#1914.

Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-merge-queue Bot pushed a commit to dapr/python-sdk that referenced this pull request Oct 13, 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.

Should be backported.

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

* fix(actor): refresh the default state tracker in place after a reentrant 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>

* fix(actor): drop the default entry when a reentrant refresh cannot match a fresh read

Two cases fall back to #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>

* feat(actor): cache state that is not found in the default state tracker

Reading a key that does not exist went back to the state store every time,
because a miss was not cached. try_get_state now records a miss as a clean
"not found" entry in the default tracker, so later reads, contains_state,
try_remove_state and remove_state answer without I/O, and try_add_state,
set_state, set_state_ttl, get_or_add_state and add_or_update_state turn it
into an add without asking the store again. get_state_names skips it and
save_state never sends it.

Misses are not cached in reentrant trackers. Nothing refreshes an outer
reentrant call's tracker when a nested reentrant call (A -> B -> A) saves,
so a cached miss there would hide the nested write and then overwrite it
through get_or_add_state or try_add_state.

The entry is a private StateMetadata subclass that reports the existing
"none" change kind, so the public StateChangeKind enum, ActorStateChange and
subclasses of ActorStateManager are unchanged.

A reentrant save refreshes a "not found" default entry the same way as a
clean one: a write replaces it with the saved value and a remove evicts it.
Without that, a reminder that cached a miss would keep seeing the key as
absent after a reentrant call created it.

Ports dapr/dotnet-sdk#1913 and dapr/dotnet-sdk#1916.

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

* test(actor): run the nested reentrant test on Python 3.10

create_task(context=...) only exists from Python 3.11. A task already runs
in a copy of the caller's context, so the argument isn't needed.

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

* perf(actor): skip the state store read when setting an untracked key

set_state and set_state_ttl read the state store for a key that was not in
the tracker only to choose between add and update, but the state provider
saves both as the same upsert. The key is now recorded as an update without
that read.

Update rather than add is deliberate: the key may already exist in the
store, so a remove later in the same turn has to send a delete instead of
dropping the pending entry. Deleting a key that turns out not to exist is
harmless; skipping the delete of one that does is not.

Keys the manager knows are absent keep the add kind, so add-then-remove
still sends nothing: try_add_state still reads the store and fails when the
key exists, and a cached "not found" entry still turns into an add.
try_remove_state still reads the store to report whether anything was
removed. A reentrant save refreshes the default tracker the same way
whatever kind the saved change had.

Ports dapr/dotnet-sdk#1914.

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

* fix(actor): log the cause when a reentrant refresh evicts a state entry

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

---------

Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-authored-by: joshvanl <me@joshvanl.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dapr-bot added a commit to dapr/python-sdk that referenced this pull request Oct 16, 2026
… (#1303)

* Refresh the default actor state tracker in place after a reentrant save (#1237)

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

Should be backported.

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

* fix(actor): refresh the default state tracker in place after a reentrant 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>

* fix(actor): drop the default entry when a reentrant refresh cannot match a fresh read

Two cases fall back to #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>

* fix(actor): evict TTL'd keys from the default tracker and log refresh failures

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

---------

Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-authored-by: joshvanl <me@joshvanl.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fb2cd88)
Signed-off-by: Samantha Coyle <sam@diagrid.io>

* Remember missing actor state keys instead of re-reading them (#1238)

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

Should be backported.

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

* fix(actor): refresh the default state tracker in place after a reentrant 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>

* fix(actor): drop the default entry when a reentrant refresh cannot match a fresh read

Two cases fall back to #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>

* feat(actor): cache state that is not found in the default state tracker

Reading a key that does not exist went back to the state store every time,
because a miss was not cached. try_get_state now records a miss as a clean
"not found" entry in the default tracker, so later reads, contains_state,
try_remove_state and remove_state answer without I/O, and try_add_state,
set_state, set_state_ttl, get_or_add_state and add_or_update_state turn it
into an add without asking the store again. get_state_names skips it and
save_state never sends it.

Misses are not cached in reentrant trackers. Nothing refreshes an outer
reentrant call's tracker when a nested reentrant call (A -> B -> A) saves,
so a cached miss there would hide the nested write and then overwrite it
through get_or_add_state or try_add_state.

The entry is a private StateMetadata subclass that reports the existing
"none" change kind, so the public StateChangeKind enum, ActorStateChange and
subclasses of ActorStateManager are unchanged.

A reentrant save refreshes a "not found" default entry the same way as a
clean one: a write replaces it with the saved value and a remove evicts it.
Without that, a reminder that cached a miss would keep seeing the key as
absent after a reentrant call created it.

Ports dapr/dotnet-sdk#1913 and dapr/dotnet-sdk#1916.

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

* test(actor): run the nested reentrant test on Python 3.10

create_task(context=...) only exists from Python 3.11. A task already runs
in a copy of the caller's context, so the argument isn't needed.

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

* test(actor): read the empty payload through a fresh state manager

The cached miss served the second read from the tracker, so the empty
payload never reached the state provider.

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

* fix(actor): log when a reentrant save cannot refresh cached state

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

---------

Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-authored-by: joshvanl <me@joshvanl.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit dbf9867)
Signed-off-by: Samantha Coyle <sam@diagrid.io>

* Skip the state store existence check when setting actor state (#1239)

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

Should be backported.

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

* fix(actor): refresh the default state tracker in place after a reentrant 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>

* fix(actor): drop the default entry when a reentrant refresh cannot match a fresh read

Two cases fall back to #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>

* feat(actor): cache state that is not found in the default state tracker

Reading a key that does not exist went back to the state store every time,
because a miss was not cached. try_get_state now records a miss as a clean
"not found" entry in the default tracker, so later reads, contains_state,
try_remove_state and remove_state answer without I/O, and try_add_state,
set_state, set_state_ttl, get_or_add_state and add_or_update_state turn it
into an add without asking the store again. get_state_names skips it and
save_state never sends it.

Misses are not cached in reentrant trackers. Nothing refreshes an outer
reentrant call's tracker when a nested reentrant call (A -> B -> A) saves,
so a cached miss there would hide the nested write and then overwrite it
through get_or_add_state or try_add_state.

The entry is a private StateMetadata subclass that reports the existing
"none" change kind, so the public StateChangeKind enum, ActorStateChange and
subclasses of ActorStateManager are unchanged.

A reentrant save refreshes a "not found" default entry the same way as a
clean one: a write replaces it with the saved value and a remove evicts it.
Without that, a reminder that cached a miss would keep seeing the key as
absent after a reentrant call created it.

Ports dapr/dotnet-sdk#1913 and dapr/dotnet-sdk#1916.

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

* test(actor): run the nested reentrant test on Python 3.10

create_task(context=...) only exists from Python 3.11. A task already runs
in a copy of the caller's context, so the argument isn't needed.

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

* perf(actor): skip the state store read when setting an untracked key

set_state and set_state_ttl read the state store for a key that was not in
the tracker only to choose between add and update, but the state provider
saves both as the same upsert. The key is now recorded as an update without
that read.

Update rather than add is deliberate: the key may already exist in the
store, so a remove later in the same turn has to send a delete instead of
dropping the pending entry. Deleting a key that turns out not to exist is
harmless; skipping the delete of one that does is not.

Keys the manager knows are absent keep the add kind, so add-then-remove
still sends nothing: try_add_state still reads the store and fails when the
key exists, and a cached "not found" entry still turns into an add.
try_remove_state still reads the store to report whether anything was
removed. A reentrant save refreshes the default tracker the same way
whatever kind the saved change had.

Ports dapr/dotnet-sdk#1914.

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

* fix(actor): log the cause when a reentrant refresh evicts a state entry

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

---------

Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: Casper Nielsen <casper@diagrid.io>
Co-authored-by: joshvanl <me@joshvanl.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fceda09)
Signed-off-by: Samantha Coyle <sam@diagrid.io>

---------

Signed-off-by: joshvanl <me@joshvanl.dev>
Signed-off-by: Casper Nielsen <casper@diagrid.io>
Signed-off-by: Samantha Coyle <sam@diagrid.io>
Co-authored-by: Casper Nielsen <casper@diagrid.io>
Co-authored-by: joshvanl <me@joshvanl.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dapr Bot <56698301+dapr-bot@users.noreply.github.com>
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.

Discussion: SetStateAsync's existence check costs a round trip on every new key, to optimize a case that may be rare

1 participant