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() { 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); + } }