Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side) - #758
Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side)#758wangbill (YunchuWang) wants to merge 31 commits into
Conversation
82ae04d to
0ac2dc3
Compare
0752610 to
cab0e9a
Compare
cab0e9a to
c05b15a
Compare
Large orchestration payloads are externalized to Azure Blob Storage as `blob:v1:<container>:<blobName>` tokens. The DTS backend stores those tokens but cannot delete the backing blobs (it has no storage credentials) — only this SDK can. This adds an opt-in, whole-scheduler singleton durable entity + orchestration job (mirroring src/ExportHistory) that drains payload rows the backend has soft-deleted and deletes their blobs, then acks so the backend can hard-delete the rows. Design: - PayloadStore.DeleteAsync is virtual (default throws NotSupportedException so it is non-breaking for existing external subclasses); BlobPayloadStore overrides it to decode the token and call DeleteIfExistsAsync (idempotent). - BlobPurgeJob (TaskEntity singleton): Create is a no-op when already Active so racing client processes don't disturb the running job; Run starts a fixed-id orchestrator. - BlobPurgeJobOrchestrator (perpetual): fetch a batch of tombstones, delete the blobs with capped parallelism, ack the successful deletions (failed tokens stay tombstoned to retry), idle on a timer when empty, ContinueAsNew periodically. - ExecuteBlobPurgeJobOperationOrchestrator bridges client -> entity. - Two new unary RPCs on TaskHubSidecarService: GetTombstonedPayloads / AckPurgedPayloads (authoritative proto follow-up: microsoft/durabletask-protobuf#76). - LargePayloadStorageOptions gains AutoPurge (opt-in, default false) and PayloadPurgeBatchSize (default 500). - Client-side BlobPurgeJobStarter (IHostedService) ensures the singleton job when AutoPurge is enabled, without blocking host startup. Worker always registers the entity/orchestrators/activities so a client-enabled job has something to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
c05b15a to
306d19f
Compare
…er simplification - Drop the `Dto` suffix now that the payload records are first-class public types in `Microsoft.DurableTask.Client` (`TombstonedPayload`, `PayloadPurgeAck`). - Collapse the magic `500` batch-size literal into a single `BlobPurgeConstants.DefaultBatchSize` used everywhere. - Rename `BlobPurgeJobStatus.Stopped` -> `Pending` (still the zero value) and remove the dead `Failed` member (nothing ever set it; the job self-heals). - Make the perpetual orchestrator self-heal: wrap each cycle in try/catch so a transient backend/entity/activity failure logs, backs off, and continues instead of failing the orchestration and killing the eternal loop. - Ack poison tokens: `DeleteExternalBlobActivity` now returns a three-way `BlobDeleteResult` (Deleted/Discarded/Retry). Malformed tokens are discarded and acked so the backend can clear the stuck row instead of re-streaming it forever; transient failures stay tombstoned to retry. - Replace the single-value `BlobPurgeJobCreationOptions` record with a plain `int` on `BlobPurgeJob.Create`. - Guard the client fetch RPC: `GetTombstonedPayloadsAsync` throws `ArgumentOutOfRangeException` unless `0 < limit < 1000`. - Simplify `BlobPurgeJobStarter` to a fixed-instance-id fire-once: drop the entity-active pre-check and schedule the Create bridge once with a fixed instance id, retrying only until the backend is reachable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Extensions/AzureBlobPayloads/AutoPurge/Models/BlobPurgeJobState.cs:34
BlobPurgeJobState.LastErroris documented as “the last error message”, but it’s never set anywhere in the auto-purge implementation (only cleared on Create). This is misleading for anyone reading the entity state; either wire it up (e.g., set it on cycle failures) or update the docs to reflect that it’s currently unused/reserved.
/// <summary>
/// Gets or sets the last error message, if any.
/// </summary>
public string? LastError { get; set; }
The backend never branches on reason - it acts on disposition alone - so reason is diagnostics only, and its granularity should match the number of distinct operator responses rather than the number of distinct causes. storageErrorCode already carries the specific storage status, so four of the old values encoded the same fact twice. TRANSIENT_STORAGE_FAILURE, STORAGE_ACCOUNT_UNREACHABLE and STORAGE_AUTHORIZATION_FAILED collapse into STORAGE_FAILURE. MALFORMED_TOKEN, INVALID_STORAGE_REQUEST, LEGACY_V1_TOKEN and UNSUPPORTED_TOKEN_VERSION collapse into TOKEN_NOT_PURGEABLE. STORE_CANNOT_DELETE stays separate because storage is never contacted, so its storageErrorCode is empty. Dispositions are unchanged on every branch. TOKEN_NOT_PURGEABLE now spans both Quarantined and Retry - an unsupported version prefix stays Retry because an SDK upgrade resolves it - so each branch continues to state its disposition explicitly rather than deriving it from the reason, which would strand those rows permanently. Proto verified byte-identical to contract-canonical.proto across all three repos by the marker-based harness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Orchestrations/BlobPurgeJobOrchestrator.cs:47
PurgeBatchSizecan be persisted as 0 (see BlobPurgeJobTests) and is then passed through to GetLargePayloadTombstonesActivity, which calls DurableTaskClient.GetLargePayloadTombstonesAsync(limit). The gRPC client throws ArgumentOutOfRangeException when limit <= 0, causing the orchestrator to repeatedly fail/back off and never make progress. Add a defensive clamp to a valid range before using the value.
string jobId = input.JobEntityId.Key;
int batchSize = input.PurgeBatchSize;
int processedCycles = input.ProcessedCycles;
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:108
- StopAsync waits for the background ensure task but never observes its completion/exception. If EnsureJobAsync ever faults unexpectedly, the exception may surface later as an UnobservedTaskException. After WhenAny returns, await the task (inside a try/catch) when it completed to observe and swallow the result as intended.
Task? pending = this.ensureTask;
if (pending is not null)
{
// The ensure loop observes cancellation and returns promptly; swallow any faulted/cancelled result.
await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
Comment-only. No enum value, field number, disposition, or code path changes; a non-comment-line filter over the full diff of both files returns empty. Block 3 of orchestrator_service.proto is re-spliced verbatim from the canonical contract. The "--- Reported with DELETED / RETRY / QUARANTINED ---" banners are removed: they asserted a 1:1 reason-to-disposition mapping that does not hold, because TOKEN_NOT_PURGEABLE is deliberately reported with two dispositions (QUARANTINED for a v1/malformed/invalid-request token, RETRY for an unrecognized version prefix that a newer worker can read). The disposition now lives in each value's own comment, and the enum header states outright that reason and disposition are orthogonal and that no mapping between them may be asserted. LargePayloadPurgeReason XML docs are rewritten to mirror the new canonical prose, so the managed surface no longer describes the superseded grouping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:36
- BlobPurgeJob.Create currently accepts any purgeBatchSize (including 0/negative), stores it, and signals the purge orchestrator. If a client ever creates the job with an invalid batch size, the orchestrator will later call GetLargePayloadTombstonesActivity with that value, and GrpcDurableTaskClient.GetLargePayloadTombstonesAsync throws for limit <= 0, causing the job to spin in the error/backoff loop. Validate the batch size at the entity boundary to keep the job from entering an unrecoverable misconfigured state.
public void Create(TaskEntityContext context, int purgeBatchSize)
{
if (this.State.Status == BlobPurgeJobStatus.Active)
{
logger.BlobPurgeJobAlreadyRunning(context.Id.Key);
return;
}
this.State.Status = BlobPurgeJobStatus.Active;
this.State.PurgeBatchSize = purgeBatchSize;
this.State.CreatedAt ??= DateTimeOffset.UtcNow;
this.State.LastModifiedAt = DateTimeOffset.UtcNow;
this.State.LastError = null;
test/Extensions/AzureBlobPayloads.Tests/AutoPurge/BlobPurgeJobTests.cs:78
- This test currently asserts that BlobPurgeJob.Create accepts a batch size of 0. If Create is callable via the client-to-entity bridge, persisting an invalid batch size can wedge the job (the gRPC client rejects limit <= 0). Update the test to assert the expected validation behavior instead.
[Fact]
public async Task Create_StoresBatchSizeVerbatim_WithoutCoercion()
{
// Arrange - the batch size is validated once at specification (LargePayloadStorageOptions), so the
// entity trusts its input and performs no coercion of its own. A zero here is stored as-is, proving
// the previous non-positive-to-default fallback was removed.
TestEntityOperation operation = new(
nameof(BlobPurgeJob.Create),
new TestEntityState(null),
0);
// Act
await this.job.RunAsync(operation);
// Assert
BlobPurgeJobState state = Assert.IsType<BlobPurgeJobState>(
operation.State.GetState(typeof(BlobPurgeJobState)));
state.PurgeBatchSize.Should().Be(0);
}
Both fields were verified write-only across the whole system: the backend persists them and nothing reads either one - no SELECT, no WHERE, no API, no alert. Disposition alone drives backend behavior, so the enum was a lossy copy of information that already lives in worker telemetry. Deleting reason also deletes the hazard it created. TOKEN_NOT_PURGEABLE spanned both Retry and Quarantined, so any consumer deriving disposition from reason would have silently stranded rows an SDK upgrade would fix. All 12 activity branches keep their disposition unchanged. Verified branch by branch against the pre-change file rather than by inspection: the unknown-prefix branch stays Retry, and the v1-prefix, malformed-token, and HTTP 400 branches stay Quarantined. Failure detail is now logged rather than sent over the wire. Design section 7 forbids logging raw exceptions, so each classification site logs a bounded cause literal instead. Logging at the classification site rather than at the call site restores the full 11-way granularity the 7-value enum had lost: 401/403, 5xx, and an unreachable account are now distinct in telemetry where the enum had collapsed all three into STORAGE_FAILURE. Two deliberate deviations from the request, both reported: 1. LargePayloadPurgeEnumParityTests.cs is kept, not deleted. It always tested two enums, and the disposition cast in GrpcDurableTaskClient survives - it is the cast that decides whether a row is deleted or quarantined. Only the reason parity fact is removed. 2. This is not the last outbound enum cast. LargePayloadPurgeResult still carries Disposition, so the no-inbound-enum invariant test still guards a live cast and is not vacuous. CA1873 at BlobPurgeJobOrchestrator.cs:66 remains deliberately unfixed: unguarded logging is the established convention here, 77 times solution-wide. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:28
- BlobPurgeJob.Create stores purgeBatchSize verbatim with no range validation. If an invalid value (<=0 or > MaxBatchSize) is ever passed (e.g., direct entity call), the scheduled BlobPurgeJobOrchestrator will repeatedly fail when fetching tombstones (client validates 1..1000) and the job will back off forever. Validate the range at the entity boundary and fail fast with ArgumentOutOfRangeException.
public void Create(TaskEntityContext context, int purgeBatchSize)
{
if (this.State.Status == BlobPurgeJobStatus.Active)
{
logger.BlobPurgeJobAlreadyRunning(context.Id.Key);
src/Extensions/AzureBlobPayloads/AutoPurge/Activities/DeleteExternalBlobActivity.cs:64
- DeleteExternalBlobActivity claims it returns failures as a disposition rather than throwing, but RunAsync currently throws on empty input via Check.NotNullOrEmpty. Since the token comes from the backend, treating an empty token as a deterministic protocol failure and returning a Quarantined disposition keeps a single bad row from failing the whole batch and avoids an activity retry loop that never produces a per-row result.
public override async Task<BlobPurgeOutcome> RunAsync(TaskActivityContext context, string input)
{
Check.NotNullOrEmpty(input, nameof(input));
return await this.DeleteAsync(input);
| public override async Task<List<LargePayloadTombstone>> GetLargePayloadTombstonesAsync( | ||
| int limit, CancellationToken cancellation = default) | ||
| { | ||
| if (limit <= 0 || limit > 1000) |
There was a problem hiding this comment.
move to constant
| { | ||
| await this.sidecarClient.ReportLargePayloadPurgeResultsAsync(request, cancellationToken: cancellation); | ||
| } | ||
| catch (RpcException e) when (e.StatusCode == StatusCode.Cancelled) |
There was a problem hiding this comment.
same here, we need to handle other exception cases as well
| return new BlobPurgeOutcome(LargePayloadPurgeDisposition.Quarantined); | ||
| } | ||
|
|
||
| if (!token.StartsWith(BlobPayloadStore.TokenPrefixV2, StringComparison.Ordinal)) |
There was a problem hiding this comment.
can we parse the token into payload token format, if version number is < v2, we dont support auto purge and so versions > v2 we support. if token does not follow the format defined, should be considered deleted and delete it from dts backend as well
| DecodeTokenResult decoded = DecodeToken(token); | ||
|
|
||
| BlobClient blob; | ||
| if (!decoded.IsV2) |
There was a problem hiding this comment.
isV2 as a flag is hacky, should be something like autoPurgable?
EnsureJobAsync read the bridge orchestration's status and then scheduled it. Those are two independent RPCs with no atomicity between them, so two hosts starting together could both observe "absent" and both schedule. The status check is replaced by a dedupe policy on the create call, which makes the decision atomic in the backend. The policy is the exact semantic equivalent of the predicate it replaces: the old code rescheduled only when the bridge was absent, Failed, or Terminated, so Failed and Terminated are the only replaceable statuses and every other status is deduped. The list must name all five explicitly because the wire format is an inverted whitelist - the policy is computed as (all statuses - dedupe statuses), so any status omitted silently becomes replaceable. Pending in particular is not optional: instances are created Pending and only become Running after awaiting their first task, so leaving it out would open a real window in which a just-scheduled bridge is replaced. This also makes the OrchestrationAlreadyExistsException handler live. It was previously dead code: the exception is only thrown for statuses in DedupeStatuses, and no policy was supplied. It is now the normal steady-state outcome on every host start after the first, so its comment is corrected rather than its behavior. OrchestrationRuntimeStatus.Canceled is [Obsolete], so naming it emits CS0618. It is suppressed with a scoped pragma, matching how ProtoUtils and StartOrchestrationOptionsExtensions handle the same member. Dropping the status instead would have silently changed behavior on Canceled. Adds a test pinning the exact dedupe set. An omitted status is invisible to the compiler and would surface only as a healthy singleton being purged and replaced in production; EnsureJobAsync had no coverage before this. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:82
- The auto-purge startup gate hard-codes
BlobPayloadStoreas the only delete-capable store (if (this.store is not BlobPayloadStore)), which contradicts the newPayloadStore.DeleteAsynccontract (virtual, intended for other implementations to override). This prevents a customPayloadStorethat overridesDeleteAsyncfrom ever enabling auto-purge, even if it can delete the v2 blob tokens correctly. Consider gating on whetherDeleteAsyncis overridden (or otherwise supported) rather than on the concrete type.
if (this.store is not BlobPayloadStore)
{
this.logger.BlobPurgeStoreCannotDelete(this.store.GetType().FullName);
return Task.CompletedTask;
}
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:62
Runpassesthis.State.PurgeBatchSizestraight intoBlobPurgeJobRunRequestwithout range validation. If the entity is created with an out-of-range batch size (e.g. 0),GetLargePayloadTombstonesAsyncwill throwArgumentOutOfRangeException(limit must be 1..1000) and the orchestrator will back off/retry forever. Clamping or defaulting the batch size at scheduling time avoids a permanently bricked job state.
context.ScheduleNewOrchestration(
new TaskName(nameof(BlobPurgeJobOrchestrator)),
new BlobPurgeJobRunRequest(context.Id, this.State.PurgeBatchSize),
startOrchestrationOptions);
src/Client/Grpc/GrpcDurableTaskClient.cs:685
ReportLargePayloadPurgeResultsAsyncblindly castsresult.Dispositionto the protobuf enum. If a caller passes the defaultLargePayloadPurgeDisposition.Unspecified(or any undefined numeric value), the request can be rejected server-side and potentially break purge reporting. Validating the disposition before adding it to the request makes misuse fail fast with a clear client-side error.
foreach (LargePayloadPurgeResult result in results)
{
request.Results.Add(new P.LargePayloadPurgeResult
{
PartitionId = result.PartitionId,
InstanceKey = result.InstanceKey,
PayloadId = result.PayloadId,
Revision = result.Revision,
// The managed disposition enum declares the same numeric values as its protobuf counterpart,
// so it maps across by value. This is the only enum on the message and it only travels
// outbound, so the SDK can never receive a value it does not know.
Disposition = (P.LargePayloadPurgeDisposition)result.Disposition,
});
The bridge deduped five statuses, which preserved the exact semantics of the status check it replaced. That preserved a dead end along with them: a bridge that had already run was Completed and therefore never re-ran, so there was no path back from a job that had stopped. Deduping only Pending and Running says something narrower - while the bridge is alive leave it alone, in any other state re-run it - and that is what makes the job self-healing. The perpetual orchestrator exits cleanly when it reads back a null entity state, which is reachable when the entity is removed by CleanEntityStorageAsync. A removed entity is back to its default Pending status, so the job is left dead with a Completed bridge behind it. Making Completed replaceable means the next host start re-runs Create, which finds the entity not Active and rebuilds the job. Re-running a finished bridge is cheap and safe: its only effect is calling Create, which no-ops while the entity is Active, so the steady-state cost is one instance replacement plus one entity call per host start. Pending stays in the list. Instances are created Pending and only become Running after awaiting their first task, so omitting it would leave a real window in which a just-scheduled bridge is replaced. Two comments were wrong and are corrected rather than carried forward. The first claimed the backend would purge and replace a terminal instance on every host restart; a null DedupeStatuses field means the backend default, which the contract explicitly leaves undefined, so that asserted behavior the SDK does not promise. The second described the OrchestrationAlreadyExistsException handler as the steady-state outcome. That was true when Completed was deduped, but under this policy a finished bridge is replaced rather than rejected, so the handler is once again the concurrent-start race path it was originally written for. The CS0618 suppression is removed with Canceled. ContinuedAsNew and Canceled are the only obsolete members of OrchestrationRuntimeStatus, so with Canceled gone the pragma covered nothing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Client/Grpc/GrpcDurableTaskClient.cs:685
- ReportLargePayloadPurgeResultsAsync currently allows LargePayloadPurgeDisposition.Unspecified to be sent over the wire via the numeric cast. The proto explicitly makes 0 a meaningless sentinel and says the backend must reject it; validating here would fail fast with a clearer exception and avoid a round-trip RPC failure.
// The managed disposition enum declares the same numeric values as its protobuf counterpart,
// so it maps across by value. This is the only enum on the message and it only travels
// outbound, so the SDK can never receive a value it does not know.
Disposition = (P.LargePayloadPurgeDisposition)result.Disposition,
});
src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs:63
- TryAddSingleton registers a single PayloadStore for the entire IServiceCollection but constructs it using named options for the current builder (monitor.Get(builder.Name)). If multiple named clients/workers call UseExternalizedPayloads with different builder names, later builders will reuse a PayloadStore constructed for a different name, while their PostConfigure path still reads options for their own name. That can lead to interceptor/store configuration mismatches (e.g., container/account settings) depending on registration order.
builder.Services.TryAddSingleton<PayloadStore>(sp =>
{
LargePayloadStorageOptions opts = sp.GetRequiredService<IOptionsMonitor<LargePayloadStorageOptions>>().Get(builder.Name);
return new BlobPayloadStore(opts);
});
src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs:60
- Same concern as the client builder extension: TryAddSingleton creates at most one PayloadStore but builds it from monitor.Get(builder.Name). With multiple named workers (or mixed named worker+client) using different names, the singleton store may be constructed from the wrong named options instance, producing mismatched configuration relative to the named GrpcDurableTaskWorkerOptions post-configure path.
builder.Services.TryAddSingleton<PayloadStore>(sp =>
{
LargePayloadStorageOptions opts = sp.GetRequiredService<IOptionsMonitor<LargePayloadStorageOptions>>().Get(builder.Name);
return new BlobPayloadStore(opts);
});
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:108
- StopAsync waits for the background ensure task to complete, but it never observes task faults. If EnsureJobAsync ever throws unexpectedly (e.g., new exception type added later), this can surface as an UnobservedTaskException. Consider observing and swallowing the exception when the task completes during StopAsync.
if (pending is not null)
{
// The ensure loop observes cancellation and returns promptly; swallow any faulted/cancelled result.
await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
}
Answers the lifecycle gap on the singleton purge job: disabling auto-purge or changing the batch size was permanently ignored once the job existed. Stop: a new entity operation moves the job off Active and is idempotent. The perpetual orchestrator is deliberately not terminated - it already reads the entity at the top of every cycle and already has a clean-exit path, so Stop simply makes that path reachable. Shutdown is cooperative and race-free: the in-flight cycle finishes and the orchestrator exits on its own terms. Disable path: the starter used to return silently when auto-purge was off, so no host ever told the backend and a job created while the flag was on kept deleting blobs forever. It now signals Stop on the same fire-and-forget, retrying background task as the enable path. The store gate is deliberately not applied there: stopping needs no ability to delete, and a user who has switched to a store that cannot delete is exactly who needs the job stopped. Batch size: two independent breaks, both fixed. Create now takes the batch size even when the job is already Active (without re-signalling Run), and the orchestrator reads it from the entity state it already fetches each cycle instead of from its fixed input, falling back to the input when the stored value is not positive. Automatic restart of a dead orchestrator is a deliberate non-goal, documented in place: the only available restart schedules on the entity path, which carries no dedupe policy, so signalling a healthy orchestrator would terminate and replace it mid-work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
Summary
Large orchestration payloads are externalized to Azure Blob Storage by the
AzureBlobPayloadsextension, with a token persisted in SQL instead of the payload bytes. When the orchestration is purged, DTS removes the SQL state but cannot delete the backing blob — it has no customer storage credentials. Only the worker does.This PR implements the worker/SDK side. The backend records a durable tombstone for each externalized payload whose orchestration state is gone; the worker fetches due tombstones, deletes the blobs, and reports the outcome of every row so the backend can resolve, reschedule, or quarantine it.
Companion changes: contract in microsoft/durabletask-protobuf#76, backend in AAPT-DTMB PR 16368738.
gRPC contract
Two unary RPCs on
TaskHubSidecarService(worker is the client). The vendoredsrc/Grpc/orchestrator_service.protois byte-identical to protobuf#76 — verified mechanically by exact-substring comparison, not by eye.LargePayloadTombstone { partitionId, instanceKey, payloadId, token, revision }LargePayloadPurgeResult { identity, revision, disposition }google.protobuf.BoolValue large_payload_auto_purge_enabled = 12on the existingGetWorkItemsRequest— no new handshake, and null means "no opinion".revisionis echoed back unmodified as a compare-and-swap guard, so duplicate or stale reports are no-ops without a per-row lease.Dispositions
Three dispositions, split on whether a failure can self-heal. There is no
Discarded: a success is reported explicitly asDeleted, and every failure is carried byRetryorQuarantined.DeletedRetryQuarantinedThe worker never computes a retry delay. It reports the failure and the backend owns scheduling and backoff. The orchestrator reports the whole batch unconditionally — including retryable rows — because the backend needs to hear about a failure in order to defer the row. If every row comes back
Retry(a storage outage), the cycle appliesErrorBackoffso an outage cannot become a tight refetch loop.dispositionis the entire outcome. An earlier revision also carriedreasonandstorageErrorCode; both were removed after verifying they were write-only end to end — the backend persists them and nothing reads either one. Failure detail is logged by the worker instead, at the classification site, which is strictly richer than the enum was: 401/403, 5xx, and an unreachable account are distinct in telemetry where the enum collapsed all three into one value.Blob ownership marker
A recognized token proves only that the text looks like one this store emits — the column is customer-writable. So ownership is recorded on the object itself:
UploadAsyncwrites fixed blob metadatamanaged_by=dts, and the worker re-reads the target's metadata immediately before deleting.If-Matchon the ETag from that same read, so a mid-flight overwrite fails the delete rather than destroying newer content.Deleted, so the tombstone is resolved rather than retried forever. This is an expected outcome, not a defect, and must not be quarantined. The worker logs it distinctly so a customer whose payloads are all self-authored is still visible in telemetry.The metadata name uses an underscore because Azure requires blob metadata names to be valid C# identifiers;
managed-bywould be rejected at upload.Only
blob:v2:tokens are auto-purged. Av1token reaching this path is an invariant violation (v1 is excluded at insertion) and is quarantined rather than deleted.Testing
Verified on a clean (
--no-incremental) build:dotnet build Microsoft.DurableTask.sln— 0 errorstest/Extensions/AzureBlobPayloads.Tests— 54 passedtest/Client/Grpc.Tests— 56 passedLargePayloadPurgeEnumParityTestspins proto↔managed parity forLargePayloadPurgeDispositionby value and name in both directions, and asserts that no inbound type exposes an enum.Dispositionis the one enum crossing the wire, and its numeric cast inGrpcDurableTaskClientis what decides whether a row is deleted or quarantined; that cast is safe only because enums travel outbound-only, and the test fails the build if a future change breaks that invariant.Notes / intentional deviations
CA1873atBlobPurgeJobOrchestrator.cs:66(unguarded logging). Kept deliberately for consistency with 77 existing instances across the solution. Blame-based attribution againstmainconfirms this is the only warning this branch introduces.PurgedCountcounts everyDeletedrow, including blobs skipped for lacking the ownership marker. Excluding them would pin the counter at 0 for a customer whose payloads are all self-authored, making a healthy draining job read as wedged; the worker log supplies the precision instead.PayloadStore.DeleteAsyncisvirtualwith a default that throwsNotSupportedException, so existing external subclasses are unaffected.BlobPurgeJob.Createis a no-op when alreadyActiveso racing client processes don't disturb a running job.BlobPurgeJobStarterimplementsIDisposablerather than disposing its CTS inStopAsync: that method returns on the host shutdown token while the ensure task may still be live, so disposing there would fault it with an unobservedObjectDisposedException.