diff --git a/src/Client/Core/PurgeInstancesFilter.cs b/src/Client/Core/PurgeInstancesFilter.cs index 04c7f9c3f..c195025ba 100644 --- a/src/Client/Core/PurgeInstancesFilter.cs +++ b/src/Client/Core/PurgeInstancesFilter.cs @@ -14,4 +14,14 @@ public record PurgeInstancesFilter( DateTimeOffset? CreatedTo = null, IEnumerable? Statuses = null) { + /// + /// Gets or sets the maximum amount of time to spend purging instances in a single call. + /// If null (default), all matching instances are purged with no time limit. + /// When set, the purge stops accepting new instances after this duration elapses. + /// The value of depends on the backend implementation: + /// it may be false if the purge timed out, true if all instances were purged, + /// or null if the backend does not support reporting completion status. + /// Not all backends support this property; those that do not will ignore it. + /// + public TimeSpan? Timeout { get; init; } } diff --git a/src/Client/Grpc/GrpcDurableTaskClient.cs b/src/Client/Grpc/GrpcDurableTaskClient.cs index eba7b9bc0..000197eeb 100644 --- a/src/Client/Grpc/GrpcDurableTaskClient.cs +++ b/src/Client/Grpc/GrpcDurableTaskClient.cs @@ -488,6 +488,19 @@ public override Task PurgeAllInstancesAsync( request.PurgeInstanceFilter.RuntimeStatus.AddRange(filter.Statuses.Select(x => x.ToGrpcStatus())); } + if (filter?.Timeout is not null) + { + if (filter.Timeout.Value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(filter), + filter.Timeout.Value, + "Timeout must be a positive TimeSpan."); + } + + request.PurgeInstanceFilter.Timeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(filter.Timeout.Value); + } + return this.PurgeInstancesCoreAsync(request, cancellation); } diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 0c34d986d..70ab6de24 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -517,6 +517,7 @@ message PurgeInstanceFilter { google.protobuf.Timestamp createdTimeFrom = 1; google.protobuf.Timestamp createdTimeTo = 2; repeated OrchestrationStatus runtimeStatus = 3; + google.protobuf.Duration timeout = 4; } message PurgeInstancesResponse { diff --git a/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs b/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs index 8d098106c..f3da85ebf 100644 --- a/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs +++ b/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs @@ -127,5 +127,44 @@ public async Task ScheduleNewOrchestrationInstanceAsync_ValidDedupeStatus_DoesNo var exception = await act.Should().ThrowAsync(); exception.Which.Should().NotBeOfType(); } + + [Fact] + public async Task PurgeAllInstancesAsync_NegativeTimeout_ThrowsArgumentOutOfRangeException() + { + // Arrange + var client = this.CreateClient(); + var filter = new PurgeInstancesFilter { Timeout = TimeSpan.FromSeconds(-1) }; + + // Act & Assert + Func act = async () => await client.PurgeAllInstancesAsync(filter); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("Timeout must be a positive TimeSpan."); + } + + [Fact] + public async Task PurgeAllInstancesAsync_ZeroTimeout_ThrowsArgumentOutOfRangeException() + { + // Arrange + var client = this.CreateClient(); + var filter = new PurgeInstancesFilter { Timeout = TimeSpan.Zero }; + + // Act & Assert + Func act = async () => await client.PurgeAllInstancesAsync(filter); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("Timeout must be a positive TimeSpan."); + } + + [Fact] + public async Task PurgeAllInstancesAsync_PositiveTimeout_DoesNotThrowValidationError() + { + // Arrange + var client = this.CreateClient(); + var filter = new PurgeInstancesFilter { Timeout = TimeSpan.FromSeconds(30) }; + + // Act & Assert - validation should pass; the call will fail at gRPC level, not validation + Func act = async () => await client.PurgeAllInstancesAsync(filter); + var exception = await act.Should().ThrowAsync(); + exception.Which.Should().NotBeOfType(); + } }