From e49f831ec81386d78a2512b72b42f807f89791df Mon Sep 17 00:00:00 2001 From: Oliver Tomlinson Date: Wed, 11 Mar 2026 22:31:21 +0000 Subject: [PATCH 1/2] Implementation of UnloadStateAsyc Implementation of UnloadStateAsyc (https://github.com/dapr/dotnet-sdk/issues/1728) Signed-off-by: Oliver Tomlinson --- src/Dapr.Actors/Runtime/ActorStateManager.cs | 33 ++++++- src/Dapr.Actors/Runtime/IActorStateManager.cs | 9 ++ src/Dapr.Actors/Runtime/UnloadStateOptions.cs | 14 +++ .../ActorStateManagerUnloadStateTest.cs | 85 +++++++++++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/Dapr.Actors/Runtime/UnloadStateOptions.cs create mode 100644 test/Dapr.Actors.Test/ActorStateManagerUnloadStateTest.cs diff --git a/src/Dapr.Actors/Runtime/ActorStateManager.cs b/src/Dapr.Actors/Runtime/ActorStateManager.cs index 277f6c4c8..032918150 100644 --- a/src/Dapr.Actors/Runtime/ActorStateManager.cs +++ b/src/Dapr.Actors/Runtime/ActorStateManager.cs @@ -35,6 +35,29 @@ internal ActorStateManager(Actor actor) this.defaultTracker = new Dictionary(); } + public Task UnloadStateAsync(string stateName, UnloadStateOptions options = null, CancellationToken cancellationToken = default) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); + EnsureStateProviderInitialized(); + + var stateChangeTracker = GetContextualStateTracker(); + if (!stateChangeTracker.ContainsKey(stateName)) + { + // Nothing to unload from memory + return Task.CompletedTask; + } + + var stateMetadata = stateChangeTracker[stateName]; + bool isModified = stateMetadata.ChangeKind == StateChangeKind.Add || stateMetadata.ChangeKind == StateChangeKind.Update || stateMetadata.ChangeKind == StateChangeKind.Remove; + if (isModified && (options == null || !options.AllowUnloadingWhenStateModified)) + { + throw new InvalidOperationException($"Cannot unload state '{stateName}' because it has been modified and not yet persisted. Set AllowUnloadingWhenStateModified to true to override."); + } + + stateChangeTracker.Remove(stateName); + return Task.CompletedTask; + } + public async Task AddStateAsync(string stateName, T value, CancellationToken cancellationToken) { EnsureStateProviderInitialized(); @@ -543,12 +566,16 @@ private StateMetadata(object value, Type type, StateChangeKind changeKind, DateT this.Type = type; this.ChangeKind = changeKind; - if (ttlExpireTime.HasValue && ttl.HasValue) { + if (ttlExpireTime.HasValue && ttl.HasValue) + { throw new ArgumentException("Cannot specify both TTLExpireTime and TTL"); } - if (ttl.HasValue) { + if (ttl.HasValue) + { this.TTLExpireTime = DateTimeOffset.UtcNow.Add(ttl.Value); - } else { + } + else + { this.TTLExpireTime = ttlExpireTime; } } diff --git a/src/Dapr.Actors/Runtime/IActorStateManager.cs b/src/Dapr.Actors/Runtime/IActorStateManager.cs index 88868d854..38b8b5bee 100644 --- a/src/Dapr.Actors/Runtime/IActorStateManager.cs +++ b/src/Dapr.Actors/Runtime/IActorStateManager.cs @@ -24,6 +24,15 @@ namespace Dapr.Actors.Runtime; /// public interface IActorStateManager { + /// + /// Unloads the specified state from the in-memory cache/tracker, but does not remove it from the underlying store. + /// + /// Name of the actor state to unload. + /// Options for unloading state (e.g., allow unloading modified state). + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous unload operation. + /// Thrown if the state is modified and not yet persisted, unless allowed by options. + Task UnloadStateAsync(string stateName, UnloadStateOptions options = null, CancellationToken cancellationToken = default); /// /// Adds an actor state with given state name. /// diff --git a/src/Dapr.Actors/Runtime/UnloadStateOptions.cs b/src/Dapr.Actors/Runtime/UnloadStateOptions.cs new file mode 100644 index 000000000..4892b363b --- /dev/null +++ b/src/Dapr.Actors/Runtime/UnloadStateOptions.cs @@ -0,0 +1,14 @@ +// Options for UnloadStateAsync operation +namespace Dapr.Actors.Runtime +{ + /// + /// Options for the UnloadStateAsync operation on ActorStateManager. + /// + public class UnloadStateOptions + { + /// + /// If true, allows unloading state even if it is modified and not yet persisted. + /// + public bool AllowUnloadingWhenStateModified { get; set; } = false; + } +} diff --git a/test/Dapr.Actors.Test/ActorStateManagerUnloadStateTest.cs b/test/Dapr.Actors.Test/ActorStateManagerUnloadStateTest.cs new file mode 100644 index 000000000..489ba7c7b --- /dev/null +++ b/test/Dapr.Actors.Test/ActorStateManagerUnloadStateTest.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright 2023 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; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; +using Dapr.Actors.Communication; +using Moq; +using Xunit; + +namespace Dapr.Actors.Test +{ + public class ActorStateManagerUnloadStateTest + { + [Fact] + public async Task UnloadState_RemovesFromMemoryButNotStore() + { + var interactor = new Moq.Mock(); + // Simulate state existence only after SaveStateAsync + bool stateSaved = false; + interactor.Setup(d => d.GetStateAsync( + Moq.It.IsAny(), + Moq.It.IsAny(), + Moq.It.Is(key => key == "big-data"), + Moq.It.IsAny())) + .ReturnsAsync(() => + stateSaved + ? new Dapr.Actors.Communication.ActorStateResponse("\"payload\"", null) + : new Dapr.Actors.Communication.ActorStateResponse("", null)); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new System.Text.Json.JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = TestContext.Current.CancellationToken; + + // Add and save state + await mngr.AddStateAsync("big-data", "payload", token); + await mngr.SaveStateAsync(token); + stateSaved = true; + Assert.Equal("payload", await mngr.GetStateAsync("big-data", token)); + + // Unload from memory + await mngr.UnloadStateAsync("big-data", cancellationToken: token); + + // Should reload from store + interactor.Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Dapr.Actors.Communication.ActorStateResponse("\"payload\"", null)); + Assert.Equal("payload", await mngr.GetStateAsync("big-data", token)); + } + + [Fact] + public async Task UnloadState_ThrowsIfModifiedUnlessAllowed() + { + var interactor = new Moq.Mock(); + // Default: state does not exist + interactor.Setup(d => d.GetStateAsync( + Moq.It.IsAny(), + Moq.It.IsAny(), + Moq.It.IsAny(), + Moq.It.IsAny())) + .ReturnsAsync(new Dapr.Actors.Communication.ActorStateResponse("", null)); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new System.Text.Json.JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = TestContext.Current.CancellationToken; + + await mngr.AddStateAsync("key", "value", token); + // Not yet saved, so is modified + await Assert.ThrowsAsync(() => mngr.UnloadStateAsync("key", cancellationToken: token)); + + // Should not throw if allowed + await mngr.UnloadStateAsync("key", new UnloadStateOptions { AllowUnloadingWhenStateModified = true }, token); + } + } +} From 02bece88e6eff872d10b77d3e65d3b3e5ab75aab Mon Sep 17 00:00:00 2001 From: Oliver Tomlinson Date: Sat, 9 May 2026 21:48:38 +0100 Subject: [PATCH 2/2] Update UnloadStateOptions.cs Signed-off-by: Oliver Tomlinson --- src/Dapr.Actors/Runtime/UnloadStateOptions.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Dapr.Actors/Runtime/UnloadStateOptions.cs b/src/Dapr.Actors/Runtime/UnloadStateOptions.cs index 4892b363b..a03590fb2 100644 --- a/src/Dapr.Actors/Runtime/UnloadStateOptions.cs +++ b/src/Dapr.Actors/Runtime/UnloadStateOptions.cs @@ -1,4 +1,16 @@ -// Options for UnloadStateAsync operation +// ------------------------------------------------------------------------ +// 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. +// 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. +// ------------------------------------------------------------------------ + namespace Dapr.Actors.Runtime { ///