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() {