From 8e0a13ab3d149d6cfbea9e4b7cc3bc55b621843e Mon Sep 17 00:00:00 2001 From: peterstone2017 <12449837+YunchuWang@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:21:59 -0700 Subject: [PATCH 1/2] Add opt-in Timeout to PurgeInstancesFilter for partial purge - Add TimeSpan? Timeout property to PurgeInstancesFilter (opt-in, default null) - Send timeout as google.protobuf.Duration in gRPC request when set - Add timeout field (4) to PurgeInstanceFilter proto message - Zero breaking changes: existing callers unaffected --- src/Client/Core/PurgeInstancesFilter.cs | 8 ++++++++ src/Client/Grpc/GrpcDurableTaskClient.cs | 5 +++++ src/Grpc/orchestrator_service.proto | 1 + 3 files changed, 14 insertions(+) diff --git a/src/Client/Core/PurgeInstancesFilter.cs b/src/Client/Core/PurgeInstancesFilter.cs index 04c7f9c3f..283c56cf2 100644 --- a/src/Client/Core/PurgeInstancesFilter.cs +++ b/src/Client/Core/PurgeInstancesFilter.cs @@ -14,4 +14,12 @@ 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 + /// and returns with set to false. + /// Already-started instance deletions will complete before the method returns. + /// + public TimeSpan? Timeout { get; init; } } diff --git a/src/Client/Grpc/GrpcDurableTaskClient.cs b/src/Client/Grpc/GrpcDurableTaskClient.cs index eba7b9bc0..cd445ad0c 100644 --- a/src/Client/Grpc/GrpcDurableTaskClient.cs +++ b/src/Client/Grpc/GrpcDurableTaskClient.cs @@ -488,6 +488,11 @@ public override Task PurgeAllInstancesAsync( request.PurgeInstanceFilter.RuntimeStatus.AddRange(filter.Statuses.Select(x => x.ToGrpcStatus())); } + if (filter?.Timeout is not null) + { + 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 { From 8cb17f2c9f645712fdef3a2111d3caee00508510 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 19 Mar 2026 14:12:50 -0700 Subject: [PATCH 2/2] Address PR review: add timeout validation, update docs, add unit tests - Add ArgumentOutOfRangeException for zero/negative Timeout in PurgeAllInstancesAsync - Update PurgeInstancesFilter.Timeout XML docs to note backend-dependent semantics - Add 3 unit tests for Timeout validation (negative, zero, positive) --- src/Client/Core/PurgeInstancesFilter.cs | 8 ++-- src/Client/Grpc/GrpcDurableTaskClient.cs | 8 ++++ .../Grpc.Tests/GrpcDurableTaskClientTests.cs | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Client/Core/PurgeInstancesFilter.cs b/src/Client/Core/PurgeInstancesFilter.cs index 283c56cf2..c195025ba 100644 --- a/src/Client/Core/PurgeInstancesFilter.cs +++ b/src/Client/Core/PurgeInstancesFilter.cs @@ -17,9 +17,11 @@ public record PurgeInstancesFilter( /// /// 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 - /// and returns with set to false. - /// Already-started instance deletions will complete before the method returns. + /// 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 cd445ad0c..000197eeb 100644 --- a/src/Client/Grpc/GrpcDurableTaskClient.cs +++ b/src/Client/Grpc/GrpcDurableTaskClient.cs @@ -490,6 +490,14 @@ public override Task PurgeAllInstancesAsync( 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); } 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(); + } }