From fb3a589b004fa0eefe7cab76a9eb2eb6eb12f12e Mon Sep 17 00:00:00 2001 From: joshvanl Date: Tue, 22 Sep 2026 15:11:35 -0300 Subject: [PATCH 1/2] 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 --- src/Dapr.Actors/Runtime/ActorStateManager.cs | 20 ++++++++++++ .../Dapr.Actors.Test/ActorStateManagerTest.cs | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/Dapr.Actors/Runtime/ActorStateManager.cs b/src/Dapr.Actors/Runtime/ActorStateManager.cs index 032918150..101bc18ce 100644 --- a/src/Dapr.Actors/Runtime/ActorStateManager.cs +++ b/src/Dapr.Actors/Runtime/ActorStateManager.cs @@ -493,6 +493,11 @@ public async Task SaveStateAsync(CancellationToken cancellationToken = default) if (stateChangeList.Count > 0) { await this.actor.Host.StateProvider.SaveStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateChangeList.AsReadOnly(), cancellationToken); + + if (!ReferenceEquals(stateChangeTracker, this.defaultTracker)) + { + this.InvalidateDefaultTracker(stateChangeList); + } } // Remove the states from tracker whcih were marked for removal. @@ -517,6 +522,21 @@ public Task SetStateContext(string stateContext) return Task.CompletedTask; } + // Writes made through a reentrancy-scoped tracker are invisible to the default + // tracker, which activation, reminders and timers read from. Drop its clean copies + // of the written keys so the next read reloads them instead of serving stale data. + private void InvalidateDefaultTracker(IEnumerable stateChanges) + { + foreach (var stateChange in stateChanges) + { + if (this.defaultTracker.TryGetValue(stateChange.StateName, out var stateMetadata) && + stateMetadata.ChangeKind == StateChangeKind.None) + { + this.defaultTracker.Remove(stateChange.StateName); + } + } + } + private bool IsStateMarkedForRemove(string stateName) { var stateChangeTracker = GetContextualStateTracker(); diff --git a/test/Dapr.Actors.Test/ActorStateManagerTest.cs b/test/Dapr.Actors.Test/ActorStateManagerTest.cs index 60e376c3d..6b3e4177c 100644 --- a/test/Dapr.Actors.Test/ActorStateManagerTest.cs +++ b/test/Dapr.Actors.Test/ActorStateManagerTest.cs @@ -127,6 +127,38 @@ public async Task StateRemoveAddTTL() await Assert.ThrowsAsync(() => mngr.GetStateAsync("key2", token)); } + [Fact] + public async Task ReentrantSaveInvalidatesDefaultTracker() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = new CancellationToken(); + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"value1\"", null))); + interactor + .Setup(d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // A read outside any reentrancy context caches the value in the default tracker. + Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); + + // A reentrancy-scoped call writes the same key through its own tracker. + await mngr.SetStateContext("ctx1"); + await mngr.SetStateAsync("key1", "value2", token); + await mngr.SaveStateAsync(token); + await mngr.SetStateContext(null); + + // The default tracker must reload the key instead of serving its stale copy. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"value2\"", null))); + Assert.Equal("value2", await mngr.GetStateAsync("key1", token)); + } + [Fact] public async Task StateDaprdExpireTime() { From d058c930b0f31e992ad98e255202e41341c4dcab Mon Sep 17 00:00:00 2001 From: joshvanl Date: Wed, 23 Sep 2026 14:50:17 -0300 Subject: [PATCH 2/2] Adds e2e tests Signed-off-by: joshvanl --- .../Reentrancy/IReentrantStateActor.cs | 32 ++++++++ .../Actors/ReentrantStateActor.cs | 76 +++++++++++++++++++ .../Startup.cs | 11 ++- .../Actors/E2ETests.ReentrantTests.cs | 37 ++++++++- 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 test/Dapr.E2E.Test.Actors/Reentrancy/IReentrantStateActor.cs create mode 100644 test/Dapr.E2E.Test.App.ReentrantActor/Actors/ReentrantStateActor.cs diff --git a/test/Dapr.E2E.Test.Actors/Reentrancy/IReentrantStateActor.cs b/test/Dapr.E2E.Test.Actors/Reentrancy/IReentrantStateActor.cs new file mode 100644 index 000000000..3129669de --- /dev/null +++ b/test/Dapr.E2E.Test.Actors/Reentrancy/IReentrantStateActor.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.E2E.Test.Actors.Reentrancy; + +public interface IReentrantStateActor : IPingActor, IActor +{ + // Writes the shared key from an ordinary method call. With reentrancy + // enabled for the type, this runs on a reentrancy-scoped state tracker. + Task SetValue(string value); + + // Registers a reminder whose callback reads the shared key. The callback + // runs on the default state tracker, because no reentrancy id reaches it. + Task StartReminder(); + + // Returns the value the reminder callback read, or an empty string if the + // reminder has not fired yet. + Task GetValueSeenByReminder(); +} diff --git a/test/Dapr.E2E.Test.App.ReentrantActor/Actors/ReentrantStateActor.cs b/test/Dapr.E2E.Test.App.ReentrantActor/Actors/ReentrantStateActor.cs new file mode 100644 index 000000000..2ee0f4f56 --- /dev/null +++ b/test/Dapr.E2E.Test.App.ReentrantActor/Actors/ReentrantStateActor.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.E2E.Test.Actors.Reentrancy; + +/// +/// Reproduces dapr/dapr#10532. Activation writes the shared key on the default +/// state tracker. An ordinary method call then writes the same key on its own +/// reentrancy-scoped tracker. A reminder callback, which also runs on the +/// default tracker, must observe that later write rather than the value cached +/// during activation. +/// +public class ReentrantStateActor : Actor, IReentrantStateActor, IRemindable +{ + private const string SharedKey = "reentrant-shared-value"; + private const string SeenKey = "reentrant-value-seen-by-reminder"; + private const string ReminderName = "reentrant-state-reminder"; + + public ReentrantStateActor(ActorHost host) + : base(host) + { + } + + public Task Ping() + { + return Task.CompletedTask; + } + + // Caches the shared key on the default tracker, which is what later goes stale. + protected override async Task OnActivateAsync() + { + await this.StateManager.SetStateAsync(SharedKey, "activation"); + await this.StateManager.SaveStateAsync(); + } + + public async Task SetValue(string value) + { + await this.StateManager.SetStateAsync(SharedKey, value); + } + + public Task StartReminder() + { + return this.RegisterReminderAsync(ReminderName, Array.Empty(), dueTime: TimeSpan.Zero, period: TimeSpan.FromMilliseconds(500)); + } + + public async Task GetValueSeenByReminder() + { + var seen = await this.StateManager.TryGetStateAsync(SeenKey); + return seen.HasValue ? seen.Value : string.Empty; + } + + public async Task ReceiveReminderAsync(string reminderName, byte[] state, TimeSpan dueTime, TimeSpan period) + { + if (reminderName != ReminderName) + { + return; + } + + var shared = await this.StateManager.GetStateAsync(SharedKey); + await this.StateManager.SetStateAsync(SeenKey, shared); + } +} diff --git a/test/Dapr.E2E.Test.App.ReentrantActor/Startup.cs b/test/Dapr.E2E.Test.App.ReentrantActor/Startup.cs index bffc65918..1ae21e2d2 100644 --- a/test/Dapr.E2E.Test.App.ReentrantActor/Startup.cs +++ b/test/Dapr.E2E.Test.App.ReentrantActor/Startup.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------ +// ------------------------------------------------------------------------ // Copyright 2021 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -36,6 +36,13 @@ public void ConfigureServices(IServiceCollection services) Enabled = true, } }); + options.Actors.RegisterActor(typeOptions: new() + { + ReentrancyConfig = new() + { + Enabled = true, + } + }); }); } @@ -54,4 +61,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapActorsHandlers(); }); } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/Actors/E2ETests.ReentrantTests.cs b/test/Dapr.E2E.Test/Actors/E2ETests.ReentrantTests.cs index b8f63e466..008a6f999 100644 --- a/test/Dapr.E2E.Test/Actors/E2ETests.ReentrantTests.cs +++ b/test/Dapr.E2E.Test/Actors/E2ETests.ReentrantTests.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------ +// ------------------------------------------------------------------------ // Copyright 2021 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -74,4 +74,39 @@ public async Task ActorCanPerformReentrantCalls() } } } + + // Regression test for dapr/dapr#10532. With reentrancy enabled, an ordinary + // method call writes state through its own reentrancy-scoped tracker, while a + // reminder callback reads through the default tracker. The callback must see + // the method's write, not the value cached during activation. + [Fact] + public async Task ReminderSeesStateWrittenByReentrantCall() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var proxy = this.ProxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReentrantStateActor"); + + await ActorRuntimeChecker.WaitForActorRuntimeAsync(this.AppId, this.Output, proxy, cts.Token); + + // Activation has already cached the shared key on the default tracker. + // This call rewrites it through a reentrancy-scoped tracker. + await proxy.SetValue("written-by-method"); + await proxy.StartReminder(); + + string seen; + while (true) + { + cts.Token.ThrowIfCancellationRequested(); + + seen = await proxy.GetValueSeenByReminder(); + this.Output.WriteLine($"Value seen by reminder: '{seen}'"); + if (!string.IsNullOrEmpty(seen)) + { + break; + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cts.Token); + } + + Assert.Equal("written-by-method", seen); + } }