Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Dapr.Actors/Runtime/ActorStateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<ActorStateChange> 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();
Expand Down
32 changes: 32 additions & 0 deletions test/Dapr.Actors.Test/ActorStateManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,38 @@ public async Task StateRemoveAddTTL()
await Assert.ThrowsAsync<KeyNotFoundException>(() => mngr.GetStateAsync<string>("key2", token));
}

[Fact]
public async Task ReentrantSaveInvalidatesDefaultTracker()
{
var interactor = new Mock<TestDaprInteractor>();
var host = ActorHost.CreateForTest<TestActor>();
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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(new ActorStateResponse<string>("\"value1\"", null)));
interactor
.Setup(d => d.SaveStateTransactionallyAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);

// A read outside any reentrancy context caches the value in the default tracker.
Assert.Equal("value1", await mngr.GetStateAsync<string>("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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(new ActorStateResponse<string>("\"value2\"", null)));
Assert.Equal("value2", await mngr.GetStateAsync<string>("key1", token));
}

[Fact]
public async Task StateDaprdExpireTime()
{
Expand Down
32 changes: 32 additions & 0 deletions test/Dapr.E2E.Test.Actors/Reentrancy/IReentrantStateActor.cs
Original file line number Diff line number Diff line change
@@ -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<string> GetValueSeenByReminder();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<byte>(), dueTime: TimeSpan.Zero, period: TimeSpan.FromMilliseconds(500));
}

public async Task<string> GetValueSeenByReminder()
{
var seen = await this.StateManager.TryGetStateAsync<string>(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<string>(SharedKey);
await this.StateManager.SetStateAsync(SeenKey, shared);
}
}
11 changes: 9 additions & 2 deletions test/Dapr.E2E.Test.App.ReentrantActor/Startup.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -36,6 +36,13 @@ public void ConfigureServices(IServiceCollection services)
Enabled = true,
}
});
options.Actors.RegisterActor<ReentrantStateActor>(typeOptions: new()
{
ReentrancyConfig = new()
{
Enabled = true,
}
});
});
}

Expand All @@ -54,4 +61,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
endpoints.MapActorsHandlers();
});
}
}
}
37 changes: 36 additions & 1 deletion test/Dapr.E2E.Test/Actors/E2ETests.ReentrantTests.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<IReentrantStateActor>(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);
}
}
Loading