diff --git a/src/Orleans.Core.Abstractions/Core/Grain.cs b/src/Orleans.Core.Abstractions/Core/Grain.cs
index 6a2dbd5a6c..41affb7d62 100644
--- a/src/Orleans.Core.Abstractions/Core/Grain.cs
+++ b/src/Orleans.Core.Abstractions/Core/Grain.cs
@@ -135,10 +135,11 @@ protected void MigrateOnIdle()
///
/// Delay Deactivation of this activation at least for the specified time duration.
- /// A positive timeSpan value means “prevent GC of this activation for that time span”.
- /// A negative timeSpan value means “cancel the previous setting of the DelayDeactivation call and make this activation behave based on the regular Activation Garbage Collection settings”.
- /// DeactivateOnIdle method would undo / override any current “keep alive” setting,
- /// making this grain immediately available for deactivation.
+ /// A positive timeSpan value means “prevent GC of this activation for that time span”.
+ /// A value means “cancel the previous setting of the call and make this activation behave based on the regular Activation Garbage Collection settings”.
+ /// A value means “delay deactivation indefinitely”.
+ /// would undo / override any current “keep alive” setting,
+ /// making this grain immediately available for deactivation.
///
protected void DelayDeactivation(TimeSpan timeSpan)
{
diff --git a/src/Orleans.Runtime/Catalog/ActivationData.cs b/src/Orleans.Runtime/Catalog/ActivationData.cs
index 14daae64cd..52a4faabff 100644
--- a/src/Orleans.Runtime/Catalog/ActivationData.cs
+++ b/src/Orleans.Runtime/Catalog/ActivationData.cs
@@ -482,13 +482,20 @@ public void DelayDeactivation(TimeSpan timespan)
{
if (timespan == TimeSpan.MaxValue || timespan == Timeout.InfiniteTimeSpan)
{
- // otherwise creates negative time.
+ // Adding these values to the current time would overflow, so use DateTime.MaxValue directly.
KeepAliveUntil = DateTime.MaxValue;
}
else if (timespan <= TimeSpan.Zero)
{
- // reset any current keepAliveUntil
+ // Cancel the previous DelayDeactivation and revert to normal collection behavior.
+ // If there was an active keep-alive, reschedule collection so the grain can be collected
+ // after CollectionAgeLimit rather than waiting for the previously scheduled far-future time.
+ var hadActiveKeepAlive = KeepAliveUntil > GrainRuntime.TimeProvider.GetUtcNow().UtcDateTime;
ResetKeepAliveRequest();
+ if (hadActiveKeepAlive)
+ {
+ _shared.InternalRuntime.ActivationCollector.TryRescheduleCollection(this);
+ }
}
else
{
diff --git a/src/Orleans.Runtime/Core/InternalGrainRuntime.cs b/src/Orleans.Runtime/Core/InternalGrainRuntime.cs
index d052aefd73..3fb20d38c2 100644
--- a/src/Orleans.Runtime/Core/InternalGrainRuntime.cs
+++ b/src/Orleans.Runtime/Core/InternalGrainRuntime.cs
@@ -19,7 +19,8 @@ internal class InternalGrainRuntime(
CompatibilityDirectorManager compatibilityDirectorManager,
IOptions collectionOptions,
ILocalGrainDirectory localGrainDirectory,
- IActivationWorkingSet activationWorkingSet)
+ IActivationWorkingSet activationWorkingSet,
+ ActivationCollector activationCollector)
{
public InsideRuntimeClient RuntimeClient { get; } = catalog.RuntimeClient;
public MessageCenter MessageCenter { get; } = messageCenter;
@@ -31,5 +32,6 @@ internal class InternalGrainRuntime(
public IOptions CollectionOptions { get; } = collectionOptions;
public ILocalGrainDirectory LocalGrainDirectory { get; } = localGrainDirectory;
public IActivationWorkingSet ActivationWorkingSet { get; } = activationWorkingSet;
+ public ActivationCollector ActivationCollector { get; } = activationCollector;
}
}
diff --git a/test/Grains/TestInternalGrainInterfaces/ActivationGCTestGrainInterfaces.cs b/test/Grains/TestInternalGrainInterfaces/ActivationGCTestGrainInterfaces.cs
index c11f64f218..07e7bd7b3a 100644
--- a/test/Grains/TestInternalGrainInterfaces/ActivationGCTestGrainInterfaces.cs
+++ b/test/Grains/TestInternalGrainInterfaces/ActivationGCTestGrainInterfaces.cs
@@ -38,4 +38,10 @@ public interface IStatelessWorkerActivationCollectorTestGrain1 : IGrainWithGuidK
Task Delay(TimeSpan dt);
Task IdentifyActivation();
}
+
+ public interface IKeepAliveActivationGcTestGrain : IGrainWithGuidKey
+ {
+ Task SetKeepAlive(TimeSpan keepAlive);
+ Task CancelKeepAlive();
+ }
}
diff --git a/test/Grains/TestInternalGrains/ActivationGCTestGrains.cs b/test/Grains/TestInternalGrains/ActivationGCTestGrains.cs
index 51ad43d37f..7c109eff4c 100644
--- a/test/Grains/TestInternalGrains/ActivationGCTestGrains.cs
+++ b/test/Grains/TestInternalGrains/ActivationGCTestGrains.cs
@@ -94,4 +94,19 @@ public Task IdentifyActivation()
}
}
+
+ public class KeepAliveActivationGcTestGrain : Grain, IKeepAliveActivationGcTestGrain
+ {
+ public Task SetKeepAlive(TimeSpan keepAlive)
+ {
+ DelayDeactivation(keepAlive);
+ return Task.CompletedTask;
+ }
+
+ public Task CancelKeepAlive()
+ {
+ DelayDeactivation(TimeSpan.Zero);
+ return Task.CompletedTask;
+ }
+ }
}
diff --git a/test/Orleans.Runtime.Internal.Tests/ActivationsLifeCycleTests/ActivationCollectorTests.cs b/test/Orleans.Runtime.Internal.Tests/ActivationsLifeCycleTests/ActivationCollectorTests.cs
index 6eb34c79ef..5f6dcc0eff 100644
--- a/test/Orleans.Runtime.Internal.Tests/ActivationsLifeCycleTests/ActivationCollectorTests.cs
+++ b/test/Orleans.Runtime.Internal.Tests/ActivationsLifeCycleTests/ActivationCollectorTests.cs
@@ -529,6 +529,36 @@ public async Task ActivationCollectorShouldCollectByCollectionSpecificAgeLimitFo
Assert.Equal(0, activationsNotCollected);
}
+ [Fact, TestCategory("ActivationCollector"), TestCategory("Functional")]
+ public async Task ActivationCollectorShouldCollectAfterCancellingKeepAlive()
+ {
+ await Initialize(DEFAULT_IDLE_TIMEOUT);
+
+ var fullGrainTypeName = RuntimeTypeNameFormatter.Format(typeof(KeepAliveActivationGcTestGrain));
+
+ // Activate the grain and extend its lifetime to 5 minutes.
+ var grain = this.testCluster.GrainFactory.GetGrain(Guid.NewGuid());
+ await grain.SetKeepAlive(TimeSpan.FromMinutes(5));
+
+ // Verify the grain is not collected while it has an active keep-alive.
+ await Task.Delay(WAIT_TIME);
+ int activationsWithKeepAlive = await TestUtils.GetActivationCount(this.testCluster.GrainFactory, fullGrainTypeName);
+ Assert.Equal(1, activationsWithKeepAlive);
+
+ // Cancel the keep-alive. The grain should now be collectable after the standard idle timeout.
+ await grain.CancelKeepAlive();
+
+ // Wait for the grain to become idle past the collection age limit.
+ await Task.Delay(DEFAULT_IDLE_TIMEOUT + DEFAULT_COLLECTION_QUANTUM);
+
+ // Force collection to deterministically verify the grain is now collectable.
+ var mgmtGrain = this.testCluster.GrainFactory.GetGrain(0);
+ await mgmtGrain.ForceActivationCollection(DEFAULT_IDLE_TIMEOUT);
+
+ int activationsNotCollected = await TestUtils.GetActivationCount(this.testCluster.GrainFactory, fullGrainTypeName);
+ Assert.Equal(0, activationsNotCollected);
+ }
+
[Fact, TestCategory("SlowBVT"), TestCategory("Timers")]
public async Task NonReentrantGrainTimer_NoKeepAlive_Test()
{