diff --git a/src/Dapr.Actors/IDaprInteractor.cs b/src/Dapr.Actors/IDaprInteractor.cs index 5849328a8..5573b63c0 100644 --- a/src/Dapr.Actors/IDaprInteractor.cs +++ b/src/Dapr.Actors/IDaprInteractor.cs @@ -11,6 +11,8 @@ // limitations under the License. // ------------------------------------------------------------------------ +using System.Collections.Generic; + namespace Dapr.Actors { using System.IO; @@ -45,7 +47,7 @@ internal interface IDaprInteractor Task SaveStateTransactionallyAsync(string actorType, string actorId, string data, CancellationToken cancellationToken = default); /// - /// Saves a state to Dapr. + /// Gets a state from Dapr. /// /// Type of actor. /// ActorId. diff --git a/src/Dapr.Actors/Runtime/ActorStateCache.cs b/src/Dapr.Actors/Runtime/ActorStateCache.cs new file mode 100644 index 000000000..666cb6625 --- /dev/null +++ b/src/Dapr.Actors/Runtime/ActorStateCache.cs @@ -0,0 +1,224 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 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. +// ------------------------------------------------------------------------ + +#nullable enable +using System; +using System.Collections.Generic; + +namespace Dapr.Actors.Runtime; + +internal sealed class ActorStateCache : IActorStateCache +{ + /// + /// Maintains the cache state. + /// + private readonly Dictionary stateMetadata = new(); + + /// + /// Adds the indicated value to the cache. + /// + /// The name of the state. + /// The cached value. + /// How far out the TTL expiry should be. + /// The type of value getting cached. + /// stateContainsKey indicates if the cache already contains the key or not and + /// addedToState indicates if the value was added or updated in the cache. + public (bool stateContainsKey, bool addedToState) Add(string stateName, T value, TimeSpan? ttl = null) + { + if (!stateMetadata.TryGetValue(stateName, out var state)) + { + stateMetadata.Add(stateName, StateMetadata.Create(value, StateChangeKind.Add, ttl)); + return (false, true); + } + + if (!IsMarkedAsRemoveOrExpired(state)) + { + return (true, false); + } + + stateMetadata[stateName] = StateMetadata.Create(value, StateChangeKind.Update, ttl); + return (true, true); + } + + /// + /// Adds the indicated value to the cache. + /// + /// The name of the state. + /// The cached value. + /// The TTL expiry timestamp. + /// The type of value getting cached. + /// stateContainsKey indicates if the cache already contains the key or not and + /// addedToState indicates if the value was added or updated in the cache. + public (bool stateContainsKey, bool addedToState) Add(string stateName, T value, DateTimeOffset ttlExpiry) + { + if (!stateMetadata.TryGetValue(stateName, out var state)) + { + stateMetadata.Add(stateName, StateMetadata.Create(value, StateChangeKind.Add, ttlExpiry)); + return (false, true); + } + + if (!IsMarkedAsRemoveOrExpired(state)) + { + return (true, false); + } + + stateMetadata[stateName] = StateMetadata.Create(value, StateChangeKind.Update, ttlExpiry); + return (true, true); + + } + + /// + /// Sets the cache with the specified value whether it already exists or not. + /// + /// The name of the state to save the value to. + /// The state metadata to save to the cache. + public void Set(string stateName, StateMetadata metadata) + { + stateMetadata[stateName] = metadata; + } + + /// + /// Removes the indicated state name from the cache. + /// + /// The name of the state to remove. + public void Remove(string stateName) => stateMetadata.Remove(stateName); + + /// + /// Retrieves the current state from the cache if available and not expired. + /// + /// The name of the state to retrieve. + /// If available and not expired, the value of the state persisted in the cache. + /// True if the cache contains the state name; false if not. + public (bool containsKey, bool isMarkedAsRemoveOrExpired) TryGet(string stateName, out StateMetadata? metadata) + { + var isMarkedAsRemoveOrExpired = false; + metadata = null; + + if (!stateMetadata.TryGetValue(stateName, out var state)) + { + return (false, false); + } + + if (IsMarkedAsRemoveOrExpired(state)) + { + isMarkedAsRemoveOrExpired = true; + } + + metadata = state; + return (true, isMarkedAsRemoveOrExpired); + + } + + /// + /// Clears the all the data from the cache. + /// + public void Clear() + { + stateMetadata.Clear(); + } + + /// + /// Builds out the change lists of states to update in the provider and states to remove from the cache. This + /// is typically only called by invocation of the SaveStateAsync method in . + /// + /// The list of state changes and states to remove from the cache. + public (IReadOnlyList stateChanges, IReadOnlyList statesToRemove) BuildChangeList() + { + var stateChanges = new List(); + var statesToRemove = new List(); + + if (stateMetadata.Count == 0) + { + return (stateChanges, statesToRemove); + } + + foreach (var stateName in stateMetadata.Keys) + { + var metadata = stateMetadata[stateName]; + if (metadata.ChangeKind is not StateChangeKind.None) + { + stateChanges.Add(new ActorStateChange(stateName, metadata.Type, metadata.Value, metadata.ChangeKind, metadata.TTLExpireTime)); + + if (metadata.ChangeKind is StateChangeKind.Remove) + { + statesToRemove.Add(stateName); + } + + //Mark the states as unmodified so the tracking for the next invocation is done correctly + var updatedState = metadata with { ChangeKind = StateChangeKind.None }; + stateMetadata[stateName] = updatedState; + } + } + + return (stateChanges, statesToRemove); + } + + /// + /// Helper method that determines if a state metadata is expired. + /// + /// The metadata to evaluate. + /// True if the state metadata is marked for removal or the TTL has expired, otherwise false. + public bool IsMarkedAsRemoveOrExpired(StateMetadata metadata) => + metadata.ChangeKind == StateChangeKind.Remove || (metadata.TTLExpireTime.HasValue && + metadata.TTLExpireTime.Value <= DateTimeOffset.UtcNow); + + /// + /// Exposed for testing only. + /// + /// + internal Dictionary GetStateMetadata() => stateMetadata; + + internal sealed record StateMetadata + { + /// + /// This should only be used for testing purposes. Use the static `Create` methods for any actual usage. + /// + /// + /// + /// + /// + /// + /// + internal StateMetadata(object? value, Type type, StateChangeKind changeKind, DateTimeOffset? ttlExpireTime = null, TimeSpan? ttl = null) + { + this.Value = value; + this.Type = type; + this.ChangeKind = changeKind; + + if (ttlExpireTime.HasValue && ttl.HasValue) { + throw new ArgumentException("Cannot specify both TTLExpireTime and TTL"); + } + + this.TTLExpireTime = ttl.HasValue ? DateTimeOffset.UtcNow.Add(ttl.Value) : ttlExpireTime; + } + + public object? Value { get; init; } + + public StateChangeKind ChangeKind { get; init; } + + public Type Type { get; init; } + + public DateTimeOffset? TTLExpireTime { get; init; } + + public static StateMetadata Create(T? value, StateChangeKind changeKind) => + new(value, typeof(T), changeKind); + + public static StateMetadata Create(T? value, StateChangeKind changeKind, DateTimeOffset? ttlExpireTime) => + new(value, typeof(T), changeKind, ttlExpireTime: ttlExpireTime); + + public static StateMetadata Create(T? value, StateChangeKind changeKind, TimeSpan? ttl) => + new(value, typeof(T), changeKind, ttl: ttl); + + public static StateMetadata CreateForRemove() => new(null, typeof(object), StateChangeKind.Remove); + } +} diff --git a/src/Dapr.Actors/Runtime/ActorStateChange.cs b/src/Dapr.Actors/Runtime/ActorStateChange.cs index 34fa68fdf..f90338f6d 100644 --- a/src/Dapr.Actors/Runtime/ActorStateChange.cs +++ b/src/Dapr.Actors/Runtime/ActorStateChange.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2021 The Dapr Authors +// Copyright 2025 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 @@ -11,75 +11,22 @@ // limitations under the License. // ------------------------------------------------------------------------ -namespace Dapr.Actors.Runtime -{ - using System; - - /// - /// Represents a change to an actor state with a given state name. - /// - public sealed class ActorStateChange - { - /// - /// Initializes a new instance of the class. - /// - /// The name of the actor state. - /// The type of value associated with given actor state name. - /// The value associated with given actor state name. - /// The kind of state change for given actor state name. - /// The time to live for the state. - public ActorStateChange(string stateName, Type type, object value, StateChangeKind changeKind, DateTimeOffset? ttlExpireTime) - { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - this.StateName = stateName; - this.Type = type; - this.Value = value; - this.ChangeKind = changeKind; - this.TTLExpireTime = ttlExpireTime; - } - - /// - /// Gets the name of the actor state. - /// - /// - /// The name of the actor state. - /// - public string StateName { get; } - - /// - /// Gets the type of value associated with given actor state name. - /// - /// - /// The type of value associated with given actor state name. - /// - public Type Type { get; } - - /// - /// Gets the value associated with given actor state name. - /// - /// - /// The value associated with given actor state name. - /// - public object Value { get; } - - /// - /// Gets the kind of state change for given actor state name. - /// - /// - /// The kind of state change for given actor state name. - /// - public StateChangeKind ChangeKind { get; } - - /// - /// Gets the time to live for the state. - /// - /// - /// The time to live for the state. - /// - /// - /// If null, the state will not expire. - /// - public DateTimeOffset? TTLExpireTime { get; } - } -} +#nullable enable +namespace Dapr.Actors.Runtime; + +using System; + +/// +/// Represents a change to an actor state with a given state name. +/// +/// The name of the actor state. +/// The type of value associated with the given actor state name. +/// The value associated with the given actor state name. +/// The kind of state change for the given actor state name. +/// The time to live for the state. If null, the state wil not expire. +public sealed record ActorStateChange( + string StateName, + Type Type, + object? Value, + StateChangeKind ChangeKind, + DateTimeOffset? TTLExpireTime); diff --git a/src/Dapr.Actors/Runtime/ActorStateManager.cs b/src/Dapr.Actors/Runtime/ActorStateManager.cs index 31ada4433..b496f1589 100644 --- a/src/Dapr.Actors/Runtime/ActorStateManager.cs +++ b/src/Dapr.Actors/Runtime/ActorStateManager.cs @@ -19,567 +19,458 @@ using Dapr.Actors.Resources; using Dapr.Actors.Communication; -namespace Dapr.Actors.Runtime +namespace Dapr.Actors.Runtime; + +internal sealed class ActorStateManager : IActorStateManager, IActorContextualState { - internal sealed class ActorStateManager : IActorStateManager, IActorContextualState + private readonly Actor actor; + private readonly string actorTypeName; + private readonly IActorStateCache defaultCache; + private static readonly AsyncLocal<(string id, IActorStateCache stateCache)> context = new(); + + internal ActorStateManager(Actor actor) { - private readonly Actor actor; - private readonly string actorTypeName; - private readonly Dictionary defaultTracker; - private static AsyncLocal<(string id, Dictionary tracker)> context = new AsyncLocal<(string, Dictionary)>(); + this.actor = actor; + this.actorTypeName = actor.Host.ActorTypeInfo.ActorTypeName; + this.defaultCache = new ActorStateCache(); + } - internal ActorStateManager(Actor actor) - { - this.actor = actor; - this.actorTypeName = actor.Host.ActorTypeInfo.ActorTypeName; - this.defaultTracker = new Dictionary(); - } + internal ActorStateManager(Actor actor, IActorStateCache stateCache) + { + this.actor = actor; + this.actorTypeName = actor.Host.ActorTypeInfo.ActorTypeName; + this.defaultCache = stateCache; + } - public async Task AddStateAsync(string stateName, T value, CancellationToken cancellationToken) - { - EnsureStateProviderInitialized(); + public async Task AddStateAsync(string stateName, T value, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - if (!(await this.TryAddStateAsync(stateName, value, cancellationToken))) - { - throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, SR.ActorStateAlreadyExists, stateName)); - } + if (!(await this.TryAddStateAsync(stateName, value, cancellationToken))) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, SR.ActorStateAlreadyExists, stateName)); } + } - public async Task AddStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken) - { - EnsureStateProviderInitialized(); + public async Task AddStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - if (!(await this.TryAddStateAsync(stateName, value, ttl, cancellationToken))) - { - throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, SR.ActorStateAlreadyExists, stateName)); - } + if (!(await this.TryAddStateAsync(stateName, value, ttl, cancellationToken))) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, SR.ActorStateAlreadyExists, stateName)); } + } - public async Task TryAddStateAsync(string stateName, T value, CancellationToken cancellationToken = default) - { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); + public async Task TryAddStateAsync(string stateName, T value, CancellationToken cancellationToken = default) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - EnsureStateProviderInitialized(); + EnsureStateProviderInitialized(); - var stateChangeTracker = GetContextualStateTracker(); + var cache = GetContextualStateTracker(); + var (stateContainsKey, addedToState) = cache.Add(stateName, value); + if (stateContainsKey) + { + return addedToState; + } - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; + var containsStateResult = await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, + this.actor.Id.ToString(), stateName, cancellationToken); + if (containsStateResult) + { + //Return false because we shouldn't add a value already present in the provider + return false; + } - // Check if the property was marked as remove or is expired in the cache - if (stateMetadata.ChangeKind == StateChangeKind.Remove || (stateMetadata.TTLExpireTime.HasValue && stateMetadata.TTLExpireTime.Value <= DateTimeOffset.UtcNow)) - { - stateChangeTracker[stateName] = StateMetadata.Create(value, StateChangeKind.Update); - return true; - } + //Add to the cache + cache.Set(stateName, ActorStateCache.StateMetadata.Create(value, StateChangeKind.Add)); + return addedToState; + } - return false; - } + public async Task TryAddStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken = default) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) - { - return false; - } + EnsureStateProviderInitialized(); - stateChangeTracker[stateName] = StateMetadata.Create(value, StateChangeKind.Add); - return true; + var cache = GetContextualStateTracker(); + var (stateContainsKey, addedToState) = cache.Add(stateName, value, ttl); + if (stateContainsKey) + { + return addedToState; } - - public async Task TryAddStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken = default) + + if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - EnsureStateProviderInitialized(); - - var stateChangeTracker = GetContextualStateTracker(); - - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; + return false; + } - // Check if the property was marked as remove in the cache or has been expired. - if (stateMetadata.ChangeKind == StateChangeKind.Remove || (stateMetadata.TTLExpireTime.HasValue && stateMetadata.TTLExpireTime.Value <= DateTimeOffset.UtcNow)) - { - stateChangeTracker[stateName] = StateMetadata.Create(value, StateChangeKind.Update, ttl: ttl); - return true; - } + //Add to the cache + cache.Set(stateName, ActorStateCache.StateMetadata.Create(value, StateChangeKind.Add, ttl)); + return addedToState; + } - return false; - } + public async Task GetStateAsync(string stateName, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) - { - return false; - } + var condRes = await this.TryGetStateAsync(stateName, cancellationToken); - stateChangeTracker[stateName] = StateMetadata.Create(value, StateChangeKind.Add, ttl: ttl); - return true; + if (condRes.HasValue) + { + return condRes.Value; } - public async Task GetStateAsync(string stateName, CancellationToken cancellationToken) - { - EnsureStateProviderInitialized(); + throw new KeyNotFoundException(string.Format(CultureInfo.CurrentCulture, SR.ErrorNamedActorStateNotFound, stateName)); + } - var condRes = await this.TryGetStateAsync(stateName, cancellationToken); + public async Task> TryGetStateAsync(string stateName, CancellationToken cancellationToken) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - if (condRes.HasValue) - { - return condRes.Value; - } + EnsureStateProviderInitialized(); - throw new KeyNotFoundException(string.Format(CultureInfo.CurrentCulture, SR.ErrorNamedActorStateNotFound, stateName)); + var stateChangeTracker = GetContextualStateTracker(); + var getCacheValue = stateChangeTracker.TryGet(stateName, out var state); + if (getCacheValue.containsKey) + { + return getCacheValue.isMarkedAsRemoveOrExpired + ? new ConditionalValue(false, default) + : new ConditionalValue(true, (T)state!.Value); } - - public async Task> TryGetStateAsync(string stateName, CancellationToken cancellationToken) + + var conditionalResult = await this.TryGetStateFromStateProviderAsync(stateName, cancellationToken); + if (conditionalResult.HasValue) { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - EnsureStateProviderInitialized(); + var stateMetadata = ActorStateCache.StateMetadata.Create(conditionalResult.Value.Value, + StateChangeKind.None, conditionalResult.Value.TTLExpireTime); + stateChangeTracker.Add(stateName, stateMetadata); + return new ConditionalValue(true, conditionalResult.Value.Value); + } - var stateChangeTracker = GetContextualStateTracker(); + return new ConditionalValue(false, default); + } - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; + public async Task SetStateAsync(string stateName, T value, CancellationToken cancellationToken) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - // Check if the property was marked as remove in the cache or is expired - if (stateMetadata.ChangeKind == StateChangeKind.Remove || (stateMetadata.TTLExpireTime.HasValue && stateMetadata.TTLExpireTime.Value <= DateTimeOffset.UtcNow)) - { - return new ConditionalValue(false, default); - } + EnsureStateProviderInitialized(); - return new ConditionalValue(true, (T)stateMetadata.Value); - } - - var conditionalResult = await this.TryGetStateFromStateProviderAsync(stateName, cancellationToken); - if (conditionalResult.HasValue) + var stateChangeTracker = GetContextualStateTracker(); + var (cacheContainsKey, _) = stateChangeTracker.TryGet(stateName, out var state); + if (cacheContainsKey && state is not null) + { + var updatedState = state with { Value = value, TTLExpireTime = null }; + if (state.ChangeKind is StateChangeKind.None or StateChangeKind.Remove) { - stateChangeTracker.Add(stateName, StateMetadata.Create(conditionalResult.Value.Value, StateChangeKind.None, ttlExpireTime: conditionalResult.Value.TTLExpireTime)); - return new ConditionalValue(true, conditionalResult.Value.Value); + updatedState = updatedState with { ChangeKind = StateChangeKind.Update }; } - return new ConditionalValue(false, default); + stateChangeTracker.Set(stateName, updatedState); } - - public async Task SetStateAsync(string stateName, T value, CancellationToken cancellationToken) + else if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), + stateName, cancellationToken)) { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - EnsureStateProviderInitialized(); - - var stateChangeTracker = GetContextualStateTracker(); - - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; - stateMetadata.Value = value; - stateMetadata.TTLExpireTime = null; - - if (stateMetadata.ChangeKind == StateChangeKind.None || - stateMetadata.ChangeKind == StateChangeKind.Remove) - { - stateMetadata.ChangeKind = StateChangeKind.Update; - } - } - else if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) - { - stateChangeTracker.Add(stateName, StateMetadata.Create(value, StateChangeKind.Update)); - } - else - { - stateChangeTracker[stateName] = StateMetadata.Create(value, StateChangeKind.Add); - } + stateChangeTracker.Add(stateName, ActorStateCache.StateMetadata.Create(value, StateChangeKind.Update)); } - - public async Task SetStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken) + else { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(value, StateChangeKind.Add)); + } + } - EnsureStateProviderInitialized(); + public async Task SetStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - var stateChangeTracker = GetContextualStateTracker(); + EnsureStateProviderInitialized(); - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; - stateMetadata.Value = value; - stateMetadata.TTLExpireTime = DateTimeOffset.UtcNow.Add(ttl); - - if (stateMetadata.ChangeKind == StateChangeKind.None || - stateMetadata.ChangeKind == StateChangeKind.Remove) - { - stateMetadata.ChangeKind = StateChangeKind.Update; - } - } - else if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) - { - stateChangeTracker.Add(stateName, StateMetadata.Create(value, StateChangeKind.Update, ttl: ttl)); - } - else + var stateChangeTracker = GetContextualStateTracker(); + var getCacheValue = stateChangeTracker.TryGet(stateName, out var state); + if (getCacheValue.containsKey && state is not null) + { + var updatedState = state with { Value = state.Value, TTLExpireTime = DateTimeOffset.UtcNow.Add(ttl) }; + if (updatedState.ChangeKind is StateChangeKind.None or StateChangeKind.Remove) { - stateChangeTracker[stateName] = StateMetadata.Create(value, StateChangeKind.Add, ttl: ttl); + updatedState = updatedState with { ChangeKind = StateChangeKind.Update }; } + stateChangeTracker.Set(stateName, updatedState); } - - public async Task RemoveStateAsync(string stateName, CancellationToken cancellationToken) + else if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), + stateName, cancellationToken)) { - EnsureStateProviderInitialized(); - - if (!(await this.TryRemoveStateAsync(stateName, cancellationToken))) - { - throw new KeyNotFoundException(string.Format(CultureInfo.CurrentCulture, SR.ErrorNamedActorStateNotFound, stateName)); - } + stateChangeTracker.Add(stateName, ActorStateCache.StateMetadata.Create(value, StateChangeKind.Update, ttl)); } - - public async Task TryRemoveStateAsync(string stateName, CancellationToken cancellationToken) + else { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - EnsureStateProviderInitialized(); - - var stateChangeTracker = GetContextualStateTracker(); - - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; - - if (stateMetadata.TTLExpireTime.HasValue && stateMetadata.TTLExpireTime.Value <= DateTimeOffset.UtcNow) - { - stateChangeTracker.Remove(stateName); - return false; - } - - switch (stateMetadata.ChangeKind) - { - case StateChangeKind.Remove: - return false; - case StateChangeKind.Add: - stateChangeTracker.Remove(stateName); - return true; - } - - stateMetadata.ChangeKind = StateChangeKind.Remove; - return true; - } + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(value, StateChangeKind.Add, ttl)); + } + } - if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) - { - stateChangeTracker.Add(stateName, StateMetadata.CreateForRemove()); - return true; - } + public async Task RemoveStateAsync(string stateName, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - return false; + if (!(await this.TryRemoveStateAsync(stateName, cancellationToken))) + { + throw new KeyNotFoundException(string.Format(CultureInfo.CurrentCulture, SR.ErrorNamedActorStateNotFound, stateName)); } + } - public async Task ContainsStateAsync(string stateName, CancellationToken cancellationToken) - { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); + public async Task TryRemoveStateAsync(string stateName, CancellationToken cancellationToken) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - EnsureStateProviderInitialized(); + EnsureStateProviderInitialized(); - var stateChangeTracker = GetContextualStateTracker(); + var stateChangeTracker = GetContextualStateTracker(); - if (stateChangeTracker.ContainsKey(stateName)) + var cacheGetResult = stateChangeTracker.TryGet(stateName, out var state); + if (cacheGetResult.containsKey && state is not null) + { + if (cacheGetResult.isMarkedAsRemoveOrExpired) { - var stateMetadata = stateChangeTracker[stateName]; - - // Check if the property was marked as remove in the cache - return stateMetadata.ChangeKind != StateChangeKind.Remove; + stateChangeTracker.Remove(stateName); + return false; } - if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) + switch (state.ChangeKind) { - return true; + case StateChangeKind.Remove: + return false; + case StateChangeKind.Add: + stateChangeTracker.Remove(stateName); + return true; } - return false; + var updatedState = state with { ChangeKind = StateChangeKind.Remove }; + stateChangeTracker.Set(stateName, updatedState); + return true; } - - public async Task GetOrAddStateAsync(string stateName, T value, CancellationToken cancellationToken) + + if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) { - EnsureStateProviderInitialized(); + stateChangeTracker.Add(stateName, ActorStateCache.StateMetadata.CreateForRemove()); + return true; + } - var condRes = await this.TryGetStateAsync(stateName, cancellationToken); + return false; + } - if (condRes.HasValue) - { - return condRes.Value; - } + public async Task ContainsStateAsync(string stateName, CancellationToken cancellationToken) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - var changeKind = this.IsStateMarkedForRemove(stateName) ? StateChangeKind.Update : StateChangeKind.Add; + EnsureStateProviderInitialized(); - var stateChangeTracker = GetContextualStateTracker(); - stateChangeTracker[stateName] = StateMetadata.Create(value, changeKind); - return value; + var stateChangeTracker = GetContextualStateTracker(); + var getCacheValue = stateChangeTracker.TryGet(stateName, out var state); + if (getCacheValue.containsKey && state is not null) + { + //Check if the property was marked as remove in the cache + return state.ChangeKind != StateChangeKind.Remove; } - public async Task GetOrAddStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken) + if (await this.actor.Host.StateProvider.ContainsStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken)) { - EnsureStateProviderInitialized(); - - var condRes = await this.TryGetStateAsync(stateName, cancellationToken); + return true; + } - if (condRes.HasValue) - { - return condRes.Value; - } + return false; + } - var changeKind = this.IsStateMarkedForRemove(stateName) ? StateChangeKind.Update : StateChangeKind.Add; + public async Task GetOrAddStateAsync(string stateName, T value, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - var stateChangeTracker = GetContextualStateTracker(); - stateChangeTracker[stateName] = StateMetadata.Create(value, changeKind, ttl: ttl); - return value; - } + var condRes = await this.TryGetStateAsync(stateName, cancellationToken); - public async Task AddOrUpdateStateAsync( - string stateName, - T addValue, - Func updateValueFactory, - CancellationToken cancellationToken = default) + if (condRes.HasValue) { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - EnsureStateProviderInitialized(); + return condRes.Value; + } - var stateChangeTracker = GetContextualStateTracker(); + var changeKind = this.IsStateMarkedForRemove(stateName) ? StateChangeKind.Update : StateChangeKind.Add; - if (stateChangeTracker.ContainsKey(stateName)) - { - var stateMetadata = stateChangeTracker[stateName]; + var stateChangeTracker = GetContextualStateTracker(); + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(value, changeKind)); + return value; + } - // Check if the property was marked as remove in the cache - if (stateMetadata.ChangeKind == StateChangeKind.Remove) - { - stateChangeTracker[stateName] = StateMetadata.Create(addValue, StateChangeKind.Update); - return addValue; - } + public async Task GetOrAddStateAsync(string stateName, T value, TimeSpan ttl, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - var newValue = updateValueFactory.Invoke(stateName, (T)stateMetadata.Value); - stateMetadata.Value = newValue; + var condRes = await this.TryGetStateAsync(stateName, cancellationToken); - if (stateMetadata.ChangeKind == StateChangeKind.None) - { - stateMetadata.ChangeKind = StateChangeKind.Update; - } + if (condRes.HasValue) + { + return condRes.Value; + } - return newValue; - } + var changeKind = this.IsStateMarkedForRemove(stateName) ? StateChangeKind.Update : StateChangeKind.Add; - var conditionalResult = await this.TryGetStateFromStateProviderAsync(stateName, cancellationToken); - if (conditionalResult.HasValue) - { - var newValue = updateValueFactory.Invoke(stateName, conditionalResult.Value.Value); - stateChangeTracker.Add(stateName, StateMetadata.Create(newValue, StateChangeKind.Update)); + var stateChangeTracker = GetContextualStateTracker(); + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(value, changeKind, ttl)); + return value; + } - return newValue; - } + public async Task AddOrUpdateStateAsync( + string stateName, + T addValue, + Func updateValueFactory, + CancellationToken cancellationToken = default) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - stateChangeTracker[stateName] = StateMetadata.Create(addValue, StateChangeKind.Add); - return addValue; - } + EnsureStateProviderInitialized(); - public async Task AddOrUpdateStateAsync( - string stateName, - T addValue, - Func updateValueFactory, - TimeSpan ttl, - CancellationToken cancellationToken = default) + var stateChangeTracker = GetContextualStateTracker(); + var getCacheValue = stateChangeTracker.TryGet(stateName, out var state); + if (getCacheValue.containsKey && state is not null) { - ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - - EnsureStateProviderInitialized(); - - var stateChangeTracker = GetContextualStateTracker(); - - if (stateChangeTracker.ContainsKey(stateName)) + //Check if the property was marked as remove in the cache + if (state.ChangeKind == StateChangeKind.Remove) { - var stateMetadata = stateChangeTracker[stateName]; - - // Check if the property was marked as remove in the cache - if (stateMetadata.ChangeKind == StateChangeKind.Remove) - { - stateChangeTracker[stateName] = StateMetadata.Create(addValue, StateChangeKind.Update, ttl: ttl); - return addValue; - } - - var newValue = updateValueFactory.Invoke(stateName, (T)stateMetadata.Value); - stateMetadata.Value = newValue; - - if (stateMetadata.ChangeKind == StateChangeKind.None) - { - stateMetadata.ChangeKind = StateChangeKind.Update; - } - - return newValue; + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(addValue, StateChangeKind.Update)); + return addValue; } - var conditionalResult = await this.TryGetStateFromStateProviderAsync(stateName, cancellationToken); - if (conditionalResult.HasValue) - { - var newValue = updateValueFactory.Invoke(stateName, conditionalResult.Value.Value); - stateChangeTracker.Add(stateName, StateMetadata.Create(newValue, StateChangeKind.Update, ttl: ttl)); + var newValue = updateValueFactory.Invoke(stateName, (T)state.Value); + var updatedState = state with { Value = newValue }; - return newValue; + if (state.ChangeKind == StateChangeKind.None) + { + updatedState = updatedState with { ChangeKind = StateChangeKind.Update }; } - - stateChangeTracker[stateName] = StateMetadata.Create(addValue, StateChangeKind.Add, ttl: ttl); - return addValue; + + stateChangeTracker.Set(stateName, updatedState); + return newValue; } - public Task ClearCacheAsync(CancellationToken cancellationToken) + var conditionalResult = await this.TryGetStateFromStateProviderAsync(stateName, cancellationToken); + if (conditionalResult.HasValue) { - EnsureStateProviderInitialized(); + var newValue = updateValueFactory.Invoke(stateName, conditionalResult.Value.Value); + stateChangeTracker.Add(stateName, ActorStateCache.StateMetadata.Create(newValue, StateChangeKind.Update)); - var stateChangeTracker = GetContextualStateTracker(); - - stateChangeTracker.Clear(); - return Task.CompletedTask; + return newValue; } - public async Task SaveStateAsync(CancellationToken cancellationToken = default) - { - EnsureStateProviderInitialized(); + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(addValue, StateChangeKind.Add)); + return addValue; + } - var stateChangeTracker = GetContextualStateTracker(); + public async Task AddOrUpdateStateAsync( + string stateName, + T addValue, + Func updateValueFactory, + TimeSpan ttl, + CancellationToken cancellationToken = default) + { + ArgumentVerifier.ThrowIfNull(stateName, nameof(stateName)); - if (stateChangeTracker.Count > 0) - { - var stateChangeList = new List(); - var statesToRemove = new List(); - - foreach (var stateName in stateChangeTracker.Keys) - { - var stateMetadata = stateChangeTracker[stateName]; - - if (stateMetadata.ChangeKind != StateChangeKind.None) - { - stateChangeList.Add( - new ActorStateChange(stateName, stateMetadata.Type, stateMetadata.Value, stateMetadata.ChangeKind, stateMetadata.TTLExpireTime)); - - if (stateMetadata.ChangeKind == StateChangeKind.Remove) - { - statesToRemove.Add(stateName); - } - - // Mark the states as unmodified so that tracking for next invocation is done correctly. - stateMetadata.ChangeKind = StateChangeKind.None; - } - } - - if (stateChangeList.Count > 0) - { - await this.actor.Host.StateProvider.SaveStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateChangeList.AsReadOnly(), cancellationToken); - } - - // Remove the states from tracker whcih were marked for removal. - foreach (var stateToRemove in statesToRemove) - { - stateChangeTracker.Remove(stateToRemove); - } - } - } + EnsureStateProviderInitialized(); - public Task SetStateContext(string stateContext) + var stateChangeTracker = GetContextualStateTracker(); + var getCacheValue = stateChangeTracker.TryGet(stateName, out var state); + if (getCacheValue.containsKey && state is not null) { - if (stateContext != null) + if (state.ChangeKind == StateChangeKind.Remove) { - context.Value = (stateContext, new Dictionary()); - } - else - { - context.Value = (null, null); + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(addValue, StateChangeKind.Update, ttl)); + return addValue; } - return Task.CompletedTask; - } + var newValue = updateValueFactory.Invoke(stateName, (T)state.Value); + var updatedState = state with { Value = newValue }; - private bool IsStateMarkedForRemove(string stateName) - { - var stateChangeTracker = GetContextualStateTracker(); - - if (stateChangeTracker.ContainsKey(stateName) && - stateChangeTracker[stateName].ChangeKind == StateChangeKind.Remove) + if (state.ChangeKind == StateChangeKind.None) { - return true; + updatedState = updatedState with { ChangeKind = StateChangeKind.Update }; } + + stateChangeTracker.Set(stateName, updatedState); - return false; + return newValue; } - private Task>> TryGetStateFromStateProviderAsync(string stateName, CancellationToken cancellationToken) + var conditionalResult = await this.TryGetStateFromStateProviderAsync(stateName, cancellationToken); + if (conditionalResult.HasValue) { - EnsureStateProviderInitialized(); - return this.actor.Host.StateProvider.TryLoadStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken); - } + var newValue = updateValueFactory.Invoke(stateName, conditionalResult.Value.Value); + stateChangeTracker.Add(stateName, ActorStateCache.StateMetadata.Create(newValue, StateChangeKind.Update, ttl)); - private void EnsureStateProviderInitialized() - { - if (this.actor.Host.StateProvider == null) - { - throw new InvalidOperationException( - "The actor was initialized without a state provider, and so cannot interact with state. " + - "If this is inside a unit test, replace Actor.StateProvider with a mock."); - } + return newValue; } - private Dictionary GetContextualStateTracker() - { - if (context.Value.id != null) - { - return context.Value.tracker; - } - else - { - return defaultTracker; - } - } - - private sealed class StateMetadata - { - private StateMetadata(object value, Type type, StateChangeKind changeKind, DateTimeOffset? ttlExpireTime = null, TimeSpan? ttl = null) - { - this.Value = value; - this.Type = type; - this.ChangeKind = changeKind; - - if (ttlExpireTime.HasValue && ttl.HasValue) { - throw new ArgumentException("Cannot specify both TTLExpireTime and TTL"); - } - if (ttl.HasValue) { - this.TTLExpireTime = DateTimeOffset.UtcNow.Add(ttl.Value); - } else { - this.TTLExpireTime = ttlExpireTime; - } - } + stateChangeTracker.Set(stateName, ActorStateCache.StateMetadata.Create(addValue, StateChangeKind.Add, ttl)); + return addValue; + } - public object Value { get; set; } + public Task ClearCacheAsync(CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); - public StateChangeKind ChangeKind { get; set; } + var cache = GetContextualStateTracker(); + cache.Clear(); + + return Task.CompletedTask; + } - public Type Type { get; } + public async Task SaveStateAsync(CancellationToken cancellationToken = default) + { + EnsureStateProviderInitialized(); - public DateTimeOffset? TTLExpireTime { get; set; } + var stateChangeTracker = GetContextualStateTracker(); + var (stateChanges, statesToRemove) = stateChangeTracker.BuildChangeList(); - public static StateMetadata Create(T value, StateChangeKind changeKind) + if (stateChanges.Count > 0) + { + await this.actor.Host.StateProvider.SaveStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateChanges, cancellationToken); + } + + //Remove the states from the tracker which were marked for removal + if (statesToRemove.Count > 0) + { + foreach (var stateToRemove in statesToRemove) { - return new StateMetadata(value, typeof(T), changeKind); + stateChangeTracker.Remove(stateToRemove); } + } + } - public static StateMetadata Create(T value, StateChangeKind changeKind, DateTimeOffset? ttlExpireTime) - { - return new StateMetadata(value, typeof(T), changeKind, ttlExpireTime: ttlExpireTime); - } + public Task SetStateContext(string stateContext) + { + context.Value = stateContext != null ? (stateContext, new ActorStateCache()) : (null, null); + return Task.CompletedTask; + } - public static StateMetadata Create(T value, StateChangeKind changeKind, TimeSpan? ttl) - { - return new StateMetadata(value, typeof(T), changeKind, ttl: ttl); - } + private bool IsStateMarkedForRemove(string stateName) + { + var stateChangeTracker = GetContextualStateTracker(); - public static StateMetadata CreateForRemove() - { - return new StateMetadata(null, typeof(object), StateChangeKind.Remove); - } + var getCacheResult = stateChangeTracker.TryGet(stateName, out var state); + return getCacheResult.containsKey && state is not null && state.ChangeKind == StateChangeKind.Remove; + } + + private Task>> TryGetStateFromStateProviderAsync(string stateName, CancellationToken cancellationToken) + { + EnsureStateProviderInitialized(); + return this.actor.Host.StateProvider.TryLoadStateAsync(this.actorTypeName, this.actor.Id.ToString(), stateName, cancellationToken); + } + + private void EnsureStateProviderInitialized() + { + if (this.actor.Host.StateProvider == null) + { + throw new InvalidOperationException( + "The actor was initialized without a state provider, and so cannot interact with state. " + + "If this is inside a unit test, replace Actor.StateProvider with a mock."); } } + + private IActorStateCache GetContextualStateTracker() => context.Value.id != null ? context.Value.stateCache : defaultCache; } diff --git a/src/Dapr.Actors/Runtime/IActorStateCache.cs b/src/Dapr.Actors/Runtime/IActorStateCache.cs new file mode 100644 index 000000000..10439ae8c --- /dev/null +++ b/src/Dapr.Actors/Runtime/IActorStateCache.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 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. +// ------------------------------------------------------------------------ + +#nullable enable +using System; +using System.Collections.Generic; + +namespace Dapr.Actors.Runtime; + +internal interface IActorStateCache +{ + /// + /// Adds the indicated value to the cache. + /// + /// The name of the state. + /// The cached value. + /// How far out the TTL expiry should be. + /// The type of value getting cached. + /// stateContainsKey indicates if the cache already contains the key or not and + /// addedToState indicates if the value was added or updated in the cache. + (bool stateContainsKey, bool addedToState) Add(string stateName, T value, TimeSpan? ttl = null); + + /// + /// Adds the indicated value to the cache. + /// + /// The name of the state. + /// The cached value. + /// The TTL expiry timestamp. + /// The type of value getting cached. + /// stateContainsKey indicates if the cache already contains the key or not and + /// addedToState indicates if the value was added or updated in the cache. + (bool stateContainsKey, bool addedToState) Add(string stateName, T value, DateTimeOffset ttlExpiry); + + /// + /// Sets the cache with the specified value whether it already exists or not. + /// + /// The name of the state to save the value to. + /// The state metadata to save to the cache. + void Set(string stateName, ActorStateCache.StateMetadata metadata); + + /// + /// Removes the indicated state name from the cache. + /// + /// The name of the state to remove. + void Remove(string stateName); + + /// + /// Retrieves the current state from the cache if available and not expired. + /// + /// The name of the state to retrieve. + /// If available and not expired, the value of the state persisted in the cache. + /// True if the cache contains the state name; false if not. + (bool containsKey, bool isMarkedAsRemoveOrExpired) TryGet( + string stateName, + out ActorStateCache.StateMetadata? metadata); + + /// + /// Clears the all the data from the cache. + /// + void Clear(); + + /// + /// Builds out the change lists of states to update in the provider and states to remove from the cache. This + /// is typically only called by invocation of the SaveStateAsync method in . + /// + /// + (IReadOnlyList stateChanges, IReadOnlyList statesToRemove) BuildChangeList(); + + /// + /// Helper method that determines if a state metadata is expired. + /// + /// The metadata to evaluate. + /// True if the state metadata is marked for removal or the TTL has expired, otherwise false. + bool IsMarkedAsRemoveOrExpired(ActorStateCache.StateMetadata metadata); +} diff --git a/test/Dapr.Actors.Test/ActorStateManagerTest.cs b/test/Dapr.Actors.Test/ActorStateManagerTest.cs index a4e0e4140..314efb8d6 100644 --- a/test/Dapr.Actors.Test/ActorStateManagerTest.cs +++ b/test/Dapr.Actors.Test/ActorStateManagerTest.cs @@ -11,182 +11,215 @@ // limitations under the License. // ------------------------------------------------------------------------ -namespace Dapr.Actors.Test +namespace Dapr.Actors.Test; + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using Xunit; +using Dapr.Actors.Communication; +using Dapr.Actors.Runtime; +using Moq; + +/// +/// Contains tests for ActorStateManager. +/// +public sealed class ActorStateManagerTest { - using System; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - using System.Collections.Generic; - using Xunit; - using Dapr.Actors.Communication; - using Dapr.Actors.Runtime; - using Moq; - - /// - /// Contains tests for ActorStateManager. - /// - public class ActorStateManagerTest + [Fact] + public async Task SetGet() { - [Fact] - public async Task SetGet() - { - 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("", null))); - - await mngr.AddStateAsync("key1", "value1", token); - await mngr.AddStateAsync("key2", "value2", token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - - await Assert.ThrowsAsync(() => mngr.AddStateAsync("key1", "value3", token)); - await Assert.ThrowsAsync(() => mngr.AddStateAsync("key2", "value4", token)); - - await mngr.SetStateAsync("key1", "value5", token); - await mngr.SetStateAsync("key2", "value6", token); - Assert.Equal("value5", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value6", await mngr.GetStateAsync("key2", token)); - } - - [Fact] - public async Task StateWithTTL() - { - 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("", null))); - - await mngr.AddStateAsync("key1", "value1", TimeSpan.FromSeconds(1), token); - await mngr.AddStateAsync("key2", "value2", TimeSpan.FromSeconds(1), token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - - await Task.Delay(TimeSpan.FromSeconds(1.5)); - - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key1", token)); - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key2", token)); - - // Should be able to add state again after expiry and should not expire. - await mngr.AddStateAsync("key1", "value1", token); - await mngr.AddStateAsync("key2", "value2", token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - await Task.Delay(TimeSpan.FromSeconds(1.5)); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - } - - [Fact] - public async Task StateRemoveAddTTL() - { - 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("", null))); - - await mngr.AddStateAsync("key1", "value1", TimeSpan.FromSeconds(1), token); - await mngr.AddStateAsync("key2", "value2", TimeSpan.FromSeconds(1), token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - - await mngr.SetStateAsync("key1", "value1", token); - await mngr.SetStateAsync("key2", "value2", token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - - // TTL is removed so state should not expire. - await Task.Delay(TimeSpan.FromSeconds(1.5)); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - - // Adding TTL back should expire state. - await mngr.SetStateAsync("key1", "value1", TimeSpan.FromSeconds(1), token); - await mngr.SetStateAsync("key2", "value2", TimeSpan.FromSeconds(1), token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - await Task.Delay(TimeSpan.FromSeconds(1.5)); - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key1", token)); - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key2", token)); - } - - [Fact] - public async Task StateDaprdExpireTime() - { - 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(); - - // Existing key which has an expiry time. - interactor - .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(new ActorStateResponse("\"value1\"", DateTime.UtcNow.AddSeconds(1)))); - - await Assert.ThrowsAsync(() => mngr.AddStateAsync("key1", "value3", token)); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - - // No longer return the value from the state provider. - interactor - .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(new ActorStateResponse("", null))); - - // Key should be expired after 1 seconds. - await Task.Delay(TimeSpan.FromSeconds(1.5)); - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key1", token)); - await Assert.ThrowsAsync(() => mngr.RemoveStateAsync("key1", token)); - await mngr.AddStateAsync("key1", "value2", TimeSpan.FromSeconds(1), token); - Assert.Equal("value2", await mngr.GetStateAsync("key1", token)); - } - - [Fact] - public async Task RemoveState() - { - 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("", null))); - - await Assert.ThrowsAsync(() => mngr.RemoveStateAsync("key1", token)); - - await mngr.AddStateAsync("key1", "value1", token); - await mngr.AddStateAsync("key2", "value2", token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - - await mngr.RemoveStateAsync("key1", token); - await mngr.RemoveStateAsync("key2", token); - - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key1", token)); - await Assert.ThrowsAsync(() => mngr.GetStateAsync("key2", token)); - - // Should be able to add state again after removal. - await mngr.AddStateAsync("key1", "value1", TimeSpan.FromSeconds(1), token); - await mngr.AddStateAsync("key2", "value2", TimeSpan.FromSeconds(1), token); - Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); - Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); - } + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + const string key1 = "key1"; + const string key2 = "key2"; + const string val1 = "value1"; + const string val2 = "value2"; + const string val3 = "value3"; + const string val4 = "value4"; + const string val5 = "value5"; + const string val6 = "value6"; + + await mngr.AddStateAsync(key1, val1, cts.Token); + await mngr.AddStateAsync(key2, val2, cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + + await Assert.ThrowsAsync(() => mngr.AddStateAsync(key1, val3, cts.Token)); + await Assert.ThrowsAsync(() => mngr.AddStateAsync(key2, val4, cts.Token)); + + await mngr.SetStateAsync(key1, val5, cts.Token); + await mngr.SetStateAsync(key2, val6, cts.Token); + Assert.Equal(val5, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val6, await mngr.GetStateAsync(key2, cts.Token)); } + + [Fact] + public async Task StateWithTTL() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + const string key1 = "key1"; + const string key2 = "key2"; + const string val1 = "value1"; + const string val2 = "value2"; + + await mngr.AddStateAsync(key1, val1, TimeSpan.FromSeconds(1), cts.Token); + await mngr.AddStateAsync(key2, val2, TimeSpan.FromSeconds(1), cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + + await Task.Delay(TimeSpan.FromSeconds(1.5), cts.Token); + + await Assert.ThrowsAsync(() => mngr.GetStateAsync(key1, cts.Token)); + await Assert.ThrowsAsync(() => mngr.GetStateAsync(key2, cts.Token)); + + // Should be able to add state again after expiry and should not expire. + await mngr.AddStateAsync(key1, val1, cts.Token); + await mngr.AddStateAsync(key2, val2, cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + await Task.Delay(TimeSpan.FromSeconds(1.5), cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + } + + [Fact] + public async Task StateRemoveAddTTL() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + const string key1 = "key1"; + const string key2 = "key2"; + const string val1 = "value1"; + const string val2 = "value2"; + + await mngr.AddStateAsync(key1, val1, TimeSpan.FromSeconds(1), cts.Token); + await mngr.AddStateAsync(key2, val2, TimeSpan.FromSeconds(1), cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + + await mngr.SetStateAsync(key1, val1, cts.Token); + await mngr.SetStateAsync(key2, val2, cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + + // TTL is removed so state should not expire. + await Task.Delay(TimeSpan.FromSeconds(1.5), cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + + // Adding TTL back should expire state. + await mngr.SetStateAsync(key1, val1, TimeSpan.FromSeconds(1), cts.Token); + await mngr.SetStateAsync(key2, val2, TimeSpan.FromSeconds(1), cts.Token); + Assert.Equal(val1, await mngr.GetStateAsync(key1, cts.Token)); + Assert.Equal(val2, await mngr.GetStateAsync(key2, cts.Token)); + await Task.Delay(TimeSpan.FromSeconds(1.5), cts.Token); + await Assert.ThrowsAsync(() => mngr.GetStateAsync(key1, cts.Token)); + await Assert.ThrowsAsync(() => mngr.GetStateAsync(key2, cts.Token)); + } + + [Fact] + public async Task ValidateStateExpirationAndExceptions() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + // Existing key which has an expiry time of 1 second - this is triggered on the call to `this.actor.Host.StateProvider.ContainsStateAsync` during `mngr.AddStateAsync` + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"value1\"", DateTime.UtcNow.AddSeconds(1)))); + + await Assert.ThrowsAsync(() => mngr.AddStateAsync("key1", "value3", TimeSpan.FromSeconds(1), cts.Token)); //This is placed before the interactor runs as cache is checked first + Assert.Equal("value3", await mngr.GetStateAsync("key1", cts.Token)); //Validate against the cache value + + // No longer return the value from the state provider. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + // Key should be expired after 1 seconds. + await Task.Delay(TimeSpan.FromSeconds(1.5), cts.Token); + + // While the key will be in the cache, it should no longer be valid as it expired after a second and we've delayed 1.5 seconds + await Assert.ThrowsAsync(() => mngr.GetStateAsync("key1", cts.Token)); + await Assert.ThrowsAsync(() => mngr.RemoveStateAsync("key1", cts.Token)); + await mngr.AddStateAsync("key1", "value2", TimeSpan.FromSeconds(1), cts.Token); + Assert.Equal("value2", await mngr.GetStateAsync("key1", cts.Token)); + } + + [Fact] + public async Task RemoveState() + { + 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("", null))); + + await Assert.ThrowsAsync(() => mngr.RemoveStateAsync("key1", token)); + + await mngr.AddStateAsync("key1", "value1", token); + await mngr.AddStateAsync("key2", "value2", token); + Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); + Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); + + await mngr.RemoveStateAsync("key1", token); + await mngr.RemoveStateAsync("key2", token); + + await Assert.ThrowsAsync(() => mngr.GetStateAsync("key1", token)); + await Assert.ThrowsAsync(() => mngr.GetStateAsync("key2", token)); + + // Should be able to add state again after removal. + await mngr.AddStateAsync("key1", "value1", TimeSpan.FromSeconds(1), token); + await mngr.AddStateAsync("key2", "value2", TimeSpan.FromSeconds(1), token); + Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); + Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); + } + + // [Fact] + // public async Task AddStateAsync_NoTTL() + // { + // var interactor = new Mock(); + // var host = ActorHost.CreateForTest(); + // host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + // var mngr = new ActorStateManager(new TestActor(host)); + // var cts = new CancellationTokenSource(); + // + // + // } } diff --git a/test/Dapr.Actors.Test/Runtime/ActorManagerTests.cs b/test/Dapr.Actors.Test/Runtime/ActorManagerTests.cs index b27e9afe3..e22ff9933 100644 --- a/test/Dapr.Actors.Test/Runtime/ActorManagerTests.cs +++ b/test/Dapr.Actors.Test/Runtime/ActorManagerTests.cs @@ -18,242 +18,241 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace Dapr.Actors.Runtime +namespace Dapr.Actors.Runtime; + +public sealed class ActorManagerTests { - public sealed class ActorManagerTests + private ActorManager CreateActorManager(Type type, ActorActivator activator = null) { - private ActorManager CreateActorManager(Type type, ActorActivator activator = null) - { - var registration = new ActorRegistration(ActorTypeInformation.Get(type, actorTypeName: null)); - var interactor = new DaprHttpInteractor(clientHandler: null, "http://localhost:3500", apiToken: null, requestTimeout: null); - return new ActorManager(registration, activator ?? new DefaultActorActivator(), JsonSerializerDefaults.Web, false, NullLoggerFactory.Instance, ActorProxy.DefaultProxyFactory, interactor); - } + var registration = new ActorRegistration(ActorTypeInformation.Get(type, actorTypeName: null)); + var interactor = new DaprHttpInteractor(clientHandler: null, "http://localhost:3500", apiToken: null, requestTimeout: null); + return new ActorManager(registration, activator ?? new DefaultActorActivator(), JsonSerializerDefaults.Web, false, NullLoggerFactory.Instance, ActorProxy.DefaultProxyFactory, interactor); + } - [Fact] - public async Task ActivateActorAsync_CreatesActorAndCallsActivateLifecycleMethod() - { - var manager = CreateActorManager(typeof(TestActor)); + [Fact] + public async Task ActivateActorAsync_CreatesActorAndCallsActivateLifecycleMethod() + { + var manager = CreateActorManager(typeof(TestActor)); - var id = ActorId.CreateRandom(); - await manager.ActivateActorAsync(id); + var id = ActorId.CreateRandom(); + await manager.ActivateActorAsync(id); - Assert.True(manager.TryGetActorAsync(id, out var actor)); - Assert.True(Assert.IsType(actor).IsActivated); - } + Assert.True(manager.TryGetActorAsync(id, out var actor)); + Assert.True(Assert.IsType(actor).IsActivated); + } - [Fact] - public async Task ActivateActorAsync_CanActivateMultipleActors() - { - var manager = CreateActorManager(typeof(TestActor)); + [Fact] + public async Task ActivateActorAsync_CanActivateMultipleActors() + { + var manager = CreateActorManager(typeof(TestActor)); - await manager.ActivateActorAsync(new ActorId("1")); - Assert.True(manager.TryGetActorAsync(new ActorId("1"), out var actor1)); + await manager.ActivateActorAsync(new ActorId("1")); + Assert.True(manager.TryGetActorAsync(new ActorId("1"), out var actor1)); - await manager.ActivateActorAsync(new ActorId("2")); - Assert.True(manager.TryGetActorAsync(new ActorId("2"), out var actor2)); + await manager.ActivateActorAsync(new ActorId("2")); + Assert.True(manager.TryGetActorAsync(new ActorId("2"), out var actor2)); - Assert.NotSame(actor1, actor2); - } + Assert.NotSame(actor1, actor2); + } - [Fact] - public async Task ActivateActorAsync_UsesActivator() - { - var activator = new TestActivator(); + [Fact] + public async Task ActivateActorAsync_UsesActivator() + { + var activator = new TestActivator(); - var manager = CreateActorManager(typeof(TestActor), activator); + var manager = CreateActorManager(typeof(TestActor), activator); - var id = ActorId.CreateRandom(); - await manager.ActivateActorAsync(id); + var id = ActorId.CreateRandom(); + await manager.ActivateActorAsync(id); - Assert.Equal(1, activator.CreateCallCount); - } + Assert.Equal(1, activator.CreateCallCount); + } - [Fact] - public async Task ActivateActorAsync_DoubleActivation_DeactivatesNewActor() - { - // We have to use the activator to observe the behavior here. We don't - // have a way to interact with the "new" actor that gets destroyed immediately. - var activator = new TestActivator(); + [Fact] + public async Task ActivateActorAsync_DoubleActivation_DeactivatesNewActor() + { + // We have to use the activator to observe the behavior here. We don't + // have a way to interact with the "new" actor that gets destroyed immediately. + var activator = new TestActivator(); - var manager = CreateActorManager(typeof(TestActor), activator); + var manager = CreateActorManager(typeof(TestActor), activator); - var id = ActorId.CreateRandom(); - await manager.ActivateActorAsync(id); + var id = ActorId.CreateRandom(); + await manager.ActivateActorAsync(id); - Assert.True(manager.TryGetActorAsync(id, out var original)); + Assert.True(manager.TryGetActorAsync(id, out var original)); - // It's a double-activation! We don't expect the runtime to do this, but the code - // handles it. - await manager.ActivateActorAsync(id); + // It's a double-activation! We don't expect the runtime to do this, but the code + // handles it. + await manager.ActivateActorAsync(id); - // Still holding the original actor - Assert.True(manager.TryGetActorAsync(id, out var another)); - Assert.Same(original, another); - Assert.False(Assert.IsType(another).IsDeactivated); - Assert.False(Assert.IsType(another).IsDisposed); + // Still holding the original actor + Assert.True(manager.TryGetActorAsync(id, out var another)); + Assert.Same(original, another); + Assert.False(Assert.IsType(another).IsDeactivated); + Assert.False(Assert.IsType(another).IsDisposed); - // We should have seen 2 create operations and 1 delete - Assert.Equal(2, activator.CreateCallCount); - Assert.Equal(1, activator.DeleteCallCount); - } + // We should have seen 2 create operations and 1 delete + Assert.Equal(2, activator.CreateCallCount); + Assert.Equal(1, activator.DeleteCallCount); + } - [Fact] - public async Task ActivateActorAsync_ExceptionDuringActivation_ActorNotStoredAndDeleted() - { - var activator = new TestActivator(); + [Fact] + public async Task ActivateActorAsync_ExceptionDuringActivation_ActorNotStoredAndDeleted() + { + var activator = new TestActivator(); - var manager = CreateActorManager(typeof(ThrowsDuringOnActivateAsync), activator); + var manager = CreateActorManager(typeof(ThrowsDuringOnActivateAsync), activator); - var id = ActorId.CreateRandom(); + var id = ActorId.CreateRandom(); - await Assert.ThrowsAsync(async () => - { - await manager.ActivateActorAsync(id); - }); + await Assert.ThrowsAsync(async () => + { + await manager.ActivateActorAsync(id); + }); - Assert.False(manager.TryGetActorAsync(id, out _)); - Assert.Equal(1, activator.DeleteCallCount); - } + Assert.False(manager.TryGetActorAsync(id, out _)); + Assert.Equal(1, activator.DeleteCallCount); + } - [Fact] - public async Task DectivateActorAsync_DeletesActorAndCallsDeactivateLifecycleMethod() - { - var manager = CreateActorManager(typeof(TestActor)); + [Fact] + public async Task DectivateActorAsync_DeletesActorAndCallsDeactivateLifecycleMethod() + { + var manager = CreateActorManager(typeof(TestActor)); - var id = ActorId.CreateRandom(); - await manager.ActivateActorAsync(id); + var id = ActorId.CreateRandom(); + await manager.ActivateActorAsync(id); - Assert.True(manager.TryGetActorAsync(id, out var actor)); - await manager.DeactivateActorAsync(id); + Assert.True(manager.TryGetActorAsync(id, out var actor)); + await manager.DeactivateActorAsync(id); - Assert.True(Assert.IsType(actor).IsDeactivated); - Assert.True(Assert.IsType(actor).IsDisposed); - } + Assert.True(Assert.IsType(actor).IsDeactivated); + Assert.True(Assert.IsType(actor).IsDisposed); + } - [Fact] - public async Task DeactivateActorAsync_ItsOkToDeactivateNonExistentActor() - { - var manager = CreateActorManager(typeof(TestActor)); + [Fact] + public async Task DeactivateActorAsync_ItsOkToDeactivateNonExistentActor() + { + var manager = CreateActorManager(typeof(TestActor)); - var id = ActorId.CreateRandom(); - Assert.False(manager.TryGetActorAsync(id, out _)); - await manager.DeactivateActorAsync(id); - } + var id = ActorId.CreateRandom(); + Assert.False(manager.TryGetActorAsync(id, out _)); + await manager.DeactivateActorAsync(id); + } - [Fact] - public async Task DeactivateActorAsync_UsesActivator() - { - var activator = new TestActivator(); + [Fact] + public async Task DeactivateActorAsync_UsesActivator() + { + var activator = new TestActivator(); - var manager = CreateActorManager(typeof(TestActor), activator); + var manager = CreateActorManager(typeof(TestActor), activator); - var id = ActorId.CreateRandom(); - await manager.ActivateActorAsync(id); - await manager.DeactivateActorAsync(id); + var id = ActorId.CreateRandom(); + await manager.ActivateActorAsync(id); + await manager.DeactivateActorAsync(id); - Assert.Equal(1, activator.CreateCallCount); - Assert.Equal(1, activator.DeleteCallCount); - } + Assert.Equal(1, activator.CreateCallCount); + Assert.Equal(1, activator.DeleteCallCount); + } - [Fact] - public async Task DeactivateActorAsync_ExceptionDuringDeactivation_ActorIsRemovedAndDeleted() - { - var activator = new TestActivator(); + [Fact] + public async Task DeactivateActorAsync_ExceptionDuringDeactivation_ActorIsRemovedAndDeleted() + { + var activator = new TestActivator(); - var manager = CreateActorManager(typeof(ThrowsDuringOnDeactivateAsync), activator); + var manager = CreateActorManager(typeof(ThrowsDuringOnDeactivateAsync), activator); - var id = ActorId.CreateRandom(); - await manager.ActivateActorAsync(id); - Assert.True(manager.TryGetActorAsync(id, out _)); + var id = ActorId.CreateRandom(); + await manager.ActivateActorAsync(id); + Assert.True(manager.TryGetActorAsync(id, out _)); - await Assert.ThrowsAsync(async () => - { - await manager.DeactivateActorAsync(id); - }); + await Assert.ThrowsAsync(async () => + { + await manager.DeactivateActorAsync(id); + }); - Assert.False(manager.TryGetActorAsync(id, out _)); - Assert.Equal(1, activator.DeleteCallCount); - } + Assert.False(manager.TryGetActorAsync(id, out _)); + Assert.Equal(1, activator.DeleteCallCount); + } + + private interface ITestActor : IActor { } - private interface ITestActor : IActor { } + private class TestActor : Actor, ITestActor, IDisposable + { + private static int counter; - private class TestActor : Actor, ITestActor, IDisposable + public TestActor(ActorHost host) : base(host) { - private static int counter; + Sequence = Interlocked.Increment(ref counter); + } - public TestActor(ActorHost host) : base(host) - { - Sequence = Interlocked.Increment(ref counter); - } + // Makes instances easier to tell apart for debugging. + public int Sequence { get; } - // Makes instances easier to tell apart for debugging. - public int Sequence { get; } + public bool IsActivated { get; set; } - public bool IsActivated { get; set; } + public bool IsDeactivated { get; set; } - public bool IsDeactivated { get; set; } + public bool IsDisposed { get; set; } - public bool IsDisposed { get; set; } + public void Dispose() + { + IsDisposed = true; + } - public void Dispose() - { - IsDisposed = true; - } + protected override Task OnActivateAsync() + { + IsActivated = true; + return Task.CompletedTask; + } - protected override Task OnActivateAsync() - { - IsActivated = true; - return Task.CompletedTask; - } + protected override Task OnDeactivateAsync() + { + IsDeactivated = true; + return Task.CompletedTask; + } + } - protected override Task OnDeactivateAsync() - { - IsDeactivated = true; - return Task.CompletedTask; - } + private class ThrowsDuringOnActivateAsync : Actor, ITestActor + { + public ThrowsDuringOnActivateAsync(ActorHost host) : base(host) + { } - private class ThrowsDuringOnActivateAsync : Actor, ITestActor + protected override Task OnActivateAsync() { - public ThrowsDuringOnActivateAsync(ActorHost host) : base(host) - { - } - - protected override Task OnActivateAsync() - { - throw new InvalidTimeZoneException(); - } + throw new InvalidTimeZoneException(); } + } - private class ThrowsDuringOnDeactivateAsync : Actor, ITestActor + private class ThrowsDuringOnDeactivateAsync : Actor, ITestActor + { + public ThrowsDuringOnDeactivateAsync(ActorHost host) : base(host) { - public ThrowsDuringOnDeactivateAsync(ActorHost host) : base(host) - { - } - - protected override Task OnDeactivateAsync() - { - throw new InvalidTimeZoneException(); - } } - private class TestActivator : DefaultActorActivator + protected override Task OnDeactivateAsync() { - public int CreateCallCount { get; set; } + throw new InvalidTimeZoneException(); + } + } - public int DeleteCallCount { get; set; } + private class TestActivator : DefaultActorActivator + { + public int CreateCallCount { get; set; } - public override Task CreateAsync(ActorHost host) - { - CreateCallCount++;; - return base.CreateAsync(host); - } + public int DeleteCallCount { get; set; } - public override Task DeleteAsync(ActorActivatorState state) - { - DeleteCallCount++; - return base.DeleteAsync(state); - } + public override Task CreateAsync(ActorHost host) + { + CreateCallCount++;; + return base.CreateAsync(host); + } + + public override Task DeleteAsync(ActorActivatorState state) + { + DeleteCallCount++; + return base.DeleteAsync(state); } } } diff --git a/test/Dapr.Actors.Test/Runtime/ActorStateTests.cs b/test/Dapr.Actors.Test/Runtime/ActorStateTests.cs new file mode 100644 index 000000000..918d0f6f5 --- /dev/null +++ b/test/Dapr.Actors.Test/Runtime/ActorStateTests.cs @@ -0,0 +1,358 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 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 Xunit; + +namespace Dapr.Actors.Runtime; + +public sealed class ActorStateTests +{ + [Fact] + public void ActorStateCache_Add_DoesNotContainAddsToState() + { + var cache = new ActorStateCache(); + const int value = 123; + var result = cache.Add("state", value); + + Assert.False(result.stateContainsKey); + Assert.True(result.addedToState); + } + + [Fact] + public void ActorStateCache_Add_AlreadyExists() + { + var cache = new ActorStateCache(); + const int value = 123; + const string stateName = "state"; + var state = ActorStateCache.StateMetadata.Create(value, StateChangeKind.Add); + cache.Set(stateName, state); + + var result = cache.Add(stateName, value); + + Assert.True(result.stateContainsKey); + Assert.False(result.addedToState); + } + + [Fact] + public void ActorStateCache_Add_MarkedAsRemoved() + { + var cache = new ActorStateCache(); + const int value = 123; + const string stateName = "state"; + var state = ActorStateCache.StateMetadata.Create(value, StateChangeKind.Remove); + cache.Set(stateName, state); + + var result = cache.Add(stateName, value, TimeSpan.FromMinutes(5)); + + Assert.True(result.stateContainsKey); + Assert.True(result.addedToState); + } + + [Fact] + public void ActorStateCache_AddExpiry_DoesNotContainAddsToState() + { + var cache = new ActorStateCache(); + const int value = 123; + var result = cache.Add("state", value, DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.False(result.stateContainsKey); + Assert.True(result.addedToState); + } + + [Fact] + public void ActorStateCache_AddExpiry_AlreadyExists() + { + var cache = new ActorStateCache(); + const int value = 123; + const string stateName = "state"; + var state = ActorStateCache.StateMetadata.Create(value, StateChangeKind.Add); + cache.Set(stateName, state); + + var result = cache.Add(stateName, value, DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.True(result.stateContainsKey); + Assert.False(result.addedToState); + } + + [Fact] + public void ActorStateCache_AddExpiry_MarkedAsRemoved() + { + var cache = new ActorStateCache(); + const int value = 123; + const string stateName = "state"; + var state = ActorStateCache.StateMetadata.Create(value, StateChangeKind.Remove); + cache.Set(stateName, state); + + var result = cache.Add(stateName, value, DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.True(result.stateContainsKey); + Assert.True(result.addedToState); + } + + [Fact] + public void ActorStateCache_Set() + { + var cache = new ActorStateCache(); + const string stateName = "state"; + const int value = 456; + const StateChangeKind kind = StateChangeKind.None; + var state = ActorStateCache.StateMetadata.Create(value, kind); + + cache.Set(stateName, state); + + var stateMetadata = cache.GetStateMetadata(); + Assert.Single(stateMetadata.Keys); + Assert.True(stateMetadata.ContainsKey(stateName)); + var data = stateMetadata[stateName]; + Assert.NotNull(data.Value); + Assert.Equal(value, data.Value); + Assert.Equal(kind, data.ChangeKind); + Assert.Null(data.TTLExpireTime); + Assert.Equal(value.GetType(), data.Type); + } + + [Fact] + public void ActorStateCache_Remove() + { + var cache = new ActorStateCache(); + const string stateName = "state"; + const int value = 456; + const StateChangeKind kind = StateChangeKind.None; + var state = ActorStateCache.StateMetadata.Create(value, kind); + + cache.Set(stateName, state); + + var stateMetadata = cache.GetStateMetadata(); + Assert.Single(stateMetadata.Keys); + Assert.True(stateMetadata.ContainsKey(stateName)); + + cache.Remove(stateName); + stateMetadata = cache.GetStateMetadata(); + Assert.Empty(stateMetadata.Keys); + } + + [Fact] + public void ActorStateCache_TryGet_ExistsWithoutExpiration() + { + var cache = new ActorStateCache(); + const int value = 123; + const StateChangeKind kind = StateChangeKind.Add; + const string stateName = "state"; + var state = ActorStateCache.StateMetadata.Create(value, kind); + + cache.Set(stateName, state); + + var result = cache.TryGet(stateName, out var retrievedState); + Assert.True(result.containsKey); + Assert.False(result.isMarkedAsRemoveOrExpired); + Assert.NotNull(retrievedState); + Assert.NotNull(retrievedState.Value); + Assert.Equal(value, (int)retrievedState.Value); + Assert.Equal(kind, retrievedState.ChangeKind); + Assert.Null(retrievedState.TTLExpireTime); + Assert.Equal(value.GetType(), retrievedState.Type); + } + + [Fact] + public void ActorStateCache_TryGet_DoesNotExist() + { + var cache = new ActorStateCache(); + var result = cache.TryGet("mystate", out var retrievedState); + + Assert.False(result.containsKey); + Assert.False(result.isMarkedAsRemoveOrExpired); + Assert.Null(retrievedState); + } + + [Fact] + public void ActorStateCache_TryGet_IsExpired() + { + var cache = new ActorStateCache(); + const string value = "acb"; + const StateChangeKind kind = StateChangeKind.Update; + const string stateName = "state"; + var state = ActorStateCache.StateMetadata.Create(value, kind, DateTimeOffset.UtcNow.AddMinutes(-10)); + + cache.Set(stateName, state); + + var result = cache.TryGet(stateName, out var retrievedState); + Assert.True(result.containsKey); + Assert.True(result.isMarkedAsRemoveOrExpired); + Assert.NotNull(retrievedState); + Assert.NotNull(retrievedState.Value); + Assert.Equal(value, (string)retrievedState.Value); + Assert.Equal(kind, retrievedState.ChangeKind); + Assert.NotNull(retrievedState.TTLExpireTime); + Assert.Equal(value.GetType(), retrievedState.Type); + } + + [Fact] + public void ActorStateCache_Clear() + { + var cache = new ActorStateCache(); + cache.Add("state1", 123); + cache.Add("state2", "456"); + cache.Add("state3", 7890); + + var data = cache.GetStateMetadata(); + Assert.Equal(3, data.Keys.Count); + Assert.Contains("state1", data.Keys); + Assert.Contains("state2", data.Keys); + Assert.Contains("state3", data.Keys); + + cache.Clear(); + data = cache.GetStateMetadata(); + Assert.Empty(data.Keys); + } + + [Fact] + public void ActorStateCache_BuildChangeList_NoChanges() + { + var cache = new ActorStateCache(); + var result = cache.BuildChangeList(); + + Assert.Empty(result.stateChanges); + Assert.Empty(result.statesToRemove); + } + + [Fact] + public void ActorStateCache_BuildChangeList_ChangesWithRemovals() + { + var cache = new ActorStateCache(); + cache.Add("state1", 456); + var state2Offset = DateTimeOffset.UtcNow.AddMinutes(15); + cache.Set("state2", ActorStateCache.StateMetadata.Create("78", StateChangeKind.Remove, state2Offset)); + cache.Add("state3", "test"); + + var (stateChanges, removalChanges) = cache.BuildChangeList(); + + //Validate stateChanges + Assert.Equal(3, stateChanges.Count); + Assert.Contains(new ActorStateChange("state1", typeof(int), 456, StateChangeKind.Add, null), stateChanges); + Assert.Contains(new ActorStateChange("state2", typeof(string), "78", StateChangeKind.Remove, state2Offset), stateChanges); + Assert.Contains(new ActorStateChange("state3", typeof(string), "test", StateChangeKind.Add, null), + stateChanges); + + //Validate removalChanges + Assert.Single(removalChanges); + Assert.Contains("state2", removalChanges); + + //Validate every state value was marked as None + var states = cache.GetStateMetadata(); + Assert.Equal(3, states.Count); + foreach (var state in states) + { + Assert.Equal(StateChangeKind.None, state.Value.ChangeKind); + } + } + + [Fact] + public void ActorStateCache_ShouldNotBeMarkedAsRemovedOrExpired() + { + var cache = new ActorStateCache(); + var state = ActorStateCache.StateMetadata.Create(123, StateChangeKind.Update, DateTimeOffset.UtcNow.AddMinutes(10)); + var result = cache.IsMarkedAsRemoveOrExpired(state); + + Assert.False(result); + } + + [Fact] + public void ActorStateCache_ShouldBeMarkedAsRemoved() + { + var cache = new ActorStateCache(); + var state = ActorStateCache.StateMetadata.Create(123, StateChangeKind.Remove); + var result = cache.IsMarkedAsRemoveOrExpired(state); + + Assert.True(result); + } + + [Fact] + public void ActorStateCache_ShouldBeMarkedAsExpired() + { + var cache = new ActorStateCache(); + var state = ActorStateCache.StateMetadata.Create(123, StateChangeKind.Update, DateTimeOffset.UtcNow.AddMinutes(-10)); + var result = cache.IsMarkedAsRemoveOrExpired(state); + + Assert.True(result); + } + + [Fact] + public void StateMetadata_ShouldThrowIfBothTtlExpireTimeAndTtlAreSet() + { + Assert.Throws(() => + { + // ReSharper disable once ObjectCreationAsStatement + new ActorStateCache.StateMetadata("123", typeof(int), StateChangeKind.None, + DateTimeOffset.UtcNow, + TimeSpan.FromMinutes(5)); + }); + } + + [Fact] + public void StateMetadata_CreatePlain() + { + const int stateValue = 123; + var type = stateValue.GetType(); + const StateChangeKind kind = StateChangeKind.None; + var data = ActorStateCache.StateMetadata.Create(stateValue, kind); + + Assert.NotNull(data.Value); + Assert.Equal(stateValue, (int)data.Value); + Assert.Equal(type, data.Type); + Assert.Equal(kind, data.ChangeKind); + Assert.Null(data.TTLExpireTime); + } + + [Fact] + public void StateMetadata_CreateWithTtl() + { + const int stateValue = 123; + var type = stateValue.GetType(); + const StateChangeKind kind = StateChangeKind.Add; + var ttl = TimeSpan.FromMinutes(10); + var data = ActorStateCache.StateMetadata.Create(stateValue, kind, ttl); + + Assert.NotNull(data.Value); + Assert.Equal(stateValue, (int)data.Value); + Assert.Equal(type, data.Type); + Assert.Equal(kind, data.ChangeKind); + Assert.NotNull(data.TTLExpireTime); + } + + [Fact] + public void StateMetadata_CreateWithTtlExpiryTime() + { + const int stateValue = 123; + var type = stateValue.GetType(); + const StateChangeKind kind = StateChangeKind.Add; + var ttlExpiry = DateTimeOffset.UtcNow.AddMinutes(5); + var data = ActorStateCache.StateMetadata.Create(stateValue, kind, ttlExpiry); + + Assert.NotNull(data.Value); + Assert.Equal(stateValue, (int)data.Value); + Assert.Equal(type, data.Type); + Assert.Equal(kind, data.ChangeKind); + Assert.Equal(ttlExpiry, data.TTLExpireTime); + } + + [Fact] + public void StateMetadata_CreateForRemoval() + { + var data = ActorStateCache.StateMetadata.CreateForRemove(); + + Assert.Null(data.Value); + Assert.Null(data.TTLExpireTime); + Assert.Equal(StateChangeKind.Remove, data.ChangeKind); + } +} diff --git a/test/Dapr.Actors.Test/TestDaprInteractor.cs b/test/Dapr.Actors.Test/TestDaprInteractor.cs index 2d81f16d0..d706520ac 100644 --- a/test/Dapr.Actors.Test/TestDaprInteractor.cs +++ b/test/Dapr.Actors.Test/TestDaprInteractor.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Dapr.Actors.Communication; @@ -58,7 +59,7 @@ public Task InvokeActorMethodWithoutRemotingAsync(string actorType, stri { throw new System.NotImplementedException(); } - + /// /// Saves state batch to Dapr. /// @@ -79,11 +80,39 @@ public virtual async Task SaveStateTransactionallyAsync(string actorType, string /// Type of actor. /// ActorId. /// Name of key to get value for. + /// The data to persist to state. + /// Cancels the operation. + /// A task that represents the asynchronous operation. + public virtual async Task SaveStateAsync( + string actorType, + string actorId, + string keyName, + string data, + CancellationToken cancellationToken = default) + { + await _testDaprInteractor.SaveStateAsync(actorType, actorId, keyName, data, cancellationToken); + } + + /// + /// Gets a state from Dapr. + /// + /// Type of actor. + /// ActorId. + /// Name of key to get value for. /// Cancels the operation. /// A task that represents the asynchronous operation. public virtual async Task> GetStateAsync(string actorType, string actorId, string keyName, CancellationToken cancellationToken = default) { - return await _testDaprInteractor.GetStateAsync(actorType, actorId, keyName); + return await _testDaprInteractor.GetStateAsync(actorType, actorId, keyName, cancellationToken); + } + + public virtual async Task>> GetListStateAsync( + string actorType, + string actorId, + string keyName, + CancellationToken cancellationToken = default) + { + return await _testDaprInteractor.GetListStateAsync(actorType, actorId, keyName, cancellationToken); } ///