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