From e40c434b5ff2c3ee229a48a202dcae6365d689b0 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 22 Sep 2026 16:18:11 -0700 Subject: [PATCH 1/6] Add opt-in version exemptions for unversioned orchestrations Allow hosts to exempt exact registered infrastructure orchestration names from worker version checks only for null or empty execution versions. Preserve existing policies, execution identities, and constructor signatures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...kOrchestrationDispatcherVersioningTests.cs | 373 ++++++++++++++++++ .../VersionSettingsTests.cs | 27 ++ docs/features/versioning.md | 16 + .../Settings/VersioningSettings.cs | 14 + .../TaskOrchestrationDispatcher.cs | 4 +- 5 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs diff --git a/Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs b/Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs new file mode 100644 index 000000000..c56ec6418 --- /dev/null +++ b/Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs @@ -0,0 +1,373 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.Core.Tests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using DurableTask.Core.Exceptions; + using DurableTask.Core.History; + using DurableTask.Core.Logging; + using DurableTask.Core.Middleware; + using DurableTask.Core.Settings; + using DurableTask.Emulator; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using static DurableTask.Core.Settings.VersioningSettings; + + [TestClass] + public class TaskOrchestrationDispatcherVersioningTests + { + const string InternalName = "Internal.Orchestration"; + + [TestMethod] + [DataRow(VersionMatchStrategy.Strict, null, false)] + [DataRow(VersionMatchStrategy.Strict, "", false)] + [DataRow(VersionMatchStrategy.Strict, "1.0", true)] + [DataRow(VersionMatchStrategy.Strict, "1.0.0", false)] + [DataRow(VersionMatchStrategy.Strict, "2.0", false)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, null, true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "0.9", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "1.0", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "2.0", false)] + [DataRow(VersionMatchStrategy.None, "", true)] + [DataRow(VersionMatchStrategy.None, "2.0", true)] + public async Task BusinessOrchestrationsRetainVersionPolicies( + VersionMatchStrategy matchStrategy, string version, bool shouldExecute) + { + foreach (VersionFailureStrategy failureStrategy in Enum.GetValues(typeof(VersionFailureStrategy))) + { + foreach (bool configureExclusion in new[] { false, true }) + { + var settings = new VersioningSettings + { + Version = "1.0", + MatchStrategy = matchStrategy, + FailureStrategy = failureStrategy, + }; + if (configureExclusion) + { + settings.ExcludedOrchestrationNames.Add(InternalName); + } + + await AssertVersionPolicyAsync(settings, "Business.Orchestration", version, shouldExecute); + } + } + } + + [TestMethod] + [DataRow(InternalName, true)] + [DataRow("internal.orchestration", false)] + [DataRow("Internal.Orchestration.Child", false)] + [DataRow("Internal", false)] + [DataRow("Business.Orchestration", false)] + public async Task OnlyExactExcludedUnversionedNamesExecute(string name, bool shouldExecute) + { + foreach (VersionFailureStrategy failureStrategy in Enum.GetValues(typeof(VersionFailureStrategy))) + { + foreach (string version in new[] { null, string.Empty }) + { + var settings = CreateSettings(failureStrategy); + await AssertVersionPolicyAsync(settings, name, version, shouldExecute); + } + } + } + + [TestMethod] + [DataRow(VersionMatchStrategy.Strict, "1.0", true)] + [DataRow(VersionMatchStrategy.Strict, "2.0", false)] + [DataRow(VersionMatchStrategy.Strict, " ", false)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "0.9", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "2.0", false)] + public async Task ExcludedNamesWithNonemptyVersionsRetainVersionPolicies( + VersionMatchStrategy matchStrategy, string version, bool shouldExecute) + { + foreach (VersionFailureStrategy failureStrategy in Enum.GetValues(typeof(VersionFailureStrategy))) + { + var settings = CreateSettings(failureStrategy); + settings.MatchStrategy = matchStrategy; + await AssertVersionPolicyAsync(settings, InternalName, version, shouldExecute); + } + } + + [TestMethod] + public async Task NullAndDefaultSettingsKeepVersioningDisabled() + { + foreach (VersioningSettings settings in new[] { null, new VersioningSettings() }) + { + await AssertVersionPolicyAsync(settings, InternalName, string.Empty, shouldExecute: true); + await AssertVersionPolicyAsync(settings, "Business.Orchestration", "2.0", shouldExecute: true); + } + } + + [TestMethod] + public async Task ExclusionDoesNotBypassOrchestrationResolution() + { + using var service = new RecordingOrchestrationService(); + var dispatcher = new TestDispatcher( + service, new NameVersionObjectManager(), + new DispatchMiddlewarePipeline(), CreateSettings(VersionFailureStrategy.Fail)); + TaskOrchestrationWorkItem workItem = CreateWorkItem(InternalName, string.Empty); + + await Assert.ThrowsExceptionAsync(() => dispatcher.ProcessAsync(workItem)); + + Assert.AreEqual(0, service.AbandonCount); + Assert.IsNull(service.CompletedState); + Assert.AreEqual(string.Empty, workItem.OrchestrationRuntimeState.Version); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public async Task ExclusionAppliesToReplayAndContinueAsNewWithoutChangingVersion(string version) + { + using var service = new RecordingOrchestrationService(); + var orchestration = new TimerOrchestration(); + var manager = new NameVersionObjectManager(); + manager.Add(new NameValueObjectCreator(InternalName, version, orchestration)); + var pipeline = new DispatchMiddlewarePipeline(); + int middlewareCalls = 0; + pipeline.Add((context, next) => + { + OrchestrationRuntimeState state = context.GetProperty(); + Assert.AreEqual(InternalName, state.Name); + Assert.AreEqual(version, state.Version); + middlewareCalls++; + return next(); + }); + var dispatcher = new TestDispatcher(service, manager, pipeline, CreateSettings(VersionFailureStrategy.Fail)); + TaskOrchestrationWorkItem workItem = CreateWorkItem(InternalName, version); + + await dispatcher.ProcessAsync(workItem); + for (int generation = 0; generation < 3; generation++) + { + Assert.AreEqual(OrchestrationStatus.Running, service.CompletedState.OrchestrationStatus); + string executionId = service.CompletedState.OrchestrationInstance.ExecutionId; + TaskMessage timerMessage = service.TimerMessages.Single(); + Assert.IsInstanceOfType(timerMessage.Event, typeof(TimerFiredEvent)); + workItem = new TaskOrchestrationWorkItem + { + InstanceId = workItem.InstanceId, + LockedUntilUtc = DateTime.MaxValue, + OrchestrationRuntimeState = new OrchestrationRuntimeState(service.CompletedRuntimeState.Events), + NewMessages = new[] { timerMessage }, + }; + + await dispatcher.ProcessAsync(workItem); + + Assert.AreEqual(0, service.AbandonCount); + Assert.AreEqual(version, service.CompletedState.Version); + if (generation < 2) + { + Assert.AreNotEqual(executionId, service.CompletedState.OrchestrationInstance.ExecutionId); + } + } + + Assert.AreEqual(OrchestrationStatus.Completed, service.CompletedState.OrchestrationStatus); + Assert.AreEqual("2", service.CompletedState.Output); + Assert.AreEqual(6, orchestration.ExecutionCount); + Assert.AreEqual(6, middlewareCalls); + } + + [TestMethod] + [DataRow(VersionFailureStrategy.Reject)] + [DataRow(VersionFailureStrategy.Fail)] + public async Task ContinueAsNewToNonemptyVersionReappliesVersionPolicy(VersionFailureStrategy failureStrategy) + { + using var service = new RecordingOrchestrationService(); + var orchestration = new VersionChangingOrchestration(); + var manager = new NameVersionObjectManager(); + manager.Add(new NameValueObjectCreator(InternalName, string.Empty, orchestration)); + manager.Add(new NameValueObjectCreator(InternalName, "2.0", orchestration)); + var dispatcher = new TestDispatcher(service, manager, new DispatchMiddlewarePipeline(), CreateSettings(failureStrategy)); + TaskOrchestrationWorkItem workItem = CreateWorkItem(InternalName, string.Empty); + + await dispatcher.ProcessAsync(workItem); + + Assert.AreEqual(1, orchestration.ExecutionCount); + Assert.AreEqual("2.0", workItem.OrchestrationRuntimeState.Version); + AssertVersionFailure(service, failureStrategy); + } + + static VersioningSettings CreateSettings(VersionFailureStrategy failureStrategy) + { + var settings = new VersioningSettings + { + Version = "1.0", + MatchStrategy = VersionMatchStrategy.Strict, + FailureStrategy = failureStrategy, + }; + settings.ExcludedOrchestrationNames.Add(InternalName); + return settings; + } + + static async Task AssertVersionPolicyAsync(VersioningSettings settings, string name, string version, bool shouldExecute) + { + using var service = new RecordingOrchestrationService(); + var orchestration = new CompletingOrchestration(); + var manager = new NameVersionObjectManager(); + manager.Add(new NameValueObjectCreator(name, version, orchestration)); + var pipeline = new DispatchMiddlewarePipeline(); + int middlewareCalls = 0; + pipeline.Add((context, next) => + { + Assert.AreSame(orchestration, context.GetProperty()); + Assert.AreEqual(version, context.GetProperty().Version); + middlewareCalls++; + return next(); + }); + var dispatcher = new TestDispatcher(service, manager, pipeline, settings); + TaskOrchestrationWorkItem workItem = CreateWorkItem(name, version); + + await dispatcher.ProcessAsync(workItem); + + Assert.AreEqual(shouldExecute ? 1 : 0, orchestration.ExecutionCount); + Assert.AreEqual(shouldExecute ? 1 : 0, middlewareCalls); + Assert.AreEqual(version, workItem.OrchestrationRuntimeState.Version); + if (shouldExecute) + { + Assert.AreEqual(0, service.AbandonCount); + Assert.AreEqual(OrchestrationStatus.Completed, service.CompletedState.OrchestrationStatus); + Assert.AreEqual("0", service.CompletedState.Output); + } + else + { + AssertVersionFailure(service, settings.FailureStrategy); + } + } + + static void AssertVersionFailure(RecordingOrchestrationService service, VersionFailureStrategy failureStrategy) + { + if (failureStrategy == VersionFailureStrategy.Reject) + { + Assert.AreEqual(1, service.AbandonCount); + Assert.IsNull(service.CompletedState); + } + else + { + Assert.AreEqual(0, service.AbandonCount); + Assert.AreEqual(OrchestrationStatus.Failed, service.CompletedState.OrchestrationStatus); + Assert.AreEqual("VersionMismatch", service.CompletedState.FailureDetails.ErrorType); + } + } + + static TaskOrchestrationWorkItem CreateWorkItem(string name, string version) + { + var instance = new OrchestrationInstance + { + InstanceId = Guid.NewGuid().ToString(), + ExecutionId = Guid.NewGuid().ToString(), + }; + return new TaskOrchestrationWorkItem + { + InstanceId = instance.InstanceId, + LockedUntilUtc = DateTime.MaxValue, + OrchestrationRuntimeState = new OrchestrationRuntimeState(), + NewMessages = new[] + { + new TaskMessage + { + OrchestrationInstance = instance, + Event = new ExecutionStartedEvent(-1, "0") + { + Name = name, + Version = version, + OrchestrationInstance = instance, + }, + }, + }, + }; + } + + sealed class TestDispatcher : TaskOrchestrationDispatcher + { + public TestDispatcher( + IOrchestrationService service, + INameVersionObjectManager manager, + DispatchMiddlewarePipeline pipeline, + VersioningSettings settings) + : base(service, manager, pipeline, new LogHelper(null), ErrorPropagationMode.UseFailureDetails, settings, null) + { + } + + public Task ProcessAsync(TaskOrchestrationWorkItem workItem) => this.OnProcessWorkItemAsync(workItem); + } + + sealed class RecordingOrchestrationService : LocalOrchestrationService, IOrchestrationService + { + public int AbandonCount { get; private set; } + public OrchestrationState CompletedState { get; private set; } + public OrchestrationRuntimeState CompletedRuntimeState { get; private set; } + public IList TimerMessages { get; private set; } + + public new Task AbandonTaskOrchestrationWorkItemAsync(TaskOrchestrationWorkItem workItem) + { + this.AbandonCount++; + return Task.CompletedTask; + } + + public new Task CompleteTaskOrchestrationWorkItemAsync( + TaskOrchestrationWorkItem workItem, OrchestrationRuntimeState newOrchestrationRuntimeState, + IList outboundMessages, IList orchestratorMessages, + IList timerMessages, TaskMessage continuedAsNewMessage, OrchestrationState state) + { + this.CompletedState = state; + this.CompletedRuntimeState = newOrchestrationRuntimeState; + this.TimerMessages = timerMessages; + return Task.CompletedTask; + } + } + + sealed class CompletingOrchestration : TaskOrchestration + { + public int ExecutionCount { get; private set; } + + public override Task RunTask(OrchestrationContext context, int input) + { + this.ExecutionCount++; + return Task.FromResult(input); + } + } + + sealed class TimerOrchestration : TaskOrchestration + { + public int ExecutionCount { get; private set; } + + public override async Task RunTask(OrchestrationContext context, int input) + { + this.ExecutionCount++; + await context.CreateTimer(context.CurrentUtcDateTime.AddMinutes(1), input); + if (input < 2) + { + context.ContinueAsNew(input + 1); + } + + return input; + } + } + + sealed class VersionChangingOrchestration : TaskOrchestration + { + public int ExecutionCount { get; private set; } + + public override Task RunTask(OrchestrationContext context, int input) + { + this.ExecutionCount++; + context.ContinueAsNew("2.0", input); + return Task.FromResult(input); + } + } + } +} diff --git a/Test/DurableTask.Core.Tests/VersionSettingsTests.cs b/Test/DurableTask.Core.Tests/VersionSettingsTests.cs index 44128164d..f3b47b765 100644 --- a/Test/DurableTask.Core.Tests/VersionSettingsTests.cs +++ b/Test/DurableTask.Core.Tests/VersionSettingsTests.cs @@ -19,6 +19,33 @@ namespace DurableTask.Core.Tests [TestClass] public class VersionSettingsTests { + [TestMethod] + public void ExcludedOrchestrationNamesAreEmptyAndInstanceScopedByDefault() + { + var settings = new VersioningSettings(); + var otherSettings = new VersioningSettings(); + + Assert.AreEqual(0, settings.ExcludedOrchestrationNames.Count); + settings.ExcludedOrchestrationNames.Add("Internal.Orchestration"); + + Assert.AreEqual(0, otherSettings.ExcludedOrchestrationNames.Count); + Assert.AreEqual(string.Empty, settings.Version); + Assert.AreEqual(VersioningSettings.VersionMatchStrategy.None, settings.MatchStrategy); + Assert.AreEqual(VersioningSettings.VersionFailureStrategy.Reject, settings.FailureStrategy); + } + + [TestMethod] + public void ExcludedOrchestrationNamesUseExactOrdinalMatching() + { + var settings = new VersioningSettings(); + Assert.IsTrue(settings.ExcludedOrchestrationNames.Add("Internal.Orchestration")); + Assert.IsFalse(settings.ExcludedOrchestrationNames.Add("Internal.Orchestration")); + + Assert.IsTrue(settings.ExcludedOrchestrationNames.Contains("Internal.Orchestration")); + Assert.IsFalse(settings.ExcludedOrchestrationNames.Contains("internal.orchestration")); + Assert.IsFalse(settings.ExcludedOrchestrationNames.Contains("Internal.Orchestration.Child")); + } + [TestMethod] [DataRow("1.0.0", "1.0.0", 0)] [DataRow("1.1.0", "1.0.0", 1)] diff --git a/docs/features/versioning.md b/docs/features/versioning.md index 0eb6d21c4..84eb7e801 100644 --- a/docs/features/versioning.md +++ b/docs/features/versioning.md @@ -224,6 +224,22 @@ var instance = await client.CreateOrchestrationInstanceAsync( | `Reject` | Default. Abandon the work item so another worker can pick it up (or retry later). | | `Fail` | Fail the orchestration with a `VersionMismatch` error. | +#### Excluding Unversioned Infrastructure Orchestrations + +A host that registers infrastructure orchestrations alongside business orchestrations can exclude specific unversioned infrastructure orchestration names from worker version checks: + +```csharp +// Use the exact name of an explicitly registered unversioned infrastructure orchestration. +// Configure exclusions before starting the worker. +versioningSettings.ExcludedOrchestrationNames.Add(registeredInfrastructureOrchestrationName); +``` + +`ExcludedOrchestrationNames` is empty by default and uses exact, case-sensitive ordinal matching, not prefixes or patterns. An exclusion applies only when the execution version is `null` or the empty string. Nonempty versions, including whitespace, still follow the configured version matching and failure strategies even when the name is in the set. Business orchestration names not in the set retain the same version checks. + +The exclusion skips only the worker version check, which runs before orchestration middleware and execution. It does not register an orchestration, change its execution version or history, or bypass name/version lookup. Register and start these infrastructure orchestrations with their explicit unversioned identity. The same check applies on replay and after `ContinueAsNew`; continuing with a nonempty version re-enables the normal version policy. + +Do not modify the set while the worker is running. + #### Blue-Green Deployment Example Run old and new workers simultaneously during deployments: diff --git a/src/DurableTask.Core/Settings/VersioningSettings.cs b/src/DurableTask.Core/Settings/VersioningSettings.cs index 41b00d161..c6c3284f7 100644 --- a/src/DurableTask.Core/Settings/VersioningSettings.cs +++ b/src/DurableTask.Core/Settings/VersioningSettings.cs @@ -12,6 +12,7 @@ // ---------------------------------------------------------------------------------- using System; +using System.Collections.Generic; namespace DurableTask.Core.Settings { @@ -72,6 +73,19 @@ public enum VersionFailureStrategy /// public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject; + /// + /// Gets the orchestration names whose unversioned executions are excluded from worker version checks. + /// + /// + /// This set is empty by default and matches names using an exact, case-sensitive ordinal comparison. + /// An exclusion applies only when the execution version is null or empty; nonempty versions still + /// follow and . + /// Use this for explicitly registered, unversioned infrastructure orchestrations, not business orchestrations. + /// Exclusions do not change execution versions or orchestration registration and lookup. + /// Configure this set before starting the worker and do not modify it while the worker is running. + /// + public ISet ExcludedOrchestrationNames { get; } = new HashSet(StringComparer.Ordinal); + /// /// Compare two versions to each other. /// diff --git a/src/DurableTask.Core/TaskOrchestrationDispatcher.cs b/src/DurableTask.Core/TaskOrchestrationDispatcher.cs index 649e7b47a..679d37774 100644 --- a/src/DurableTask.Core/TaskOrchestrationDispatcher.cs +++ b/src/DurableTask.Core/TaskOrchestrationDispatcher.cs @@ -398,7 +398,9 @@ protected async Task OnProcessWorkItemAsync(TaskOrchestrationWorkItem work IReadOnlyList decisions = new List(); bool versioningFailed = false; - if (this.versioningSettings != null) + if (this.versioningSettings != null && + !(string.IsNullOrEmpty(runtimeState.Version) && + this.versioningSettings.ExcludedOrchestrationNames.Contains(runtimeState.Name))) { switch (this.versioningSettings.MatchStrategy) { From 767e78e45c85eac710934a2e74da2a4e21cd8a63 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 24 Sep 2026 09:01:42 -0700 Subject: [PATCH 2/6] Add optional large payload purge service capability Move the shared purge models into Core with their existing null-only token checks and disposition values. Add an optional BCL-only service client interface without changing existing client contracts or the version exemption. Copilot-Session: 883b4cbd-e93c-4d4e-8cb8-ffcf69525cfa Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../LargePayloadPurgeTests.cs | 110 ++++++++++++++++++ docs/providers/custom-provider.md | 14 +++ ...estrationServiceLargePayloadPurgeClient.cs | 55 +++++++++ .../LargePayloadPurgeDisposition.cs | 49 ++++++++ .../LargePayloadPurgeResult.cs | 53 +++++++++ .../LargePayloadPurgeTombstone.cs | 53 +++++++++ 6 files changed, 334 insertions(+) create mode 100644 Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs create mode 100644 src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs create mode 100644 src/DurableTask.Core/LargePayloadPurgeDisposition.cs create mode 100644 src/DurableTask.Core/LargePayloadPurgeResult.cs create mode 100644 src/DurableTask.Core/LargePayloadPurgeTombstone.cs diff --git a/Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs b/Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs new file mode 100644 index 000000000..46ee1e20e --- /dev/null +++ b/Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs @@ -0,0 +1,110 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.Core.Tests +{ + using System; + using System.Threading.Tasks; + using DurableTask.Emulator; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class LargePayloadPurgeTests + { + [TestMethod] + [DataRow("tombstone-token", "payload-token")] + [DataRow("", "")] + [DataRow(" \t\r\n ", "\t ")] + [DataRow("Case/Sensitive+Token=", "%2Fopaque+payload==")] + public void TombstonePreservesOpaqueTokens(string tombstoneToken, string payloadToken) + { + var tombstone = new LargePayloadPurgeTombstone(tombstoneToken, payloadToken); + + Assert.AreEqual(tombstoneToken, tombstone.TombstoneToken); + Assert.AreEqual(payloadToken, tombstone.PayloadToken); + } + + [TestMethod] + [DataRow(null, "payload", "tombstoneToken")] + [DataRow("tombstone", null, "payloadToken")] + [DataRow(null, null, "tombstoneToken")] + public void TombstoneRejectsNullTokens(string tombstoneToken, string payloadToken, string parameterName) + { + ArgumentNullException exception = Assert.ThrowsException( + () => new LargePayloadPurgeTombstone(tombstoneToken, payloadToken)); + + Assert.AreEqual(parameterName, exception.ParamName); + } + + [TestMethod] + [DataRow("", 0)] + [DataRow(" \t\r\n ", 1)] + [DataRow("Case/Sensitive+Token=", 2)] + [DataRow("tombstone-token", 3)] + [DataRow("tombstone-token", -1)] + [DataRow("tombstone-token", 4)] + [DataRow("tombstone-token", int.MaxValue)] + public void ResultPreservesOpaqueTokenAndUnvalidatedDisposition(string tombstoneToken, int disposition) + { + var result = new LargePayloadPurgeResult(tombstoneToken, (LargePayloadPurgeDisposition)disposition); + + Assert.AreEqual(tombstoneToken, result.TombstoneToken); + Assert.AreEqual(disposition, (int)result.Disposition); + } + + [TestMethod] + public void ResultRejectsNullToken() + { + ArgumentNullException exception = Assert.ThrowsException( + () => new LargePayloadPurgeResult(null, LargePayloadPurgeDisposition.Deleted)); + + Assert.AreEqual("tombstoneToken", exception.ParamName); + } + + [TestMethod] + public void DispositionValuesRetainNumericContract() + { + Assert.AreEqual(0, (int)LargePayloadPurgeDisposition.Unspecified); + Assert.AreEqual(1, (int)LargePayloadPurgeDisposition.Deleted); + Assert.AreEqual(2, (int)LargePayloadPurgeDisposition.Retry); + Assert.AreEqual(3, (int)LargePayloadPurgeDisposition.Quarantined); + } + + [TestMethod] + public void ModelsRetainReferenceEquality() + { + var tombstone = new LargePayloadPurgeTombstone("tombstone", "payload"); + var result = new LargePayloadPurgeResult("tombstone", LargePayloadPurgeDisposition.Deleted); + + Assert.AreNotEqual(tombstone, new LargePayloadPurgeTombstone("tombstone", "payload")); + Assert.AreNotEqual(result, new LargePayloadPurgeResult("tombstone", LargePayloadPurgeDisposition.Deleted)); + } + + [TestMethod] + public async Task ExistingServiceClientWorksWithoutOptionalLargePayloadPurgeCapability() + { + using var service = new LocalOrchestrationService(); + IOrchestrationServiceClient serviceClient = service; + Assert.IsFalse(serviceClient is IOrchestrationServiceLargePayloadPurgeClient); + + var client = new TaskHubClient(serviceClient); + OrchestrationInstance instance = await client.CreateOrchestrationInstanceAsync( + "Business.Orchestration", string.Empty, input: null); + OrchestrationState state = await client.GetOrchestrationStateAsync(instance); + + Assert.AreEqual(instance.InstanceId, state.OrchestrationInstance.InstanceId); + Assert.AreEqual("Business.Orchestration", state.Name); + Assert.AreEqual(OrchestrationStatus.Pending, state.OrchestrationStatus); + } + } +} diff --git a/docs/providers/custom-provider.md b/docs/providers/custom-provider.md index 7b49636b5..1015b9fd2 100644 --- a/docs/providers/custom-provider.md +++ b/docs/providers/custom-provider.md @@ -104,6 +104,20 @@ public interface IOrchestrationServiceClient > [!NOTE] > Most providers implement both interfaces in a single class. +### Optional Large Payload Purge Capability + +Service clients that support purging tombstoned large payloads can additionally implement `DurableTask.Core.IOrchestrationServiceLargePayloadPurgeClient`, following the optional capability pattern used by `IOrchestrationServicePurgeClient`. Existing implementations of `IOrchestrationServiceClient` do not need to change. + +| Method | Purpose | +| ------ | ------- | +| `SetLargePayloadAutoPurgeAsync` | Explicitly enable or disable large payload auto-purge for the client's task hub. | +| `GetLargePayloadsToPurgeAsync` | Fetch up to the requested limit of tombstones ready for processing. | +| `ReportLargePayloadPurgeResultsAsync` | Report the worker's outcomes using the unchanged tombstone correlation tokens. | + +Each operation accepts a UTC deadline (`DateTime.MaxValue` for no deadline) and a cancellation token. The service implementation owns transport, deadline/cancellation handling, disposition validation, and retry scheduling; the Core interface adds no worker or automatic setup. + +`LargePayloadPurgeTombstone` and `LargePayloadPurgeResult` are sealed, immutable Core models. Their constructors reject null tokens but preserve all other token strings without interpreting or normalizing them, including empty and whitespace values. Results retain the supplied `LargePayloadPurgeDisposition` without validating it. The numeric values are `Unspecified = 0`, `Deleted = 1`, `Retry = 2`, and `Quarantined = 3`; the backing service validates reported dispositions rather than treating an unspecified or unknown outcome as success. + ## Minimal Implementation Here's a skeleton for a custom provider: diff --git a/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs b/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs new file mode 100644 index 000000000..3240f9d30 --- /dev/null +++ b/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs @@ -0,0 +1,55 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.Core +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Optional orchestration service client capability for purging tombstoned large payloads. + /// + public interface IOrchestrationServiceLargePayloadPurgeClient + { + /// + /// Enables or disables large payload auto-purge for the client's task hub. + /// + /// Whether large payload auto-purge is enabled. + /// The operation deadline in UTC, or for no deadline. + /// The token used to cancel the operation. + /// A task that represents the operation. + Task SetLargePayloadAutoPurgeAsync(bool enabled, DateTime deadlineUtc, CancellationToken cancellationToken); + + /// + /// Gets tombstoned large payloads that are ready to be purged. + /// + /// The maximum number of tombstones to return. + /// The operation deadline in UTC, or for no deadline. + /// The token used to cancel the operation. + /// The tombstones to process. + Task> GetLargePayloadsToPurgeAsync( + int limit, DateTime deadlineUtc, CancellationToken cancellationToken); + + /// + /// Reports the outcomes of attempts to purge tombstoned large payloads. + /// + /// The purge outcomes, including the unchanged tombstone correlation tokens. + /// The operation deadline in UTC, or for no deadline. + /// The token used to cancel the operation. + /// A task that represents the operation. + Task ReportLargePayloadPurgeResultsAsync( + IReadOnlyList results, DateTime deadlineUtc, CancellationToken cancellationToken); + } +} diff --git a/src/DurableTask.Core/LargePayloadPurgeDisposition.cs b/src/DurableTask.Core/LargePayloadPurgeDisposition.cs new file mode 100644 index 000000000..870448076 --- /dev/null +++ b/src/DurableTask.Core/LargePayloadPurgeDisposition.cs @@ -0,0 +1,49 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Relocated from Azure/azure-functions-durable-extension under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE + +namespace DurableTask.Core +{ + /// + /// The outcome of a worker's attempt to purge a tombstoned large payload. + /// + public enum LargePayloadPurgeDisposition + { + /// + /// No outcome was specified. The backing service must reject this value, not treat it as success. + /// + Unspecified = 0, + + /// + /// Terminal success: the payload was deleted, was already absent, or is not owned by the payload store. + /// + Deleted = 1, + + /// + /// A potentially transient failure. The backing service schedules another attempt. + /// + Retry = 2, + + /// + /// A deterministic failure. Preserve the tombstone for operator action without retrying it. + /// + Quarantined = 3, + } +} diff --git a/src/DurableTask.Core/LargePayloadPurgeResult.cs b/src/DurableTask.Core/LargePayloadPurgeResult.cs new file mode 100644 index 000000000..81ed01a05 --- /dev/null +++ b/src/DurableTask.Core/LargePayloadPurgeResult.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Relocated from Azure/azure-functions-durable-extension under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE + +#nullable enable +namespace DurableTask.Core +{ + using System; + + /// + /// The worker's purge outcome for one tombstone. + /// + public sealed class LargePayloadPurgeResult + { + /// + /// Initializes a new instance of the class. + /// + /// The opaque correlation token echoed from the tombstone. + /// The worker's classification of the purge outcome. + public LargePayloadPurgeResult(string tombstoneToken, LargePayloadPurgeDisposition disposition) + { + this.TombstoneToken = tombstoneToken ?? throw new ArgumentNullException(nameof(tombstoneToken)); + this.Disposition = disposition; + } + + /// + /// Gets the unchanged tombstone correlation token. + /// + public string TombstoneToken { get; } + + /// + /// Gets the purge outcome. Validation and retry scheduling belong to the backing service. + /// + public LargePayloadPurgeDisposition Disposition { get; } + } +} diff --git a/src/DurableTask.Core/LargePayloadPurgeTombstone.cs b/src/DurableTask.Core/LargePayloadPurgeTombstone.cs new file mode 100644 index 000000000..73f41d9d6 --- /dev/null +++ b/src/DurableTask.Core/LargePayloadPurgeTombstone.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Relocated from Azure/azure-functions-durable-extension under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE + +#nullable enable +namespace DurableTask.Core +{ + using System; + + /// + /// A tombstoned large payload to be purged by the worker. + /// + public sealed class LargePayloadPurgeTombstone + { + /// + /// Initializes a new instance of the class. + /// + /// The opaque backend-issued correlation token. + /// The unchanged external payload token. + public LargePayloadPurgeTombstone(string tombstoneToken, string payloadToken) + { + this.TombstoneToken = tombstoneToken ?? throw new ArgumentNullException(nameof(tombstoneToken)); + this.PayloadToken = payloadToken ?? throw new ArgumentNullException(nameof(payloadToken)); + } + + /// + /// Gets the opaque correlation token that must be echoed unchanged in the purge result. + /// + public string TombstoneToken { get; } + + /// + /// Gets the external payload token, which must not be interpreted or normalized by intermediaries. + /// + public string PayloadToken { get; } + } +} From f32124acbfa9f66f30886559355a018cc916a983 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 24 Sep 2026 09:03:01 -0700 Subject: [PATCH 3/6] Clarify large payload purge deadline and setup semantics Document that DateTime.MaxValue leaves the deadline unspecified by the caller and that the backing service may apply a default. Setting auto-purge records the choice without managing a runner. Copilot-Session: 883b4cbd-e93c-4d4e-8cb8-ffcf69525cfa Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/providers/custom-provider.md | 4 ++-- .../IOrchestrationServiceLargePayloadPurgeClient.cs | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/providers/custom-provider.md b/docs/providers/custom-provider.md index 1015b9fd2..9ca7135bb 100644 --- a/docs/providers/custom-provider.md +++ b/docs/providers/custom-provider.md @@ -110,11 +110,11 @@ Service clients that support purging tombstoned large payloads can additionally | Method | Purpose | | ------ | ------- | -| `SetLargePayloadAutoPurgeAsync` | Explicitly enable or disable large payload auto-purge for the client's task hub. | +| `SetLargePayloadAutoPurgeAsync` | Record the explicit enable/disable choice for the client's task hub, without starting, stopping, or waiting for a purge runner. | | `GetLargePayloadsToPurgeAsync` | Fetch up to the requested limit of tombstones ready for processing. | | `ReportLargePayloadPurgeResultsAsync` | Report the worker's outcomes using the unchanged tombstone correlation tokens. | -Each operation accepts a UTC deadline (`DateTime.MaxValue` for no deadline) and a cancellation token. The service implementation owns transport, deadline/cancellation handling, disposition validation, and retry scheduling; the Core interface adds no worker or automatic setup. +Each operation accepts a UTC deadline and a cancellation token. `DateTime.MaxValue` means the caller has not specified a deadline; the backing service may apply a default deadline. The service implementation owns transport, deadline/cancellation handling, disposition validation, and retry scheduling; the Core interface adds no worker or automatic setup. `LargePayloadPurgeTombstone` and `LargePayloadPurgeResult` are sealed, immutable Core models. Their constructors reject null tokens but preserve all other token strings without interpreting or normalizing them, including empty and whitespace values. Results retain the supplied `LargePayloadPurgeDisposition` without validating it. The numeric values are `Unspecified = 0`, `Deleted = 1`, `Retry = 2`, and `Quarantined = 3`; the backing service validates reported dispositions rather than treating an unspecified or unknown outcome as success. diff --git a/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs b/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs index 3240f9d30..a988d6dfb 100644 --- a/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs +++ b/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs @@ -24,10 +24,12 @@ namespace DurableTask.Core public interface IOrchestrationServiceLargePayloadPurgeClient { /// - /// Enables or disables large payload auto-purge for the client's task hub. + /// Records whether large payload auto-purge is enabled for the client's task hub. /// + /// This operation does not start, stop, or wait for a purge runner. /// Whether large payload auto-purge is enabled. - /// The operation deadline in UTC, or for no deadline. + /// The operation deadline in UTC, or when the caller + /// has not specified a deadline. The backing service may apply a default deadline. /// The token used to cancel the operation. /// A task that represents the operation. Task SetLargePayloadAutoPurgeAsync(bool enabled, DateTime deadlineUtc, CancellationToken cancellationToken); @@ -36,7 +38,8 @@ public interface IOrchestrationServiceLargePayloadPurgeClient /// Gets tombstoned large payloads that are ready to be purged. /// /// The maximum number of tombstones to return. - /// The operation deadline in UTC, or for no deadline. + /// The operation deadline in UTC, or when the caller + /// has not specified a deadline. The backing service may apply a default deadline. /// The token used to cancel the operation. /// The tombstones to process. Task> GetLargePayloadsToPurgeAsync( @@ -46,7 +49,8 @@ Task> GetLargePayloadsToPurgeAsync( /// Reports the outcomes of attempts to purge tombstoned large payloads. /// /// The purge outcomes, including the unchanged tombstone correlation tokens. - /// The operation deadline in UTC, or for no deadline. + /// The operation deadline in UTC, or when the caller + /// has not specified a deadline. The backing service may apply a default deadline. /// The token used to cancel the operation. /// A task that represents the operation. Task ReportLargePayloadPurgeResultsAsync( From 77e3715340c9b2acf9e947dae6ac7f14f20ea4dc Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 24 Sep 2026 13:49:31 -0700 Subject: [PATCH 4/6] Separate purge service interface and reuse SDK client models Keep Core free of purge contracts and SDK Client dependencies. Add the standalone interface package, SDK model identity and Core boundary tests, and standard build/sign/pack wiring. Require an explicitly supplied compatible SDK package until the model release is available. Copilot-Session: 883b4cbd-e93c-4d4e-8cb8-ffcf69525cfa Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + DurableTask.sln | 22 ++++ .../CorePackageBoundaryTests.cs | 45 +++++++ .../LargePayloadPurgeTests.cs | 110 ------------------ ...argePayloadPurge.Abstractions.Tests.csproj | 19 +++ .../LargePayloadPurgeContractTests.cs | 93 +++++++++++++++ docs/providers/custom-provider.md | 14 ++- eng/ci/official-build.yml | 8 ++ eng/ci/public-build.yml | 41 ++++++- eng/templates/build-steps.yml | 23 ++++ eng/templates/build.yml | 35 ++++++ .../LargePayloadPurgeDisposition.cs | 49 -------- .../LargePayloadPurgeResult.cs | 53 --------- .../LargePayloadPurgeTombstone.cs | 53 --------- ...Task.LargePayloadPurge.Abstractions.csproj | 37 ++++++ ...estrationServiceLargePayloadPurgeClient.cs | 17 +-- 16 files changed, 341 insertions(+), 279 deletions(-) create mode 100644 Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs delete mode 100644 Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs create mode 100644 Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj create mode 100644 Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs delete mode 100644 src/DurableTask.Core/LargePayloadPurgeDisposition.cs delete mode 100644 src/DurableTask.Core/LargePayloadPurgeResult.cs delete mode 100644 src/DurableTask.Core/LargePayloadPurgeTombstone.cs create mode 100644 src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj rename src/{DurableTask.Core => DurableTask.LargePayloadPurge.Abstractions}/IOrchestrationServiceLargePayloadPurgeClient.cs (77%) diff --git a/Directory.Packages.props b/Directory.Packages.props index f088054dc..3341a8f3f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/DurableTask.sln b/DurableTask.sln index 035e08999..737c29122 100644 --- a/DurableTask.sln +++ b/DurableTask.sln @@ -93,6 +93,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "templates", "templates", "{ eng\templates\test.yml = eng\templates\test.yml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.LargePayloadPurge.Abstractions", "src\DurableTask.LargePayloadPurge.Abstractions\DurableTask.LargePayloadPurge.Abstractions.csproj", "{6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.LargePayloadPurge.Abstractions.Tests", "Test\DurableTask.LargePayloadPurge.Abstractions.Tests\DurableTask.LargePayloadPurge.Abstractions.Tests.csproj", "{4433BF68-8A85-4CE8-B537-56C3AC909738}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -285,6 +289,22 @@ Global {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24}.Release|Any CPU.Build.0 = Release|Any CPU {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24}.Release|x64.ActiveCfg = Release|Any CPU {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24}.Release|x64.Build.0 = Release|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|x64.Build.0 = Debug|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|Any CPU.Build.0 = Release|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|x64.ActiveCfg = Release|Any CPU + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|x64.Build.0 = Release|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|x64.ActiveCfg = Debug|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|x64.Build.0 = Debug|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|Any CPU.Build.0 = Release|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|x64.ActiveCfg = Release|Any CPU + {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -318,6 +338,8 @@ Global {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24} = {8B797A00-0F43-46F9-8F1A-C945FD4F304F} {8123FF5E-A7B6-4649-BF5C-C2C69F2D67D3} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {BF34A9C8-20CA-44B9-BC72-AB905AB27F69} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0} = {DBCD161C-D409-48E5-924E-9B7FA1C36B84} + {4433BF68-8A85-4CE8-B537-56C3AC909738} = {95C69A06-7F62-4652-A480-207B614C2869} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution EnterpriseLibraryConfigurationToolBinariesPath = packages\TransientFaultHandling.Core.5.1.1209.1\lib\NET4 diff --git a/Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs b/Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs new file mode 100644 index 000000000..a7d821a5d --- /dev/null +++ b/Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs @@ -0,0 +1,45 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.Core.Tests +{ + using System.Linq; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class CorePackageBoundaryTests + { + [TestMethod] + [DataRow("DurableTask.Core.IOrchestrationServiceLargePayloadPurgeClient")] + [DataRow("DurableTask.Core.LargePayloadPurgeTombstone")] + [DataRow("DurableTask.Core.LargePayloadPurgeResult")] + [DataRow("DurableTask.Core.LargePayloadPurgeDisposition")] + public void CoreDoesNotDefineOrForwardPurgeContracts(string typeName) + { + var assembly = typeof(TaskHubClient).Assembly; + + // GetType resolves forwarded types as well as types defined in this assembly. + Assert.IsNull(assembly.GetType(typeName)); + } + + [TestMethod] + public void CoreDoesNotReferencePurgeAbstractionsOrSdkClient() + { + string[] references = typeof(TaskHubClient).Assembly.GetReferencedAssemblies() + .Select(assembly => assembly.Name).ToArray(); + + CollectionAssert.DoesNotContain(references, "DurableTask.LargePayloadPurge.Abstractions"); + CollectionAssert.DoesNotContain(references, "Microsoft.DurableTask.Client"); + } + } +} diff --git a/Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs b/Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs deleted file mode 100644 index 46ee1e20e..000000000 --- a/Test/DurableTask.Core.Tests/LargePayloadPurgeTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -// ---------------------------------------------------------------------------------- -// Copyright Microsoft Corporation -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ---------------------------------------------------------------------------------- - -namespace DurableTask.Core.Tests -{ - using System; - using System.Threading.Tasks; - using DurableTask.Emulator; - using Microsoft.VisualStudio.TestTools.UnitTesting; - - [TestClass] - public class LargePayloadPurgeTests - { - [TestMethod] - [DataRow("tombstone-token", "payload-token")] - [DataRow("", "")] - [DataRow(" \t\r\n ", "\t ")] - [DataRow("Case/Sensitive+Token=", "%2Fopaque+payload==")] - public void TombstonePreservesOpaqueTokens(string tombstoneToken, string payloadToken) - { - var tombstone = new LargePayloadPurgeTombstone(tombstoneToken, payloadToken); - - Assert.AreEqual(tombstoneToken, tombstone.TombstoneToken); - Assert.AreEqual(payloadToken, tombstone.PayloadToken); - } - - [TestMethod] - [DataRow(null, "payload", "tombstoneToken")] - [DataRow("tombstone", null, "payloadToken")] - [DataRow(null, null, "tombstoneToken")] - public void TombstoneRejectsNullTokens(string tombstoneToken, string payloadToken, string parameterName) - { - ArgumentNullException exception = Assert.ThrowsException( - () => new LargePayloadPurgeTombstone(tombstoneToken, payloadToken)); - - Assert.AreEqual(parameterName, exception.ParamName); - } - - [TestMethod] - [DataRow("", 0)] - [DataRow(" \t\r\n ", 1)] - [DataRow("Case/Sensitive+Token=", 2)] - [DataRow("tombstone-token", 3)] - [DataRow("tombstone-token", -1)] - [DataRow("tombstone-token", 4)] - [DataRow("tombstone-token", int.MaxValue)] - public void ResultPreservesOpaqueTokenAndUnvalidatedDisposition(string tombstoneToken, int disposition) - { - var result = new LargePayloadPurgeResult(tombstoneToken, (LargePayloadPurgeDisposition)disposition); - - Assert.AreEqual(tombstoneToken, result.TombstoneToken); - Assert.AreEqual(disposition, (int)result.Disposition); - } - - [TestMethod] - public void ResultRejectsNullToken() - { - ArgumentNullException exception = Assert.ThrowsException( - () => new LargePayloadPurgeResult(null, LargePayloadPurgeDisposition.Deleted)); - - Assert.AreEqual("tombstoneToken", exception.ParamName); - } - - [TestMethod] - public void DispositionValuesRetainNumericContract() - { - Assert.AreEqual(0, (int)LargePayloadPurgeDisposition.Unspecified); - Assert.AreEqual(1, (int)LargePayloadPurgeDisposition.Deleted); - Assert.AreEqual(2, (int)LargePayloadPurgeDisposition.Retry); - Assert.AreEqual(3, (int)LargePayloadPurgeDisposition.Quarantined); - } - - [TestMethod] - public void ModelsRetainReferenceEquality() - { - var tombstone = new LargePayloadPurgeTombstone("tombstone", "payload"); - var result = new LargePayloadPurgeResult("tombstone", LargePayloadPurgeDisposition.Deleted); - - Assert.AreNotEqual(tombstone, new LargePayloadPurgeTombstone("tombstone", "payload")); - Assert.AreNotEqual(result, new LargePayloadPurgeResult("tombstone", LargePayloadPurgeDisposition.Deleted)); - } - - [TestMethod] - public async Task ExistingServiceClientWorksWithoutOptionalLargePayloadPurgeCapability() - { - using var service = new LocalOrchestrationService(); - IOrchestrationServiceClient serviceClient = service; - Assert.IsFalse(serviceClient is IOrchestrationServiceLargePayloadPurgeClient); - - var client = new TaskHubClient(serviceClient); - OrchestrationInstance instance = await client.CreateOrchestrationInstanceAsync( - "Business.Orchestration", string.Empty, input: null); - OrchestrationState state = await client.GetOrchestrationStateAsync(instance); - - Assert.AreEqual(instance.InstanceId, state.OrchestrationInstance.InstanceId); - Assert.AreEqual("Business.Orchestration", state.Name); - Assert.AreEqual(OrchestrationStatus.Pending, state.OrchestrationStatus); - } - } -} diff --git a/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj b/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj new file mode 100644 index 000000000..9e937c792 --- /dev/null +++ b/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj @@ -0,0 +1,19 @@ + + + + + net8.0;net48 + false + + + + + + + + + + + + + diff --git a/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs b/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs new file mode 100644 index 000000000..e4a19ece6 --- /dev/null +++ b/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs @@ -0,0 +1,93 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.LargePayloadPurge.Tests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.DurableTask.Client; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class LargePayloadPurgeContractTests + { + [TestMethod] + public void PackageExportsOnlyTheStandaloneInterface() + { + Type contract = typeof(IOrchestrationServiceLargePayloadPurgeClient); + + Assert.IsTrue(contract.IsInterface); + Assert.AreEqual("DurableTask.LargePayloadPurge", contract.Namespace); + Assert.AreEqual("DurableTask.LargePayloadPurge.Abstractions", contract.Assembly.GetName().Name); + CollectionAssert.AreEqual(new[] { contract }, contract.Assembly.GetExportedTypes()); + Assert.AreEqual(0, contract.GetInterfaces().Length); + Assert.AreEqual(3, contract.GetMethods().Length); + } + + [TestMethod] + public void SetAcceptsExplicitChoiceAndCallerDeadlineAndCancellation() + { + AssertSignature( + nameof(IOrchestrationServiceLargePayloadPurgeClient.SetLargePayloadAutoPurgeAsync), + typeof(Task), + new[] { typeof(bool), typeof(DateTime), typeof(CancellationToken) }, + new[] { "enabled", "deadlineUtc", "cancellationToken" }); + } + + [TestMethod] + public void GetReturnsCanonicalSdkTombstones() + { + AssertSignature( + nameof(IOrchestrationServiceLargePayloadPurgeClient.GetLargePayloadsToPurgeAsync), + typeof(Task>), + new[] { typeof(int), typeof(DateTime), typeof(CancellationToken) }, + new[] { "limit", "deadlineUtc", "cancellationToken" }); + } + + [TestMethod] + public void ReportAcceptsCanonicalSdkResults() + { + AssertSignature( + nameof(IOrchestrationServiceLargePayloadPurgeClient.ReportLargePayloadPurgeResultsAsync), + typeof(Task), + new[] { typeof(IReadOnlyList), typeof(DateTime), typeof(CancellationToken) }, + new[] { "results", "deadlineUtc", "cancellationToken" }); + } + + [TestMethod] + public void ModelsComeFromSdkClientNotTheInterfacePackage() + { + Assembly clientAssembly = typeof(DurableTaskClient).Assembly; + + Assert.AreSame(clientAssembly, typeof(LargePayloadTombstone).Assembly); + Assert.AreSame(clientAssembly, typeof(LargePayloadPurgeResult).Assembly); + Assert.AreSame(clientAssembly, typeof(LargePayloadPurgeDisposition).Assembly); + Assert.AreNotSame(clientAssembly, typeof(IOrchestrationServiceLargePayloadPurgeClient).Assembly); + } + + static void AssertSignature(string methodName, Type returnType, Type[] parameterTypes, string[] parameterNames) + { + MethodInfo method = typeof(IOrchestrationServiceLargePayloadPurgeClient).GetMethod(methodName); + Assert.IsNotNull(method); + Assert.AreEqual(returnType, method.ReturnType); + ParameterInfo[] parameters = method.GetParameters(); + CollectionAssert.AreEqual(parameterTypes, parameters.Select(parameter => parameter.ParameterType).ToArray()); + CollectionAssert.AreEqual(parameterNames, parameters.Select(parameter => parameter.Name).ToArray()); + Assert.IsFalse(parameters.Any(parameter => parameter.IsOptional)); + } + } +} diff --git a/docs/providers/custom-provider.md b/docs/providers/custom-provider.md index 9ca7135bb..3f0e8c951 100644 --- a/docs/providers/custom-provider.md +++ b/docs/providers/custom-provider.md @@ -106,7 +106,7 @@ public interface IOrchestrationServiceClient ### Optional Large Payload Purge Capability -Service clients that support purging tombstoned large payloads can additionally implement `DurableTask.Core.IOrchestrationServiceLargePayloadPurgeClient`, following the optional capability pattern used by `IOrchestrationServicePurgeClient`. Existing implementations of `IOrchestrationServiceClient` do not need to change. +Service clients that support purging tombstoned large payloads can additionally implement `DurableTask.LargePayloadPurge.IOrchestrationServiceLargePayloadPurgeClient` from the separate `Microsoft.Azure.DurableTask.LargePayloadPurge.Abstractions` package, following the optional capability pattern used by `IOrchestrationServicePurgeClient`. Existing implementations of `IOrchestrationServiceClient` do not need to change. | Method | Purpose | | ------ | ------- | @@ -114,9 +114,17 @@ Service clients that support purging tombstoned large payloads can additionally | `GetLargePayloadsToPurgeAsync` | Fetch up to the requested limit of tombstones ready for processing. | | `ReportLargePayloadPurgeResultsAsync` | Report the worker's outcomes using the unchanged tombstone correlation tokens. | -Each operation accepts a UTC deadline and a cancellation token. `DateTime.MaxValue` means the caller has not specified a deadline; the backing service may apply a default deadline. The service implementation owns transport, deadline/cancellation handling, disposition validation, and retry scheduling; the Core interface adds no worker or automatic setup. +Each operation accepts the caller's UTC deadline and cancellation token. `DateTime.MaxValue` means the caller has not specified a deadline. The service implementation owns transport, propagation of the caller's deadline/cancellation, disposition validation, and retry scheduling; the interface adds no worker, deadline policy, or automatic setup. -`LargePayloadPurgeTombstone` and `LargePayloadPurgeResult` are sealed, immutable Core models. Their constructors reject null tokens but preserve all other token strings without interpreting or normalizing them, including empty and whitespace values. Results retain the supplied `LargePayloadPurgeDisposition` without validating it. The numeric values are `Unspecified = 0`, `Deleted = 1`, `Retry = 2`, and `Quarantined = 3`; the backing service validates reported dispositions rather than treating an unspecified or unknown outcome as success. +The package defines only the interface. Its signatures reuse the existing `Microsoft.DurableTask.Client.LargePayloadTombstone` and `Microsoft.DurableTask.Client.LargePayloadPurgeResult` records and `Microsoft.DurableTask.Client.LargePayloadPurgeDisposition` enum. Tombstone correlation tokens are echoed unchanged, and the backing service validates outcomes rather than treating an unspecified or unknown disposition as success. + +This package depends on `Microsoft.DurableTask.Client` and its transitive dependencies, including Core; it is not a BCL-only package. The reverse dependency does not exist: Core contains none of these purge contracts and does not depend on this package or SDK Client. Consumers must reference the interface package explicitly. There are no duplicate Core models or compatibility type forwarders. + +#### Build and release prerequisite + +The SDK Client package must contain the three public large payload purge models. The published SDK Client 1.26.0 package does not contain them. Until a compatible SDK release is available, building this project requires an explicitly supplied `DurableTaskClientVersion` and a package source containing that version; no unreleased SDK version is assumed by the repository. Core can still be restored and built independently without this property. + +The public and official pipeline templates expose the corresponding `durableTaskClientVersion` parameter and fail explicitly when it is not supplied. Before normal CI and release can succeed, publish the compatible SDK Client package and pin that real version in the repository and pipeline configuration. Then publish the interface package and the required Core version before updating host and service consumers. The interface project's initial version is 0.1.0; this is not a claim that the package has been published. Local validation packages are not production release dependencies. ## Minimal Implementation diff --git a/eng/ci/official-build.yml b/eng/ci/official-build.yml index 23eab48ef..512506c88 100644 --- a/eng/ci/official-build.yml +++ b/eng/ci/official-build.yml @@ -1,3 +1,9 @@ +parameters: + - name: durableTaskClientVersion + displayName: 'SDK Client version containing the large payload purge models (release prerequisite)' + type: string + default: '' + trigger: batch: true branches: @@ -47,3 +53,5 @@ extends: dependsOn: [] jobs: - template: /eng/templates/build.yml@self + parameters: + durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml index fa6bdf0d6..af81b041f 100644 --- a/eng/ci/public-build.yml +++ b/eng/ci/public-build.yml @@ -1,5 +1,11 @@ # This pipeline is used for public PR and CI builds. +parameters: +- name: durableTaskClientVersion + displayName: 'SDK Client version containing the large payload purge models (release prerequisite)' + type: string + default: '' + # Run on changes in main trigger: batch: true @@ -66,7 +72,8 @@ extends: # In the release configuration, the packages don't expose their internals # to the test projects. buildConfiguration: 'Debug' - buildTests: true + buildTests: true + durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} # Run tests - template: /eng/templates/test.yml@self parameters: @@ -86,7 +93,8 @@ extends: # In the release configuration, the packages don't expose their internals # to the test projects. buildConfiguration: 'Debug' - buildTests: true + buildTests: true + durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} # Run tests - template: /eng/templates/test.yml@self parameters: @@ -106,9 +114,36 @@ extends: # In the release configuration, the packages don't expose their internals # to the test projects. buildConfiguration: 'Debug' - buildTests: true + buildTests: true + durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} # Run tests - template: /eng/templates/test.yml@self parameters: testAssembly: 'tests/**/DurableTask.Emulator.Tests.dll' artifactName: 'EmulatorTests' + + - stage: DTFxLargePayloadPurgeValidate + dependsOn: [] + jobs: + - job: Validate + steps: + - task: UseDotNet@2 + displayName: 'Use the .NET 8 SDK' + inputs: + packageType: sdk + version: '8.0.x' + - task: DotNetCoreCLI@2 + displayName: 'Restore large payload purge contract tests' + inputs: + command: restore + feedsToUse: config + nugetConfigPath: 'NuGet.config' + verbosityRestore: Minimal + projects: 'Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj' + restoreArguments: '-p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' + - task: DotNetCoreCLI@2 + displayName: 'Test large payload purge contracts (net8.0 and net48)' + inputs: + command: test + projects: 'Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj' + arguments: '--no-restore --configuration Debug -p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' diff --git a/eng/templates/build-steps.yml b/eng/templates/build-steps.yml index 294b8ced3..ee03d448c 100644 --- a/eng/templates/build-steps.yml +++ b/eng/templates/build-steps.yml @@ -7,6 +7,10 @@ parameters: type: boolean default: false +- name: durableTaskClientVersion + type: string + default: '' + steps: # Start by restoring all the dependencies. This needs to be its own task # from what I can tell. We specifically only target DurableTask.AzureStorage @@ -29,6 +33,25 @@ steps: src/DurableTask.Emulator/DurableTask.Emulator.csproj src/DurableTask.ApplicationInsights/DurableTask.ApplicationInsights.csproj +- task: DotNetCoreCLI@2 + displayName: 'Restore large payload purge abstractions' + inputs: + command: restore + feedsToUse: config + nugetConfigPath: 'NuGet.config' + verbosityRestore: Minimal + projects: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' + restoreArguments: '-p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' + +- task: VSBuild@1 + displayName: 'Build (LargePayloadPurge Abstractions)' + inputs: + solution: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' + vsVersion: '17.0' + logFileVerbosity: minimal + configuration: ${{ parameters.buildConfiguration }} + msbuildArgs: /p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }} /p:FileVersionRevision=$(Build.BuildId) /p:ContinuousIntegrationBuild=true + # Build the filtered solution in release mode, specifying the continuous integration flag. - task: VSBuild@1 displayName: 'Build (AzureStorage)' diff --git a/eng/templates/build.yml b/eng/templates/build.yml index a6b0993fd..eddb3d87c 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -1,3 +1,8 @@ +parameters: + - name: durableTaskClientVersion + type: string + default: '' + jobs: - job: Build @@ -28,6 +33,25 @@ jobs: src/DurableTask.AzureServiceFabric/DurableTask.AzureServiceFabric.csproj src/DurableTask.ApplicationInsights/DurableTask.ApplicationInsights.csproj + - task: DotNetCoreCLI@2 + displayName: 'Restore large payload purge abstractions' + inputs: + command: restore + feedsToUse: config + nugetConfigPath: 'NuGet.config' + verbosityRestore: Minimal + projects: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' + restoreArguments: '-p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' + + - task: VSBuild@1 + displayName: 'Build (LargePayloadPurge Abstractions)' + inputs: + solution: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' + vsVersion: '17.0' + logFileVerbosity: minimal + configuration: Release + msbuildArgs: /p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }} /p:FileVersionRevision=$(Build.BuildId) /p:ContinuousIntegrationBuild=true + # Build the filtered solution in release mode, specifying the continuous integration flag. - task: VSBuild@1 @@ -89,6 +113,17 @@ jobs: folderPath: src pattern: DurableTask.*.dll signType: dll + + - task: DotNetCoreCLI@2 + displayName: 'Generate large payload purge abstractions package' + inputs: + command: pack + verbosityPack: Minimal + configuration: Release + nobuild: true + packDirectory: $(build.artifactStagingDirectory) + packagesToPack: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' + buildProperties: 'DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' # need to add all the code-signing stuff + SBOM - task: DotNetCoreCLI@2 diff --git a/src/DurableTask.Core/LargePayloadPurgeDisposition.cs b/src/DurableTask.Core/LargePayloadPurgeDisposition.cs deleted file mode 100644 index 870448076..000000000 --- a/src/DurableTask.Core/LargePayloadPurgeDisposition.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Relocated from Azure/azure-functions-durable-extension under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE - -namespace DurableTask.Core -{ - /// - /// The outcome of a worker's attempt to purge a tombstoned large payload. - /// - public enum LargePayloadPurgeDisposition - { - /// - /// No outcome was specified. The backing service must reject this value, not treat it as success. - /// - Unspecified = 0, - - /// - /// Terminal success: the payload was deleted, was already absent, or is not owned by the payload store. - /// - Deleted = 1, - - /// - /// A potentially transient failure. The backing service schedules another attempt. - /// - Retry = 2, - - /// - /// A deterministic failure. Preserve the tombstone for operator action without retrying it. - /// - Quarantined = 3, - } -} diff --git a/src/DurableTask.Core/LargePayloadPurgeResult.cs b/src/DurableTask.Core/LargePayloadPurgeResult.cs deleted file mode 100644 index 81ed01a05..000000000 --- a/src/DurableTask.Core/LargePayloadPurgeResult.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Relocated from Azure/azure-functions-durable-extension under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE - -#nullable enable -namespace DurableTask.Core -{ - using System; - - /// - /// The worker's purge outcome for one tombstone. - /// - public sealed class LargePayloadPurgeResult - { - /// - /// Initializes a new instance of the class. - /// - /// The opaque correlation token echoed from the tombstone. - /// The worker's classification of the purge outcome. - public LargePayloadPurgeResult(string tombstoneToken, LargePayloadPurgeDisposition disposition) - { - this.TombstoneToken = tombstoneToken ?? throw new ArgumentNullException(nameof(tombstoneToken)); - this.Disposition = disposition; - } - - /// - /// Gets the unchanged tombstone correlation token. - /// - public string TombstoneToken { get; } - - /// - /// Gets the purge outcome. Validation and retry scheduling belong to the backing service. - /// - public LargePayloadPurgeDisposition Disposition { get; } - } -} diff --git a/src/DurableTask.Core/LargePayloadPurgeTombstone.cs b/src/DurableTask.Core/LargePayloadPurgeTombstone.cs deleted file mode 100644 index 73f41d9d6..000000000 --- a/src/DurableTask.Core/LargePayloadPurgeTombstone.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Relocated from Azure/azure-functions-durable-extension under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE - -#nullable enable -namespace DurableTask.Core -{ - using System; - - /// - /// A tombstoned large payload to be purged by the worker. - /// - public sealed class LargePayloadPurgeTombstone - { - /// - /// Initializes a new instance of the class. - /// - /// The opaque backend-issued correlation token. - /// The unchanged external payload token. - public LargePayloadPurgeTombstone(string tombstoneToken, string payloadToken) - { - this.TombstoneToken = tombstoneToken ?? throw new ArgumentNullException(nameof(tombstoneToken)); - this.PayloadToken = payloadToken ?? throw new ArgumentNullException(nameof(payloadToken)); - } - - /// - /// Gets the opaque correlation token that must be echoed unchanged in the purge result. - /// - public string TombstoneToken { get; } - - /// - /// Gets the external payload token, which must not be interpreted or normalized by intermediaries. - /// - public string PayloadToken { get; } - } -} diff --git a/src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj b/src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj new file mode 100644 index 000000000..5afaff329 --- /dev/null +++ b/src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj @@ -0,0 +1,37 @@ + + + + + netstandard2.0 + DurableTask.LargePayloadPurge + Microsoft.Azure.DurableTask.LargePayloadPurge.Abstractions + Optional orchestration service capability for large payload purge using the Durable Task SDK client models. + true + + + + 0 + 1 + 0 + $(MajorVersion).$(MinorVersion).$(PatchVersion) + $(VersionPrefix).0 + $(VersionPrefix).$([MSBuild]::Modulo($(FileVersionRevision), 65536)) + $(MajorVersion).$(MinorVersion).0.0 + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix) + + + + + + + + + + + + + + + diff --git a/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs b/src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs similarity index 77% rename from src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs rename to src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs index a988d6dfb..2bfe82bbd 100644 --- a/src/DurableTask.Core/IOrchestrationServiceLargePayloadPurgeClient.cs +++ b/src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs @@ -11,12 +11,13 @@ // limitations under the License. // ---------------------------------------------------------------------------------- -namespace DurableTask.Core +namespace DurableTask.LargePayloadPurge { using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; + using Microsoft.DurableTask.Client; /// /// Optional orchestration service client capability for purging tombstoned large payloads. @@ -28,8 +29,8 @@ public interface IOrchestrationServiceLargePayloadPurgeClient /// /// This operation does not start, stop, or wait for a purge runner. /// Whether large payload auto-purge is enabled. - /// The operation deadline in UTC, or when the caller - /// has not specified a deadline. The backing service may apply a default deadline. + /// The caller's operation deadline in UTC, or + /// when the caller has not specified a deadline. /// The token used to cancel the operation. /// A task that represents the operation. Task SetLargePayloadAutoPurgeAsync(bool enabled, DateTime deadlineUtc, CancellationToken cancellationToken); @@ -38,19 +39,19 @@ public interface IOrchestrationServiceLargePayloadPurgeClient /// Gets tombstoned large payloads that are ready to be purged. /// /// The maximum number of tombstones to return. - /// The operation deadline in UTC, or when the caller - /// has not specified a deadline. The backing service may apply a default deadline. + /// The caller's operation deadline in UTC, or + /// when the caller has not specified a deadline. /// The token used to cancel the operation. /// The tombstones to process. - Task> GetLargePayloadsToPurgeAsync( + Task> GetLargePayloadsToPurgeAsync( int limit, DateTime deadlineUtc, CancellationToken cancellationToken); /// /// Reports the outcomes of attempts to purge tombstoned large payloads. /// /// The purge outcomes, including the unchanged tombstone correlation tokens. - /// The operation deadline in UTC, or when the caller - /// has not specified a deadline. The backing service may apply a default deadline. + /// The caller's operation deadline in UTC, or + /// when the caller has not specified a deadline. /// The token used to cancel the operation. /// A task that represents the operation. Task ReportLargePayloadPurgeResultsAsync( From dfad3b084c12f15bad5c80990f5b4136d202424b Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 24 Sep 2026 13:52:23 -0700 Subject: [PATCH 5/6] Keep SDK purge validation out of shared Core CI steps The dedicated purge contract stage already builds its product project. Leave existing Core, Azure Storage, and Emulator validation independent of the SDK release prerequisite. Copilot-Session: 883b4cbd-e93c-4d4e-8cb8-ffcf69525cfa Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/ci/public-build.yml | 3 --- eng/templates/build-steps.yml | 23 ----------------------- 2 files changed, 26 deletions(-) diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml index af81b041f..c9d25ac80 100644 --- a/eng/ci/public-build.yml +++ b/eng/ci/public-build.yml @@ -73,7 +73,6 @@ extends: # to the test projects. buildConfiguration: 'Debug' buildTests: true - durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} # Run tests - template: /eng/templates/test.yml@self parameters: @@ -94,7 +93,6 @@ extends: # to the test projects. buildConfiguration: 'Debug' buildTests: true - durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} # Run tests - template: /eng/templates/test.yml@self parameters: @@ -115,7 +113,6 @@ extends: # to the test projects. buildConfiguration: 'Debug' buildTests: true - durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} # Run tests - template: /eng/templates/test.yml@self parameters: diff --git a/eng/templates/build-steps.yml b/eng/templates/build-steps.yml index ee03d448c..294b8ced3 100644 --- a/eng/templates/build-steps.yml +++ b/eng/templates/build-steps.yml @@ -7,10 +7,6 @@ parameters: type: boolean default: false -- name: durableTaskClientVersion - type: string - default: '' - steps: # Start by restoring all the dependencies. This needs to be its own task # from what I can tell. We specifically only target DurableTask.AzureStorage @@ -33,25 +29,6 @@ steps: src/DurableTask.Emulator/DurableTask.Emulator.csproj src/DurableTask.ApplicationInsights/DurableTask.ApplicationInsights.csproj -- task: DotNetCoreCLI@2 - displayName: 'Restore large payload purge abstractions' - inputs: - command: restore - feedsToUse: config - nugetConfigPath: 'NuGet.config' - verbosityRestore: Minimal - projects: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' - restoreArguments: '-p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' - -- task: VSBuild@1 - displayName: 'Build (LargePayloadPurge Abstractions)' - inputs: - solution: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' - vsVersion: '17.0' - logFileVerbosity: minimal - configuration: ${{ parameters.buildConfiguration }} - msbuildArgs: /p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }} /p:FileVersionRevision=$(Build.BuildId) /p:ContinuousIntegrationBuild=true - # Build the filtered solution in release mode, specifying the continuous integration flag. - task: VSBuild@1 displayName: 'Build (AzureStorage)' From fb7d54a10e864e5b71e65306ee75e32580c047c5 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 24 Sep 2026 14:25:32 -0700 Subject: [PATCH 6/6] Leave purge abstractions ownership to the SDK repository Remove the standalone contract project, extraction-only tests, and SDK dependency/release wiring from this repository. Restore all non-versioning files to the original feature baseline while preserving the generic opt-in version exemption unchanged. Copilot-Session: 883b4cbd-e93c-4d4e-8cb8-ffcf69525cfa Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 - DurableTask.sln | 22 ----- .../CorePackageBoundaryTests.cs | 45 --------- ...argePayloadPurge.Abstractions.Tests.csproj | 19 ---- .../LargePayloadPurgeContractTests.cs | 93 ------------------- docs/providers/custom-provider.md | 22 ----- eng/ci/official-build.yml | 8 -- eng/ci/public-build.yml | 38 +------- eng/templates/build.yml | 35 ------- ...Task.LargePayloadPurge.Abstractions.csproj | 37 -------- ...estrationServiceLargePayloadPurgeClient.cs | 60 ------------ 11 files changed, 3 insertions(+), 377 deletions(-) delete mode 100644 Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs delete mode 100644 Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj delete mode 100644 Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs delete mode 100644 src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj delete mode 100644 src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 3341a8f3f..f088054dc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,6 @@ - diff --git a/DurableTask.sln b/DurableTask.sln index 737c29122..035e08999 100644 --- a/DurableTask.sln +++ b/DurableTask.sln @@ -93,10 +93,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "templates", "templates", "{ eng\templates\test.yml = eng\templates\test.yml EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.LargePayloadPurge.Abstractions", "src\DurableTask.LargePayloadPurge.Abstractions\DurableTask.LargePayloadPurge.Abstractions.csproj", "{6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.LargePayloadPurge.Abstractions.Tests", "Test\DurableTask.LargePayloadPurge.Abstractions.Tests\DurableTask.LargePayloadPurge.Abstractions.Tests.csproj", "{4433BF68-8A85-4CE8-B537-56C3AC909738}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -289,22 +285,6 @@ Global {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24}.Release|Any CPU.Build.0 = Release|Any CPU {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24}.Release|x64.ActiveCfg = Release|Any CPU {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24}.Release|x64.Build.0 = Release|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|x64.ActiveCfg = Debug|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Debug|x64.Build.0 = Debug|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|Any CPU.Build.0 = Release|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|x64.ActiveCfg = Release|Any CPU - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0}.Release|x64.Build.0 = Release|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|x64.ActiveCfg = Debug|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Debug|x64.Build.0 = Debug|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|Any CPU.Build.0 = Release|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|x64.ActiveCfg = Release|Any CPU - {4433BF68-8A85-4CE8-B537-56C3AC909738}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -338,8 +318,6 @@ Global {87CA84DE-A0FE-443C-8B2B-AB89F5DF5C24} = {8B797A00-0F43-46F9-8F1A-C945FD4F304F} {8123FF5E-A7B6-4649-BF5C-C2C69F2D67D3} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {BF34A9C8-20CA-44B9-BC72-AB905AB27F69} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {6F64A0A2-61B3-4D1D-B26F-3E58F52EF1C0} = {DBCD161C-D409-48E5-924E-9B7FA1C36B84} - {4433BF68-8A85-4CE8-B537-56C3AC909738} = {95C69A06-7F62-4652-A480-207B614C2869} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution EnterpriseLibraryConfigurationToolBinariesPath = packages\TransientFaultHandling.Core.5.1.1209.1\lib\NET4 diff --git a/Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs b/Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs deleted file mode 100644 index a7d821a5d..000000000 --- a/Test/DurableTask.Core.Tests/CorePackageBoundaryTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ---------------------------------------------------------------------------------- -// Copyright Microsoft Corporation -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ---------------------------------------------------------------------------------- - -namespace DurableTask.Core.Tests -{ - using System.Linq; - using Microsoft.VisualStudio.TestTools.UnitTesting; - - [TestClass] - public class CorePackageBoundaryTests - { - [TestMethod] - [DataRow("DurableTask.Core.IOrchestrationServiceLargePayloadPurgeClient")] - [DataRow("DurableTask.Core.LargePayloadPurgeTombstone")] - [DataRow("DurableTask.Core.LargePayloadPurgeResult")] - [DataRow("DurableTask.Core.LargePayloadPurgeDisposition")] - public void CoreDoesNotDefineOrForwardPurgeContracts(string typeName) - { - var assembly = typeof(TaskHubClient).Assembly; - - // GetType resolves forwarded types as well as types defined in this assembly. - Assert.IsNull(assembly.GetType(typeName)); - } - - [TestMethod] - public void CoreDoesNotReferencePurgeAbstractionsOrSdkClient() - { - string[] references = typeof(TaskHubClient).Assembly.GetReferencedAssemblies() - .Select(assembly => assembly.Name).ToArray(); - - CollectionAssert.DoesNotContain(references, "DurableTask.LargePayloadPurge.Abstractions"); - CollectionAssert.DoesNotContain(references, "Microsoft.DurableTask.Client"); - } - } -} diff --git a/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj b/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj deleted file mode 100644 index 9e937c792..000000000 --- a/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - net8.0;net48 - false - - - - - - - - - - - - - diff --git a/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs b/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs deleted file mode 100644 index e4a19ece6..000000000 --- a/Test/DurableTask.LargePayloadPurge.Abstractions.Tests/LargePayloadPurgeContractTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -// ---------------------------------------------------------------------------------- -// Copyright Microsoft Corporation -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ---------------------------------------------------------------------------------- - -namespace DurableTask.LargePayloadPurge.Tests -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.DurableTask.Client; - using Microsoft.VisualStudio.TestTools.UnitTesting; - - [TestClass] - public class LargePayloadPurgeContractTests - { - [TestMethod] - public void PackageExportsOnlyTheStandaloneInterface() - { - Type contract = typeof(IOrchestrationServiceLargePayloadPurgeClient); - - Assert.IsTrue(contract.IsInterface); - Assert.AreEqual("DurableTask.LargePayloadPurge", contract.Namespace); - Assert.AreEqual("DurableTask.LargePayloadPurge.Abstractions", contract.Assembly.GetName().Name); - CollectionAssert.AreEqual(new[] { contract }, contract.Assembly.GetExportedTypes()); - Assert.AreEqual(0, contract.GetInterfaces().Length); - Assert.AreEqual(3, contract.GetMethods().Length); - } - - [TestMethod] - public void SetAcceptsExplicitChoiceAndCallerDeadlineAndCancellation() - { - AssertSignature( - nameof(IOrchestrationServiceLargePayloadPurgeClient.SetLargePayloadAutoPurgeAsync), - typeof(Task), - new[] { typeof(bool), typeof(DateTime), typeof(CancellationToken) }, - new[] { "enabled", "deadlineUtc", "cancellationToken" }); - } - - [TestMethod] - public void GetReturnsCanonicalSdkTombstones() - { - AssertSignature( - nameof(IOrchestrationServiceLargePayloadPurgeClient.GetLargePayloadsToPurgeAsync), - typeof(Task>), - new[] { typeof(int), typeof(DateTime), typeof(CancellationToken) }, - new[] { "limit", "deadlineUtc", "cancellationToken" }); - } - - [TestMethod] - public void ReportAcceptsCanonicalSdkResults() - { - AssertSignature( - nameof(IOrchestrationServiceLargePayloadPurgeClient.ReportLargePayloadPurgeResultsAsync), - typeof(Task), - new[] { typeof(IReadOnlyList), typeof(DateTime), typeof(CancellationToken) }, - new[] { "results", "deadlineUtc", "cancellationToken" }); - } - - [TestMethod] - public void ModelsComeFromSdkClientNotTheInterfacePackage() - { - Assembly clientAssembly = typeof(DurableTaskClient).Assembly; - - Assert.AreSame(clientAssembly, typeof(LargePayloadTombstone).Assembly); - Assert.AreSame(clientAssembly, typeof(LargePayloadPurgeResult).Assembly); - Assert.AreSame(clientAssembly, typeof(LargePayloadPurgeDisposition).Assembly); - Assert.AreNotSame(clientAssembly, typeof(IOrchestrationServiceLargePayloadPurgeClient).Assembly); - } - - static void AssertSignature(string methodName, Type returnType, Type[] parameterTypes, string[] parameterNames) - { - MethodInfo method = typeof(IOrchestrationServiceLargePayloadPurgeClient).GetMethod(methodName); - Assert.IsNotNull(method); - Assert.AreEqual(returnType, method.ReturnType); - ParameterInfo[] parameters = method.GetParameters(); - CollectionAssert.AreEqual(parameterTypes, parameters.Select(parameter => parameter.ParameterType).ToArray()); - CollectionAssert.AreEqual(parameterNames, parameters.Select(parameter => parameter.Name).ToArray()); - Assert.IsFalse(parameters.Any(parameter => parameter.IsOptional)); - } - } -} diff --git a/docs/providers/custom-provider.md b/docs/providers/custom-provider.md index 3f0e8c951..7b49636b5 100644 --- a/docs/providers/custom-provider.md +++ b/docs/providers/custom-provider.md @@ -104,28 +104,6 @@ public interface IOrchestrationServiceClient > [!NOTE] > Most providers implement both interfaces in a single class. -### Optional Large Payload Purge Capability - -Service clients that support purging tombstoned large payloads can additionally implement `DurableTask.LargePayloadPurge.IOrchestrationServiceLargePayloadPurgeClient` from the separate `Microsoft.Azure.DurableTask.LargePayloadPurge.Abstractions` package, following the optional capability pattern used by `IOrchestrationServicePurgeClient`. Existing implementations of `IOrchestrationServiceClient` do not need to change. - -| Method | Purpose | -| ------ | ------- | -| `SetLargePayloadAutoPurgeAsync` | Record the explicit enable/disable choice for the client's task hub, without starting, stopping, or waiting for a purge runner. | -| `GetLargePayloadsToPurgeAsync` | Fetch up to the requested limit of tombstones ready for processing. | -| `ReportLargePayloadPurgeResultsAsync` | Report the worker's outcomes using the unchanged tombstone correlation tokens. | - -Each operation accepts the caller's UTC deadline and cancellation token. `DateTime.MaxValue` means the caller has not specified a deadline. The service implementation owns transport, propagation of the caller's deadline/cancellation, disposition validation, and retry scheduling; the interface adds no worker, deadline policy, or automatic setup. - -The package defines only the interface. Its signatures reuse the existing `Microsoft.DurableTask.Client.LargePayloadTombstone` and `Microsoft.DurableTask.Client.LargePayloadPurgeResult` records and `Microsoft.DurableTask.Client.LargePayloadPurgeDisposition` enum. Tombstone correlation tokens are echoed unchanged, and the backing service validates outcomes rather than treating an unspecified or unknown disposition as success. - -This package depends on `Microsoft.DurableTask.Client` and its transitive dependencies, including Core; it is not a BCL-only package. The reverse dependency does not exist: Core contains none of these purge contracts and does not depend on this package or SDK Client. Consumers must reference the interface package explicitly. There are no duplicate Core models or compatibility type forwarders. - -#### Build and release prerequisite - -The SDK Client package must contain the three public large payload purge models. The published SDK Client 1.26.0 package does not contain them. Until a compatible SDK release is available, building this project requires an explicitly supplied `DurableTaskClientVersion` and a package source containing that version; no unreleased SDK version is assumed by the repository. Core can still be restored and built independently without this property. - -The public and official pipeline templates expose the corresponding `durableTaskClientVersion` parameter and fail explicitly when it is not supplied. Before normal CI and release can succeed, publish the compatible SDK Client package and pin that real version in the repository and pipeline configuration. Then publish the interface package and the required Core version before updating host and service consumers. The interface project's initial version is 0.1.0; this is not a claim that the package has been published. Local validation packages are not production release dependencies. - ## Minimal Implementation Here's a skeleton for a custom provider: diff --git a/eng/ci/official-build.yml b/eng/ci/official-build.yml index 512506c88..23eab48ef 100644 --- a/eng/ci/official-build.yml +++ b/eng/ci/official-build.yml @@ -1,9 +1,3 @@ -parameters: - - name: durableTaskClientVersion - displayName: 'SDK Client version containing the large payload purge models (release prerequisite)' - type: string - default: '' - trigger: batch: true branches: @@ -53,5 +47,3 @@ extends: dependsOn: [] jobs: - template: /eng/templates/build.yml@self - parameters: - durableTaskClientVersion: ${{ parameters.durableTaskClientVersion }} diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml index c9d25ac80..fa6bdf0d6 100644 --- a/eng/ci/public-build.yml +++ b/eng/ci/public-build.yml @@ -1,11 +1,5 @@ # This pipeline is used for public PR and CI builds. -parameters: -- name: durableTaskClientVersion - displayName: 'SDK Client version containing the large payload purge models (release prerequisite)' - type: string - default: '' - # Run on changes in main trigger: batch: true @@ -72,7 +66,7 @@ extends: # In the release configuration, the packages don't expose their internals # to the test projects. buildConfiguration: 'Debug' - buildTests: true + buildTests: true # Run tests - template: /eng/templates/test.yml@self parameters: @@ -92,7 +86,7 @@ extends: # In the release configuration, the packages don't expose their internals # to the test projects. buildConfiguration: 'Debug' - buildTests: true + buildTests: true # Run tests - template: /eng/templates/test.yml@self parameters: @@ -112,35 +106,9 @@ extends: # In the release configuration, the packages don't expose their internals # to the test projects. buildConfiguration: 'Debug' - buildTests: true + buildTests: true # Run tests - template: /eng/templates/test.yml@self parameters: testAssembly: 'tests/**/DurableTask.Emulator.Tests.dll' artifactName: 'EmulatorTests' - - - stage: DTFxLargePayloadPurgeValidate - dependsOn: [] - jobs: - - job: Validate - steps: - - task: UseDotNet@2 - displayName: 'Use the .NET 8 SDK' - inputs: - packageType: sdk - version: '8.0.x' - - task: DotNetCoreCLI@2 - displayName: 'Restore large payload purge contract tests' - inputs: - command: restore - feedsToUse: config - nugetConfigPath: 'NuGet.config' - verbosityRestore: Minimal - projects: 'Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj' - restoreArguments: '-p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' - - task: DotNetCoreCLI@2 - displayName: 'Test large payload purge contracts (net8.0 and net48)' - inputs: - command: test - projects: 'Test/DurableTask.LargePayloadPurge.Abstractions.Tests/DurableTask.LargePayloadPurge.Abstractions.Tests.csproj' - arguments: '--no-restore --configuration Debug -p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' diff --git a/eng/templates/build.yml b/eng/templates/build.yml index eddb3d87c..a6b0993fd 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -1,8 +1,3 @@ -parameters: - - name: durableTaskClientVersion - type: string - default: '' - jobs: - job: Build @@ -33,25 +28,6 @@ jobs: src/DurableTask.AzureServiceFabric/DurableTask.AzureServiceFabric.csproj src/DurableTask.ApplicationInsights/DurableTask.ApplicationInsights.csproj - - task: DotNetCoreCLI@2 - displayName: 'Restore large payload purge abstractions' - inputs: - command: restore - feedsToUse: config - nugetConfigPath: 'NuGet.config' - verbosityRestore: Minimal - projects: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' - restoreArguments: '-p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' - - - task: VSBuild@1 - displayName: 'Build (LargePayloadPurge Abstractions)' - inputs: - solution: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' - vsVersion: '17.0' - logFileVerbosity: minimal - configuration: Release - msbuildArgs: /p:DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }} /p:FileVersionRevision=$(Build.BuildId) /p:ContinuousIntegrationBuild=true - # Build the filtered solution in release mode, specifying the continuous integration flag. - task: VSBuild@1 @@ -113,17 +89,6 @@ jobs: folderPath: src pattern: DurableTask.*.dll signType: dll - - - task: DotNetCoreCLI@2 - displayName: 'Generate large payload purge abstractions package' - inputs: - command: pack - verbosityPack: Minimal - configuration: Release - nobuild: true - packDirectory: $(build.artifactStagingDirectory) - packagesToPack: 'src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj' - buildProperties: 'DurableTaskClientVersion=${{ parameters.durableTaskClientVersion }}' # need to add all the code-signing stuff + SBOM - task: DotNetCoreCLI@2 diff --git a/src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj b/src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj deleted file mode 100644 index 5afaff329..000000000 --- a/src/DurableTask.LargePayloadPurge.Abstractions/DurableTask.LargePayloadPurge.Abstractions.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - - netstandard2.0 - DurableTask.LargePayloadPurge - Microsoft.Azure.DurableTask.LargePayloadPurge.Abstractions - Optional orchestration service capability for large payload purge using the Durable Task SDK client models. - true - - - - 0 - 1 - 0 - $(MajorVersion).$(MinorVersion).$(PatchVersion) - $(VersionPrefix).0 - $(VersionPrefix).$([MSBuild]::Modulo($(FileVersionRevision), 65536)) - $(MajorVersion).$(MinorVersion).0.0 - $(VersionPrefix) - $(VersionPrefix)-$(VersionSuffix) - - - - - - - - - - - - - - - diff --git a/src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs b/src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs deleted file mode 100644 index 2bfe82bbd..000000000 --- a/src/DurableTask.LargePayloadPurge.Abstractions/IOrchestrationServiceLargePayloadPurgeClient.cs +++ /dev/null @@ -1,60 +0,0 @@ -// ---------------------------------------------------------------------------------- -// Copyright Microsoft Corporation -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ---------------------------------------------------------------------------------- - -namespace DurableTask.LargePayloadPurge -{ - using System; - using System.Collections.Generic; - using System.Threading; - using System.Threading.Tasks; - using Microsoft.DurableTask.Client; - - /// - /// Optional orchestration service client capability for purging tombstoned large payloads. - /// - public interface IOrchestrationServiceLargePayloadPurgeClient - { - /// - /// Records whether large payload auto-purge is enabled for the client's task hub. - /// - /// This operation does not start, stop, or wait for a purge runner. - /// Whether large payload auto-purge is enabled. - /// The caller's operation deadline in UTC, or - /// when the caller has not specified a deadline. - /// The token used to cancel the operation. - /// A task that represents the operation. - Task SetLargePayloadAutoPurgeAsync(bool enabled, DateTime deadlineUtc, CancellationToken cancellationToken); - - /// - /// Gets tombstoned large payloads that are ready to be purged. - /// - /// The maximum number of tombstones to return. - /// The caller's operation deadline in UTC, or - /// when the caller has not specified a deadline. - /// The token used to cancel the operation. - /// The tombstones to process. - Task> GetLargePayloadsToPurgeAsync( - int limit, DateTime deadlineUtc, CancellationToken cancellationToken); - - /// - /// Reports the outcomes of attempts to purge tombstoned large payloads. - /// - /// The purge outcomes, including the unchanged tombstone correlation tokens. - /// The caller's operation deadline in UTC, or - /// when the caller has not specified a deadline. - /// The token used to cancel the operation. - /// A task that represents the operation. - Task ReportLargePayloadPurgeResultsAsync( - IReadOnlyList results, DateTime deadlineUtc, CancellationToken cancellationToken); - } -}