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..adad5ba8d --- /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 = new 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"); + + // 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 = new CancellationToken(); + + await mngr.AddStateAsync("key", "value", token); + // Not yet saved, so is modified + await Assert.ThrowsAsync(() => mngr.UnloadStateAsync("key")); + + // Should not throw if allowed + await mngr.UnloadStateAsync("key", new UnloadStateOptions { AllowUnloadingWhenStateModified = true }); + } + } +}