diff --git a/src/Worker/Core/ExtendedSessionsCache.cs b/src/Worker/Core/ExtendedSessionsCache.cs
index 59df2536..39635e31 100644
--- a/src/Worker/Core/ExtendedSessionsCache.cs
+++ b/src/Worker/Core/ExtendedSessionsCache.cs
@@ -11,7 +11,18 @@ namespace Microsoft.DurableTask.Worker;
///
public class ExtendedSessionsCache : IDisposable
{
+ // Guards both the lazy-initialization of `extendedSessions` in GetOrInitializeCache() and the
+ // disposal state transition in Dispose(). Without this shared lock, Dispose() could observe
+ // `extendedSessions` as null (because it hasn't been lazily created yet), mark itself disposed,
+ // and return -- while a concurrent GetOrInitializeCache() call races in and constructs a brand
+ // new MemoryCache immediately afterwards. That cache would never be disposed (Dispose() has
+ // already run and is now a permanent no-op), leaking it and any entries added to it. The lock
+ // makes initialization and disposal mutually exclusive, so there's no window where a cache can
+ // be created after (or concurrently with) disposal.
+ readonly object syncRoot = new();
+
MemoryCache? extendedSessions;
+ bool disposed;
///
/// Gets a value indicating whether returns whether or not the cache has been initialized.
@@ -24,7 +35,38 @@ public class ExtendedSessionsCache : IDisposable
///
public void Dispose()
{
- this.extendedSessions?.Dispose();
+ MemoryCache? cacheToDispose;
+ lock (this.syncRoot)
+ {
+ if (this.disposed)
+ {
+ // Already disposed by a previous (or concurrent, now-completed) call. MemoryCache.Clear()
+ // and MemoryCache.Dispose() are not safe to call more than once -- Clear() throws
+ // ObjectDisposedException if the cache has already been disposed -- so this guard makes
+ // Dispose() idempotent and safe under concurrent callers.
+ return;
+ }
+
+ this.disposed = true;
+
+ // Clear the field (under the same lock used by GetOrInitializeCache()) so that no caller
+ // can observe or lazily recreate a cache after this point; GetOrInitializeCache() checks
+ // `this.disposed` under the lock and throws ObjectDisposedException instead.
+ cacheToDispose = this.extendedSessions;
+ this.extendedSessions = null;
+ }
+
+ // MemoryCache.Dispose() does NOT invoke post-eviction callbacks for entries that are still
+ // present in the cache -- it merely tears down the cache's internal state. Any entries
+ // (e.g. cached extended-session state holding an IDisposable shim) that are still cached at
+ // shutdown would therefore never be disposed. Calling Clear() first forces every remaining
+ // entry to be removed via the normal removal path, which does invoke eviction callbacks for
+ // each entry, ensuring they are triggered instead of silently skipped. Note that eviction
+ // callbacks are queued asynchronously (via Task.Factory.StartNew), so Clear() does not
+ // guarantee those callbacks have completed by the time Dispose() returns -- it only
+ // guarantees they are scheduled before the cache itself is torn down.
+ cacheToDispose?.Clear();
+ cacheToDispose?.Dispose();
GC.SuppressFinalize(this);
}
@@ -36,13 +78,97 @@ public void Dispose()
/// This specifies how often the cache checks for stale items, and evicts them.
///
/// The IMemoryCache that holds the cached .
+ /// The cache has already been disposed.
public MemoryCache GetOrInitializeCache(double expirationScanFrequencyInSeconds)
{
- this.extendedSessions ??= new MemoryCache(new MemoryCacheOptions
+ lock (this.syncRoot)
+ {
+ if (this.disposed)
+ {
+ throw new ObjectDisposedException(nameof(ExtendedSessionsCache));
+ }
+
+ this.extendedSessions ??= new MemoryCache(new MemoryCacheOptions
+ {
+ ExpirationScanFrequency = TimeSpan.FromSeconds(expirationScanFrequencyInSeconds / 5),
+ });
+
+ return this.extendedSessions;
+ }
+ }
+
+ ///
+ /// Attempts to retrieve the cached value for the given key, if present and this cache has not been
+ /// disposed (nor is concurrently being disposed by another thread). Callers should use this instead
+ /// of calling directly on the
+ /// returned by , since this method is
+ /// synchronized with and therefore can never observe -- or throw from -- a
+ /// cache instance that is concurrently being torn down.
+ ///
+ /// The type of the cached value.
+ /// The cache key.
+ /// When this method returns, contains the cached value, if found.
+ /// true if a value was found; false if not found, or if this cache is disposed.
+ internal bool TryGetCachedValue(string key, out T? value)
+ {
+ lock (this.syncRoot)
+ {
+ if (this.disposed || this.extendedSessions is null)
+ {
+ value = default;
+ return false;
+ }
+
+ return this.extendedSessions.TryGetValue(key, out value);
+ }
+ }
+
+ ///
+ /// Removes the cached value for the given key, if present. This is a safe no-op if this cache has
+ /// already been disposed (or is concurrently being disposed). Synchronized with
+ /// for the same reason as .
+ ///
+ /// The cache key to remove.
+ internal void RemoveCachedValue(string key)
+ {
+ lock (this.syncRoot)
+ {
+ if (this.disposed || this.extendedSessions is null)
+ {
+ return;
+ }
+
+ this.extendedSessions.Remove(key);
+ }
+ }
+
+ ///
+ /// Attempts to insert or replace the cached value for the given key. Returns false without
+ /// modifying the cache if this has already been disposed, or is
+ /// concurrently being disposed by another thread -- in which case the caller retains ownership of
+ /// (and remains responsible for disposing it, if applicable) instead of
+ /// assuming the cache accepted it and will eventually evict and dispose it via a post-eviction
+ /// callback. Synchronized with so there is no window in which an entry can be
+ /// inserted after disposal has begun tearing the cache down (e.g. after Clear() has already
+ /// run but before the underlying itself has been disposed) -- an insertion
+ /// that would otherwise never be evicted or disposed again.
+ ///
+ /// The type of the value to cache.
+ /// The cache key.
+ /// The value to cache.
+ /// The cache entry options (e.g. sliding expiration, eviction callback).
+ /// true if the value was inserted; false if rejected because this cache is disposed.
+ internal bool TrySetCachedValue(string key, T value, MemoryCacheEntryOptions options)
+ {
+ lock (this.syncRoot)
{
- ExpirationScanFrequency = TimeSpan.FromSeconds(expirationScanFrequencyInSeconds / 5),
- });
+ if (this.disposed || this.extendedSessions is null)
+ {
+ return false;
+ }
- return this.extendedSessions;
+ this.extendedSessions.Set(key, value, options);
+ return true;
+ }
}
}
diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs
index c92ee5d6..42e83f07 100644
--- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs
+++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs
@@ -16,7 +16,7 @@ namespace Microsoft.DurableTask.Worker.Shims;
///
/// A wrapper to go from to .
///
-sealed partial class TaskOrchestrationContextWrapper : TaskOrchestrationContext
+sealed partial class TaskOrchestrationContextWrapper : TaskOrchestrationContext, IDisposable
{
// We use a stack (a custom implementation using a single-linked list) to make it easier for users
// to abandon external events that they no longer care about. The common case is a Task.WhenAny in a loop.
@@ -33,6 +33,22 @@ sealed partial class TaskOrchestrationContextWrapper : TaskOrchestrationContext
bool preserveUnprocessedEventsOnContinueAsNew;
TaskOrchestrationEntityContext? entityFeature;
+ // Cached and reused across NewGuid() calls (instead of creating and disposing a new SHA1 instance
+ // per call) to reduce per-call allocation overhead. A single TaskOrchestrationContextWrapper is used
+ // for the duration of a single orchestration execution, and orchestrator code executes sequentially
+ // (never concurrently) within that execution, so reusing this instance is safe. HashAlgorithm.Initialize()
+ // resets all internal state before each use, so the computed hash is identical to using a fresh instance.
+ // This instance is disposed via Dispose() (see TaskOrchestrationShim, which disposes the previous
+ // wrapper before replacing it with a new one on the next replay/decision task).
+ //
+ // Note: on .NET Framework, the underlying SHA1CryptoServiceProvider.Initialize() disposes and
+ // recreates its native CAPI hash handle on every call, so the native-handle churn is not eliminated
+ // there -- only the managed-side allocation (the HashAlgorithm object itself and SHA1.Create()'s
+ // provider lookup) is avoided. On modern .NET (net5.0+) running on Windows, the CNG-based
+ // implementation can use a reusable hash handle (BCRYPT_HASH_REUSABLE_FLAG) and reset it in place,
+ // so this caching also avoids native-handle churn on those runtimes.
+ SHA1? cachedHashAlgorithm;
+
///
/// Initializes a new instance of the class.
///
@@ -434,16 +450,18 @@ static void SwapByteArrayElements(byte[] byteArray, int left, int right)
byte[] namespaceValueByteArray = namespaceValueGuid.ToByteArray();
SwapByteArrayValues(namespaceValueByteArray);
- byte[] hashByteArray;
#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms -- not for cryptography
- using (HashAlgorithm hashAlgorithm = SHA1.Create()) /* CodeQL [SM02196] Suppressed: SHA1 is not used for cryptographic purposes here. The information being hashed is not sensitive,
+ SHA1 hashAlgorithm = this.cachedHashAlgorithm ??= SHA1.Create(); /* CodeQL [SM02196] Suppressed: SHA1 is not used for cryptographic purposes here. The information being hashed is not sensitive,
and the goal is to generate a deterministic Guid. We cannot update to SHA2-based algorithms without breaking
customers' inflight orchestrations. */
- {
- hashAlgorithm.TransformBlock(namespaceValueByteArray, 0, namespaceValueByteArray.Length, null, 0);
- hashAlgorithm.TransformFinalBlock(nameByteArray, 0, nameByteArray.Length);
- hashByteArray = hashAlgorithm.Hash;
- }
+
+ // Reset internal state before each use since this instance is cached and reused across calls
+ // rather than being created and disposed per call. This produces byte-for-byte identical hashes
+ // to constructing a new SHA1 instance for every call.
+ hashAlgorithm.Initialize();
+ hashAlgorithm.TransformBlock(namespaceValueByteArray, 0, namespaceValueByteArray.Length, null, 0);
+ hashAlgorithm.TransformFinalBlock(nameByteArray, 0, nameByteArray.Length);
+ byte[] hashByteArray = hashAlgorithm.Hash;
#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -- not for cryptography
byte[] newGuidByteArray = new byte[16];
@@ -458,6 +476,17 @@ and the goal is to generate a deterministic Guid. We cannot update to SHA2-based
return new Guid(newGuidByteArray);
}
+ ///
+ /// Releases the resources cached by this instance, including the instance used by
+ /// . This should be called once this wrapper is no longer needed, i.e. once the
+ /// orchestration execution that owns it has completed and it is being replaced or discarded.
+ ///
+ public void Dispose()
+ {
+ this.cachedHashAlgorithm?.Dispose();
+ this.cachedHashAlgorithm = null;
+ }
+
///
/// exits the critical section, if currently within a critical section. Otherwise, this has no effect.
///
diff --git a/src/Worker/Core/Shims/TaskOrchestrationShim.cs b/src/Worker/Core/Shims/TaskOrchestrationShim.cs
index eb7a179b..b7c8f1de 100644
--- a/src/Worker/Core/Shims/TaskOrchestrationShim.cs
+++ b/src/Worker/Core/Shims/TaskOrchestrationShim.cs
@@ -13,8 +13,16 @@ namespace Microsoft.DurableTask.Worker.Shims;
///
/// This class is intended for use with alternate .NET-based durable task runtimes. It's not intended for use
/// in application code.
+///
+/// The base type (defined in DurableTask.Core) has no disposal hook of its
+/// own, so the framework will never call automatically. Callers that construct a
+/// directly (e.g. the gRPC worker processor and the orchestration
+/// runner) own its lifetime and are responsible for disposing it once they are done with it -- typically
+/// immediately after the single call completes, or (for extended sessions) when the
+/// cached shim is evicted/removed.
+///
///
-partial class TaskOrchestrationShim : TaskOrchestration
+partial class TaskOrchestrationShim : TaskOrchestration, IDisposable
{
readonly ITaskOrchestrator implementation;
readonly OrchestrationInvocationContext invocationContext;
@@ -64,6 +72,14 @@ public TaskOrchestrationShim(
innerContext.ErrorDataConverter = converterShim;
object? input = this.DataConverter.Deserialize(rawInput, this.implementation.InputType);
+
+ // Defensively dispose any previous wrapper before replacing it, in case this shim instance is
+ // ever reused across more than one Execute call. Current callers construct a fresh shim per
+ // execution and call Execute exactly once, so the actual resource cleanup for this shim's wrapper
+ // happens via Dispose() (see the class remarks); this is still safe to do if it ever runs, since
+ // orchestrator code always runs synchronously within a single Execute call, so a previous wrapper
+ // is guaranteed to no longer be in use once we reach this point.
+ this.wrapperContext?.Dispose();
this.wrapperContext = new(innerContext, this.invocationContext, input, this.properties);
string instanceId = innerContext.OrchestrationInstance.InstanceId;
@@ -118,4 +134,15 @@ public override void RaiseEvent(OrchestrationContext context, string name, strin
{
this.wrapperContext?.CompleteExternalEvent(name, input);
}
+
+ ///
+ /// Releases the resources (e.g. the cached instance
+ /// backing ) held by this shim's current wrapper. Callers
+ /// that construct this shim directly are responsible for calling this once they are finished with it,
+ /// since the base type provides no framework-invoked disposal hook.
+ ///
+ public void Dispose()
+ {
+ this.wrapperContext?.Dispose();
+ }
}
diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs
index 6d63f6af..20105510 100644
--- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs
+++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs
@@ -774,14 +774,25 @@ await this.ExecuteWithRetryAsync(
};
TaskOrchestration shim = this.shimFactory.CreateOrchestration(name, orchestrator, parent);
- TaskOrchestrationExecutor executor = new(
- runtimeState,
- shim,
- BehaviorOnContinueAsNew.Carryover,
- request.EntityParameters.ToCore(),
- ErrorPropagationMode.UseFailureDetails,
- this.exceptionPropertiesProvider);
- result = executor.Execute();
+ try
+ {
+ TaskOrchestrationExecutor executor = new(
+ runtimeState,
+ shim,
+ BehaviorOnContinueAsNew.Carryover,
+ request.EntityParameters.ToCore(),
+ ErrorPropagationMode.UseFailureDetails,
+ this.exceptionPropertiesProvider);
+ result = executor.Execute();
+ }
+ finally
+ {
+ // This worker (unlike the extended-session path in GrpcOrchestrationRunner) never
+ // reuses a shim across work items, so it owns and must dispose it once execution
+ // of this single work item completes (e.g. to release the SHA1 instance cached by
+ // NewGuid).
+ (shim as IDisposable)?.Dispose();
+ }
}
else
{
diff --git a/src/Worker/Grpc/GrpcEntityRunner.cs b/src/Worker/Grpc/GrpcEntityRunner.cs
index 28ebc67e..2c5b01f6 100644
--- a/src/Worker/Grpc/GrpcEntityRunner.cs
+++ b/src/Worker/Grpc/GrpcEntityRunner.cs
@@ -113,7 +113,7 @@ public static async Task LoadAndRunAsync(
addToExtendedSessions = true;
// If an entity state was provided, even if we already have one stored, we always want to use the provided state.
- if (!entityStateIncluded && extendedSessions.TryGetValue(request.InstanceId, out string? entityState))
+ if (!entityStateIncluded && extendedSessionsCache!.TryGetCachedValue(request.InstanceId, out string? entityState))
{
batch.EntityState = entityState;
stateCached = true;
@@ -135,15 +135,19 @@ public static async Task LoadAndRunAsync(
if (addToExtendedSessions)
{
- // addToExtendedSessions can only be set to true if extendedSessions is not null
- extendedSessions!.Set(
+ // addToExtendedSessions can only be set to true if extendedSessionsCache is not null.
+ // TrySetCachedValue is synchronized with a concurrent ExtendedSessionsCache.Dispose() (see
+ // GrpcOrchestrationRunner for the full rationale); the entity's cached state is a plain
+ // string with nothing to dispose, but routing through the same encapsulated API keeps cache
+ // mutation consistently guarded against a racing shutdown.
+ extendedSessionsCache!.TrySetCachedValue(
request.InstanceId,
result.EntityState,
new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(extendedSessionIdleTimeoutInSeconds) });
}
else
{
- extendedSessions?.Remove(request.InstanceId);
+ extendedSessionsCache?.RemoveCachedValue(request.InstanceId);
}
P.EntityBatchResult response = result.ToEntityBatchResult();
diff --git a/src/Worker/Grpc/GrpcOrchestrationRunner.cs b/src/Worker/Grpc/GrpcOrchestrationRunner.cs
index 5fbe2228..cc7a6a96 100644
--- a/src/Worker/Grpc/GrpcOrchestrationRunner.cs
+++ b/src/Worker/Grpc/GrpcOrchestrationRunner.cs
@@ -144,9 +144,14 @@ public static string LoadAndRun(
if (isExtendedSession && extendedSessions != null)
{
+ // extendedSessions is only non-null when extendedSessionsCache is also non-null. All reads,
+ // removals, and (later) insertions are routed through extendedSessionsCache's synchronized
+ // wrapper methods rather than operating on the raw MemoryCache directly, so every operation
+ // is atomic with respect to a concurrent Dispose() of the cache (see round-9 fix below).
+ //
// If a history was provided, even if we already have an extended session stored, we always want to evict whatever state is in the cache and replace it with a new extended
// session based on the provided history
- if (!pastEventsIncluded && extendedSessions.TryGetValue(request.InstanceId, out ExtendedSessionState? extendedSessionState) && extendedSessionState is not null)
+ if (!pastEventsIncluded && extendedSessionsCache!.TryGetCachedValue(request.InstanceId, out ExtendedSessionState? extendedSessionState) && extendedSessionState is not null)
{
OrchestrationRuntimeState runtimeState = extendedSessionState!.RuntimeState;
runtimeState.NewEvents.Clear();
@@ -158,12 +163,12 @@ public static string LoadAndRun(
result = extendedSessionState.OrchestrationExecutor.ExecuteNewEvents();
if (extendedSessionState.OrchestrationExecutor.IsCompleted)
{
- extendedSessions.Remove(request.InstanceId);
+ extendedSessionsCache.RemoveCachedValue(request.InstanceId);
}
}
else
{
- extendedSessions.Remove(request.InstanceId);
+ extendedSessionsCache!.RemoveCachedValue(request.InstanceId);
addToExtendedSessions = true;
}
}
@@ -197,25 +202,64 @@ public static string LoadAndRun(
: ActivatorUtilities.GetServiceOrCreateInstance(services);
TaskOrchestration shim = factory.CreateOrchestration(orchestratorName, implementation, properties, parent);
- TaskOrchestrationExecutor executor = new(
- runtimeState,
- shim,
- BehaviorOnContinueAsNew.Carryover,
- request.EntityParameters.ToCore(),
- ErrorPropagationMode.UseFailureDetails);
- result = executor.Execute();
-
- if (addToExtendedSessions && !executor.IsCompleted)
+ // Tracks whether ownership of the shim has been successfully transferred to the
+ // extended-sessions cache. Execute() (or the subsequent cache Set() call) could throw;
+ // in that case ownership is never transferred, and the finally block below must dispose
+ // the shim itself rather than leaking it.
+ bool transferredShimToCache = false;
+ try
{
- // addToExtendedSessions can only be set to true if extendedSessions is not null
- extendedSessions!.Set(
- request.InstanceId,
- new(runtimeState, shim, executor),
- new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(extendedSessionIdleTimeoutInSeconds) });
+ TaskOrchestrationExecutor executor = new(
+ runtimeState,
+ shim,
+ BehaviorOnContinueAsNew.Carryover,
+ request.EntityParameters.ToCore(),
+ ErrorPropagationMode.UseFailureDetails);
+ result = executor.Execute();
+
+ if (addToExtendedSessions && !executor.IsCompleted)
+ {
+ // addToExtendedSessions can only be set to true if extendedSessionsCache is not
+ // null. The shim is now (attempted to be) owned by the cache; it must not be
+ // disposed here since the orchestration may resume via ExecuteNewEvents() on a
+ // future call. Register an eviction callback so it's disposed exactly once,
+ // whenever this entry is removed for any reason (explicit Remove,
+ // sliding-expiration timeout, capacity eviction, or cache disposal).
+ MemoryCacheEntryOptions cacheEntryOptions = new()
+ {
+ SlidingExpiration = TimeSpan.FromSeconds(extendedSessionIdleTimeoutInSeconds),
+ };
+ cacheEntryOptions.RegisterPostEvictionCallback(DisposeEvictedExtendedSession);
+
+ // TrySetCachedValue is synchronized with a concurrent ExtendedSessionsCache.Dispose()
+ // (e.g. during a graceful worker shutdown that races with this in-flight execution).
+ // It returns false -- without inserting anything -- if the cache has already been (or
+ // is concurrently being) disposed, so there is no window in which an entry can be
+ // silently added after the cache has begun tearing down and would then never be
+ // evicted or disposed again. transferredShimToCache reflects the actual outcome, so
+ // the finally block below correctly retains and disposes the shim itself when the
+ // hand-off is rejected.
+ transferredShimToCache = extendedSessionsCache!.TrySetCachedValue(
+ request.InstanceId,
+ new ExtendedSessionState(runtimeState, shim, executor),
+ cacheEntryOptions);
+ }
+ else
+ {
+ extendedSessionsCache?.RemoveCachedValue(request.InstanceId);
+ }
}
- else
+ finally
{
- extendedSessions?.Remove(request.InstanceId);
+ if (!transferredShimToCache)
+ {
+ // This execution either isn't part of an extended session, it completed on its
+ // first execution, or an exception was thrown before ownership could be
+ // transferred to the cache above. In every one of these cases nothing else will
+ // ever use the shim again, so it must be disposed here to release its resources
+ // (e.g. the SHA1 instance cached by NewGuid).
+ (shim as IDisposable)?.Dispose();
+ }
}
}
}
@@ -232,4 +276,16 @@ public static string LoadAndRun(
byte[] responseBytes = response.ToByteArray();
return Convert.ToBase64String(responseBytes);
}
+
+ // Invoked by the extended-sessions MemoryCache whenever a cached ExtendedSessionState entry is
+ // evicted, for any reason (explicit Remove, sliding-expiration timeout, capacity eviction, or the
+ // cache itself being disposed). Disposes the cached shim's resources (e.g. the SHA1 instance used by
+ // NewGuid) exactly once, at the point where the orchestration can no longer resume via this entry.
+ static void DisposeEvictedExtendedSession(object key, object? value, EvictionReason reason, object? state)
+ {
+ if (value is ExtendedSessionState sessionState && sessionState.TaskOrchestration is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
}
diff --git a/src/Worker/Grpc/WorkItemStreamConsumer.cs b/src/Worker/Grpc/WorkItemStreamConsumer.cs
index cce0b23a..9c39ef7b 100644
--- a/src/Worker/Grpc/WorkItemStreamConsumer.cs
+++ b/src/Worker/Grpc/WorkItemStreamConsumer.cs
@@ -60,13 +60,22 @@ internal static class WorkItemStreamConsumer
/// reset retry counters that should only count consecutive transport failures.
///
/// Outer worker cancellation token.
+ ///
+ /// Test-only observability hook invoked synchronously every time the silent-disconnect timer is
+ /// (re-)armed -- once before the read loop starts, and once per item, immediately before that
+ /// item is dispatched. Always in production; lets tests prove the
+ /// per-item reset actually happens (and in what order relative to dispatch) without depending on
+ /// real elapsed time. Never invoked when disables
+ /// detection.
+ ///
/// The classified outcome plus whether any message was observed.
public static async Task ConsumeAsync(
Func> openStream,
TimeSpan silentDisconnectTimeout,
Action onItem,
Action? onFirstMessage,
- CancellationToken cancellation)
+ CancellationToken cancellation,
+ Action? onSilentDisconnectTimerArmed = null)
{
bool silentDisconnectEnabled = silentDisconnectTimeout > TimeSpan.Zero;
@@ -79,6 +88,7 @@ void ArmSilentDisconnectTimer()
if (silentDisconnectEnabled)
{
timeoutSource.CancelAfter(effectiveTimeout);
+ onSilentDisconnectTimerArmed?.Invoke();
}
}
diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs
index e8335e79..5544470c 100644
--- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs
+++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs
@@ -2,7 +2,9 @@
// Licensed under the MIT License.
using System.Collections.Generic;
+using System.Globalization;
using System.Reflection;
+using System.Security.Cryptography;
using DurableTask.Core;
using DurableTask.Core.Serializing.Internal;
using Microsoft.Extensions.Logging.Abstractions;
@@ -391,6 +393,203 @@ await wrapper.CallSubOrchestratorAsync(
innerContext.LastSubOrchestrationVersion.Should().Be(string.Empty);
}
+ [Fact]
+ public void NewGuid_FixedInputs_ProducesStableDeterministicValue()
+ {
+ // Arrange — these golden values were computed independently (offline, using the documented
+ // algorithm: SHA1("9e952958-5e33-4daf-827f-2fa12937b875" bytes + name bytes), with the RFC 4122
+ // byte swaps and version/variant bits applied) for the given instance ID, timestamp, and counter.
+ // This regression test protects replay compatibility: it must keep producing these exact GUIDs.
+ TestOrchestrationContext innerContext = new(
+ "fixed-instance-id",
+ DateTime.Parse("2023-05-06T07:08:09.1234567Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+
+ // Act
+ Guid first = wrapper.NewGuid();
+ Guid second = wrapper.NewGuid();
+ Guid third = wrapper.NewGuid();
+
+ // Assert
+ first.Should().Be(Guid.Parse("0f353f85-75d2-56f8-89b5-a7773ace7605"));
+ second.Should().Be(Guid.Parse("b0fd1465-f3d8-5a7e-98b1-f34137b15060"));
+ third.Should().Be(Guid.Parse("12bec829-d5e1-563c-ac70-9806cad148c1"));
+ }
+
+ [Fact]
+ public void NewGuid_DifferentInstanceId_ProducesDifferentStableDeterministicValue()
+ {
+ // Arrange — same timestamp and counter as the other golden-value test, but a different
+ // instance ID, computed independently the same way. Confirms the instance ID is still part of
+ // the hashed name and that the namespace/algorithm/byte-ordering were not altered.
+ TestOrchestrationContext innerContext = new(
+ "other-instance-id",
+ DateTime.Parse("2023-05-06T07:08:09.1234567Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+
+ // Act
+ Guid result = wrapper.NewGuid();
+
+ // Assert
+ result.Should().Be(Guid.Parse("258d445c-0c1e-594c-a4a1-0a837e4ebe92"));
+ }
+
+ [Fact]
+ public void NewGuid_CalledRepeatedly_ProducesDistinctValuesEachTime()
+ {
+ // Arrange — the internal counter advances on every call, so repeated calls with the same
+ // instance ID and timestamp must still yield distinct GUIDs.
+ TestOrchestrationContext innerContext = new(
+ "repeat-instance-id",
+ DateTime.Parse("2024-01-01T00:00:00.0000000Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+
+ // Act
+ List results = new();
+ for (int i = 0; i < 5; i++)
+ {
+ results.Add(wrapper.NewGuid());
+ }
+
+ // Assert — all five results are distinct from one another.
+ results.Distinct().Should().HaveCount(5);
+ }
+
+ [Fact]
+ public void NewGuid_ReplayingSameHistory_ProducesIdenticalGuidSequence()
+ {
+ // Arrange — simulates replay: two independent wrapper instances (as would be created for two
+ // separate replay passes over the same orchestration history) observe the same instance ID and
+ // the same sequence of CurrentUtcDateTime values as history is replayed.
+ string instanceId = "replay-instance-id";
+ DateTime timestamp = DateTime.Parse("2022-11-11T11:11:11.1111111Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+
+ TestOrchestrationContext innerContext1 = new(instanceId, timestamp);
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper1 = new(innerContext1, invocationContext, "input");
+
+ TestOrchestrationContext innerContext2 = new(instanceId, timestamp);
+ TaskOrchestrationContextWrapper wrapper2 = new(innerContext2, invocationContext, "input");
+
+ // Act — generate the same number of GUIDs from both "replay passes".
+ Guid[] pass1 = [wrapper1.NewGuid(), wrapper1.NewGuid(), wrapper1.NewGuid()];
+ Guid[] pass2 = [wrapper2.NewGuid(), wrapper2.NewGuid(), wrapper2.NewGuid()];
+
+ // Assert — replay must produce an identical sequence of GUIDs given identical inputs.
+ pass2.Should().Equal(pass1);
+ }
+
+ [Fact]
+ public void NewGuid_MultipleCalls_ReuseCachedHashAlgorithmInstance()
+ {
+ // Arrange — verifies the optimization from
+ // https://github.com/microsoft/durabletask-dotnet/issues/778: the SHA1 instance backing
+ // NewGuid() is created once and reused across calls, rather than being constructed and
+ // disposed on every call.
+ TestOrchestrationContext innerContext = new();
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+ FieldInfo cachedHashAlgorithmField = typeof(TaskOrchestrationContextWrapper)
+ .GetField("cachedHashAlgorithm", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ // Act
+ wrapper.NewGuid();
+ object? afterFirstCall = cachedHashAlgorithmField.GetValue(wrapper);
+ wrapper.NewGuid();
+ object? afterSecondCall = cachedHashAlgorithmField.GetValue(wrapper);
+
+ // Assert — the same underlying instance is reused rather than a new one being allocated.
+ afterFirstCall.Should().NotBeNull();
+ afterSecondCall.Should().BeSameAs(afterFirstCall);
+ }
+
+ [Fact]
+ public void Dispose_ReleasesCachedHashAlgorithm()
+ {
+ // Arrange
+ TestOrchestrationContext innerContext = new();
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+ FieldInfo cachedHashAlgorithmField = typeof(TaskOrchestrationContextWrapper)
+ .GetField("cachedHashAlgorithm", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ wrapper.NewGuid();
+ SHA1 cachedInstance = (SHA1)cachedHashAlgorithmField.GetValue(wrapper)!;
+
+ // Act
+ wrapper.Dispose();
+
+ // Assert — the field is cleared, and the underlying instance was actually disposed (not merely
+ // dereferenced), confirmed by it throwing when used afterwards.
+ cachedHashAlgorithmField.GetValue(wrapper).Should().BeNull();
+ Action useAfterDispose = () => cachedInstance.ComputeHash([1, 2, 3]);
+ useAfterDispose.Should().Throw();
+ }
+
+ [Fact]
+ public void Dispose_CalledMultipleTimes_DoesNotThrow()
+ {
+ // Arrange
+ TestOrchestrationContext innerContext = new();
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+ wrapper.NewGuid();
+
+ // Act
+ Action dispose = () =>
+ {
+ wrapper.Dispose();
+ wrapper.Dispose();
+ };
+
+ // Assert — disposing an already-disposed (or never-used) wrapper is safe.
+ dispose.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Dispose_WithoutPriorNewGuidCall_DoesNotThrow()
+ {
+ // Arrange — the cached SHA1 instance is lazily created, so Dispose() must tolerate the case
+ // where NewGuid() was never called.
+ TestOrchestrationContext innerContext = new();
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+
+ // Act
+ Action dispose = () => wrapper.Dispose();
+
+ // Assert
+ dispose.Should().NotThrow();
+ }
+
+ [Fact]
+ public void NewGuid_AfterDispose_StillProducesStableDeterministicValue()
+ {
+ // Arrange — Dispose() releases the cached SHA1 instance, but the wrapper lazily creates a new
+ // one on the next NewGuid() call (via the `??=` pattern). This must still produce byte-identical
+ // GUIDs to the ones computed with a fresh instance, proving disposal does not affect correctness.
+ TestOrchestrationContext innerContext = new(
+ "fixed-instance-id",
+ DateTime.Parse("2023-05-06T07:08:09.1234567Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input");
+
+ // Act
+ wrapper.Dispose(); // dispose before any use is allowed (no-op, since nothing was cached yet)
+ Guid first = wrapper.NewGuid();
+ wrapper.Dispose(); // dispose the now-cached instance mid-sequence
+ Guid second = wrapper.NewGuid(); // should transparently create a new instance and continue correctly
+ Guid third = wrapper.NewGuid();
+
+ // Assert — identical to the golden values in NewGuid_FixedInputs_ProducesStableDeterministicValue.
+ first.Should().Be(Guid.Parse("0f353f85-75d2-56f8-89b5-a7773ace7605"));
+ second.Should().Be(Guid.Parse("b0fd1465-f3d8-5a7e-98b1-f34137b15060"));
+ third.Should().Be(Guid.Parse("12bec829-d5e1-563c-ac70-9806cad148c1"));
+ }
+
static IReadOnlyDictionary GetLastScheduledTaskTags(TrackingOrchestrationContext innerContext)
{
PropertyInfo tagsProperty = innerContext.LastScheduledTaskOptions!.GetType().GetProperty("Tags")!;
@@ -508,15 +707,30 @@ public override void SendEvent(OrchestrationInstance orchestrationInstance, stri
class TestOrchestrationContext : OrchestrationContext
{
+ // Only set when a fixed value is supplied via the constructor overload below; otherwise the
+ // base class's (internally-set) value is used, preserving prior behavior for existing callers.
+ readonly DateTime? fixedCurrentUtcDateTime;
+
public TestOrchestrationContext()
+ : this(Guid.NewGuid().ToString(), currentUtcDateTime: null)
+ {
+ }
+
+ // Allows tests to pin the InstanceId and CurrentUtcDateTime that feed into NewGuid(), since
+ // OrchestrationContext.CurrentUtcDateTime's setter is internal to DurableTask.Core and cannot
+ // be assigned directly from this assembly.
+ public TestOrchestrationContext(string instanceId, DateTime? currentUtcDateTime)
{
this.OrchestrationInstance = new()
{
- InstanceId = Guid.NewGuid().ToString(),
+ InstanceId = instanceId,
ExecutionId = Guid.NewGuid().ToString(),
};
+ this.fixedCurrentUtcDateTime = currentUtcDateTime;
}
+ public override DateTime CurrentUtcDateTime => this.fixedCurrentUtcDateTime ?? base.CurrentUtcDateTime;
+
public override void ContinueAsNew(object input)
{
throw new NotImplementedException();
diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationShimTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationShimTests.cs
new file mode 100644
index 00000000..20167272
--- /dev/null
+++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationShimTests.cs
@@ -0,0 +1,139 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using System.Security.Cryptography;
+using DurableTask.Core;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.DurableTask.Worker.Shims;
+
+public class TaskOrchestrationShimTests
+{
+ static readonly FieldInfo ShimWrapperContextField = typeof(TaskOrchestrationShim)
+ .GetField("wrapperContext", BindingFlags.Instance | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException($"{nameof(TaskOrchestrationShim)}.wrapperContext was not found.");
+
+ static readonly FieldInfo CachedHashAlgorithmField = typeof(TaskOrchestrationContextWrapper)
+ .GetField("cachedHashAlgorithm", BindingFlags.Instance | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException(
+ $"{nameof(TaskOrchestrationContextWrapper)}.cachedHashAlgorithm was not found.");
+
+ [Fact]
+ public void Dispose_ForwardsToWrapperContext_ReleasingCachedHashAlgorithm()
+ {
+ // Arrange. We inject the wrapper context the same way Execute() would, so we can prove that
+ // TaskOrchestrationShim.Dispose() forwards to TaskOrchestrationContextWrapper.Dispose(),
+ // actually releasing the cached SHA1 instance backing the deterministic NewGuid()
+ // optimization from issue #778 (not merely dereferencing it).
+ TaskOrchestrationShim shim = CreateShim();
+ TaskOrchestrationContextWrapper wrapperContext = CreateWrapperContext();
+ wrapperContext.NewGuid(); // Populate the cached SHA1 instance.
+ ShimWrapperContextField.SetValue(shim, wrapperContext);
+
+ SHA1 cachedInstance = (SHA1)CachedHashAlgorithmField.GetValue(wrapperContext)!;
+
+ // Act
+ shim.Dispose();
+
+ // Assert
+ CachedHashAlgorithmField.GetValue(wrapperContext).Should().BeNull();
+ Action useAfterDispose = () => cachedInstance.ComputeHash(new byte[] { 1, 2, 3 });
+ useAfterDispose.Should().Throw();
+ }
+
+ [Fact]
+ public void Dispose_WithoutExecute_DoesNotThrow()
+ {
+ // Arrange. Execute() was never called, so the shim's wrapperContext field is still null. This
+ // must remain a safe no-op (e.g. eviction/teardown paths may run before the shim ever executes).
+ TaskOrchestrationShim shim = CreateShim();
+
+ // Act
+ Action dispose = shim.Dispose;
+
+ // Assert
+ dispose.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Dispose_CalledMultipleTimes_DoesNotThrow()
+ {
+ // Arrange. Both the processor's try/finally and, defensively, an eviction callback could end up
+ // disposing the same shim; disposal must be idempotent.
+ TaskOrchestrationShim shim = CreateShim();
+ TaskOrchestrationContextWrapper wrapperContext = CreateWrapperContext();
+ wrapperContext.NewGuid();
+ ShimWrapperContextField.SetValue(shim, wrapperContext);
+
+ // Act
+ Action dispose = () =>
+ {
+ shim.Dispose();
+ shim.Dispose();
+ };
+
+ // Assert
+ dispose.Should().NotThrow();
+ }
+
+ static TaskOrchestrationShim CreateShim()
+ {
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ return new TaskOrchestrationShim(invocationContext, new NoOpOrchestrator());
+ }
+
+ static TaskOrchestrationContextWrapper CreateWrapperContext()
+ {
+ OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null);
+ TestOrchestrationContext innerContext = new();
+ return new TaskOrchestrationContextWrapper(innerContext, invocationContext, deserializedInput: null);
+ }
+
+ sealed class NoOpOrchestrator : ITaskOrchestrator
+ {
+ public Type InputType => typeof(object);
+
+ public Type OutputType => typeof(object);
+
+ public Task