Skip to content

Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side) - #758

Open
wangbill (YunchuWang) wants to merge 28 commits into
mainfrom
yunchuwang-wangbill-blob-payload-autopurge-sdk
Open

Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side)#758
wangbill (YunchuWang) wants to merge 28 commits into
mainfrom
yunchuwang-wangbill-blob-payload-autopurge-sdk

Conversation

@YunchuWang

@YunchuWang wangbill (YunchuWang) commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

Large orchestration payloads are externalized to Azure Blob Storage by the AzureBlobPayloads extension, 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 vendored src/Grpc/orchestrator_service.proto is byte-identical to protobuf#76 — verified mechanically by exact-substring comparison, not by eye.

rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);
rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);
  • LargePayloadTombstone { partitionId, instanceKey, payloadId, token, revision }
  • LargePayloadPurgeResult { identity, revision, disposition }
  • Opt-in is google.protobuf.BoolValue large_payload_auto_purge_enabled = 12 on the existing GetWorkItemsRequest — no new handshake, and null means "no opinion".

revision is 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 as Deleted, and every failure is carried by Retry or Quarantined.

Disposition Meaning Examples
Deleted Tombstone resolved blob deleted; already absent; blob not store-owned
Retry Transient; backend reschedules storage unreachable, 401/403, 408/429/5xx, unknown token prefix
Quarantined Deterministic; retrying can never succeed malformed v2 token, HTTP 400, legacy v1 token

The 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 applies ErrorBackoff so an outage cannot become a tight refetch loop.

disposition is the entire outcome. An earlier revision also carried reason and storageErrorCode; 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: UploadAsync writes fixed blob metadata managed_by=dts, and the worker re-reads the target's metadata immediately before deleting.

  • Marker present → delete, using If-Match on the ETag from that same read, so a mid-flight overwrite fails the delete rather than destroying newer content.
  • Marker absent → blob left untouched, and the row is still reported 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-by would be rejected at upload.

Only blob:v2: tokens are auto-purged. A v1 token 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.sln0 errors
  • test/Extensions/AzureBlobPayloads.Tests54 passed
  • test/Client/Grpc.Tests56 passed
  • Blame-attributed warnings — 127 unique in-repo, exactly 1 introduced by this branch

LargePayloadPurgeEnumParityTests pins proto↔managed parity for LargePayloadPurgeDisposition by value and name in both directions, and asserts that no inbound type exposes an enum. Disposition is the one enum crossing the wire, and its numeric cast in GrpcDurableTaskClient is 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

  • Ships one intentional warning: CA1873 at BlobPurgeJobOrchestrator.cs:66 (unguarded logging). Kept deliberately for consistency with 77 existing instances across the solution. Blame-based attribution against main confirms this is the only warning this branch introduces.
  • PurgedCount counts every Deleted row, 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.DeleteAsync is virtual with a default that throws NotSupportedException, so existing external subclasses are unaffected.
  • BlobPurgeJob.Create is a no-op when already Active so racing client processes don't disturb a running job.
  • BlobPurgeJobStarter implements IDisposable rather than disposing its CTS in StopAsync: that method returns on the host shutdown token while the ensure task may still be live, so disposing there would fault it with an unobserved ObjectDisposedException.

Depends on protobuf#76 for the authoritative contract and on the DTS backend serving these RPCs.

@YunchuWang
wangbill (YunchuWang) force-pushed the yunchuwang-wangbill-blob-payload-autopurge-sdk branch 2 times, most recently from 82ae04d to 0ac2dc3 Compare July 8, 2026 20:50
@YunchuWang
wangbill (YunchuWang) force-pushed the yunchuwang-wangbill-blob-payload-autopurge-sdk branch 4 times, most recently from 0752610 to cab0e9a Compare July 13, 2026 19:07
@YunchuWang wangbill (YunchuWang) changed the title Add large-payload blob auto-purge (worker/SDK side) Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side) Jul 13, 2026
@YunchuWang
wangbill (YunchuWang) force-pushed the yunchuwang-wangbill-blob-payload-autopurge-sdk branch from cab0e9a to c05b15a Compare July 13, 2026 20:38
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>
@YunchuWang
wangbill (YunchuWang) force-pushed the yunchuwang-wangbill-blob-payload-autopurge-sdk branch from c05b15a to 306d19f Compare July 13, 2026 22:43
Comment thread src/Client/Core/PayloadPurgeAckDto.cs Outdated
Comment thread src/Client/Core/TombstonedPayloadDto.cs Outdated
Comment thread src/Client/Grpc/GrpcDurableTaskClient.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/AutoPurge/Models/BlobPurgeJobStatus.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/AutoPurge/Models/BlobPurgeJobStatus.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:36

  • purgeBatchSize is stored verbatim into entity state without validation. If a caller invokes BlobPurgeJob.Create with 0/negative or >1000, the orchestrator will later fail when calling GetTombstonedPayloads (the gRPC client enforces 1..1000), causing the job to back off and loop forever without making progress. Consider validating the range here (or coercing to a safe default) even if the starter path uses validated options, since the entity operation is callable independently.
        this.State.Status = BlobPurgeJobStatus.Active;
        this.State.PurgeBatchSize = purgeBatchSize;
        this.State.CreatedAt ??= DateTimeOffset.UtcNow;
        this.State.LastModifiedAt = DateTimeOffset.UtcNow;
        this.State.LastError = null;

src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:74

  • The auto-purge capability gate is currently this.store is not BlobPayloadStore, which assumes only BlobPayloadStore can delete payloads. Since PayloadStore.DeleteAsync was intentionally added as a virtual extensibility point, a custom PayloadStore could support deletion without being BlobPayloadStore. Consider gating on whether DeleteAsync is overridden (capability) rather than the concrete store type (implementation), so delete-capable custom stores can still opt in.
        if (this.store is not BlobPayloadStore)
        {
            this.logger.BlobPurgeStoreCannotDelete(this.store.GetType().FullName);
            return Task.CompletedTask;
        }

Replaces the earlier draft shape with the finalized contract and dispositions.

Wire contract (orchestrator_service.proto): drops GetTombstonedPayloads /
AckPurgedPayloads and their messages, and splices in the canonical block
verbatim -- GetLargePayloadTombstones / ReportLargePayloadPurgeResults, the
LargePayloadPurgeDisposition and LargePayloadPurgeReason enums, and
large_payload_auto_purge_enabled = 12 on GetWorkItemsRequest.

Dispositions: removes Discarded entirely. It was success-shaped and destroyed
evidence. Every attempt now returns a disposition plus a stable reason code.
v1 tokens and malformed v2 bodies are Quarantined; unknown version prefixes,
unreachable accounts, and authorization failures are Retry. Splitting the
store's three ArgumentException cases is done with a token prefix gate in the
activity rather than new exception types, which required widening
TokenPrefixV2 to internal.

Ownership marker: uploads now stamp managed_by=dts as blob metadata on both
the compressed and uncompressed write paths, and the delete path reads the
blob's properties and passes that read's ETag as an If-Match on the delete, so
check-and-delete is atomic without a lease. A blob without the marker is left
untouched and reported DELETED / BLOB_NOT_STORE_OWNED. Metadata rather than
index tags: tags are unsupported on ADLS Gen2 and would require Storage Blob
Data Owner.

Orchestration: the batch is now always reported, since the backend owns retry
scheduling and needs to hear about failures to defer a row. Backoff is
preserved for the all-retry case so a storage outage cannot become a tight
refetch loop. Progress counts only Deleted rows; counting quarantined rows as
purged would reintroduce the success-shaped reporting this change removes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 11, 2026 22:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:55

  • processedCycles is incremented before the continue-as-new check, but the condition uses >; this causes the orchestrator to ContinueAsNew after 6 cycles when ContinueAsNewFrequency is 5. This contradicts the constant’s intent and increases history size more than necessary.
            processedCycles++;
            if (processedCycles > ContinueAsNewFrequency)
            {
                context.ContinueAsNew(new BlobPurgeJobRunRequest(input.JobEntityId, batchSize, ProcessedCycles: 0));
                return null!;

src/Grpc/orchestrator_service.proto:799

  • PR description and the embedded gRPC contract snippet describe GetTombstonedPayloads / AckPurgedPayloads, but this proto defines GetLargePayloadTombstones / ReportLargePayloadPurgeResults. Before merging (and before syncing from the upstream protobuf repo), please reconcile the RPC/message naming to avoid a breaking drift between the SDK and the authoritative proto.
    // Returns a bounded, deterministically ordered batch of due large-payload tombstones whose
    // external blobs the worker must delete. Scoped to the caller's authenticated task hub.
    // Only rows that are pending and whose next attempt time has arrived are returned; a row stays
    // pending until its outcome is reported, so this is safe under retries and duplicate callers.
    rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);

    // Reports the outcome of each attempted blob deletion. The backend owns retry scheduling: it
    // deletes rows reported as DELETED, reschedules RETRY with a reason-appropriate next attempt,
    // and moves QUARANTINED rows out of the active fetch while preserving their evidence.
    rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);

Comment on lines +128 to +134
foreach (LargePayloadPurgeResult result in results)
{
if (result.Disposition == disposition)
{
count++;
}
}
Comment on lines +394 to +400
foreach (KeyValuePair<string, string> entry in metadata)
{
if (string.Equals(entry.Key, OwnershipMarkerName, StringComparison.OrdinalIgnoreCase))
{
return string.Equals(entry.Value, OwnershipMarkerValue, StringComparison.Ordinal);
}
}
Pre-review polish only; no behaviour change.

CS8603 in DeleteExternalBlobActivity.SanitizeErrorCode fired on netstandard2.0
only, where string.IsNullOrEmpty carries no [NotNullWhen(false)] annotation so
flow analysis cannot prove the else branch non-null. Replaced with a constant
pattern, which the compiler analyses itself and so behaves identically on every
target framework.

CA1001 in BlobPurgeJobStarter: the type owned a CancellationTokenSource but was
not disposable. The source is deliberately disposed in Dispose rather than in
StopAsync -- StopAsync stops waiting as soon as the host shutdown token fires,
so the ensure task may still hold the token, and disposing there would fault
that still-running task with ObjectDisposedException. The container disposes
singletons after every StopAsync has returned, which is the safe point.

SA1600: documented the BlobPurgeJobStarter constructor.

CA1873 on BlobPurgeJobOrchestrator is deliberately left as-is: it occurs 77
times across this repo, so unguarded log calls are the established local
convention and changing one new file would make it inconsistent with its
neighbours for no benefit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 11, 2026 22:33
The managed-to-proto enum casts in ReportLargePayloadPurgeResultsAsync are safe
only because no enum crosses the wire inbound on this feature: the SDK casts
values it defined itself, so it can never receive an unknown value and silently
reinterpret it. That invariant held by the shape of the contract rather than by
construction, and nothing in the code stated it.

Adding an enum to an inbound type would create exactly the hazard the casts
avoid today -- a newer backend sending a value this SDK does not know, mapped by
raw numeric cast onto a valid-but-wrong member -- and it would compile silently,
mis-dispositioning rows in production rather than failing a build.

Asserted by reflection over the inbound types in the existing parity test file,
which already exists to protect this mapping. The casts are unchanged: they are
correct today, and an explicit switch would add branching for a hazard that does
not currently exist on this path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:28

  • BlobPurgeJob.Create stores purgeBatchSize verbatim without validation. Because the entity can be invoked via the public bridge orchestrator, an invalid value (e.g., 0) can be persisted and later cause GetLargePayloadTombstonesAsync(limit) to throw (limit must be 1..1000), resulting in a perpetual error/backoff loop.
    public void Create(TaskEntityContext context, int purgeBatchSize)
    {
        if (this.State.Status == BlobPurgeJobStatus.Active)
        {
            logger.BlobPurgeJobAlreadyRunning(context.Id.Key);

src/Extensions/AzureBlobPayloads/AutoPurge/Orchestrations/BlobPurgeJobOrchestrator.cs:56

  • ContinueAsNewFrequency is documented/used as "every 5 cycles", but the condition processedCycles > ContinueAsNewFrequency triggers on the 6th cycle (processedCycles is incremented before the check). This keeps more history than intended.
            processedCycles++;
            if (processedCycles > ContinueAsNewFrequency)
            {
                context.ContinueAsNew(new BlobPurgeJobRunRequest(input.JobEntityId, batchSize, ProcessedCycles: 0));
                return null!;
            }

src/Grpc/orchestrator_service.proto:799

  • The PR description's "gRPC contract" section describes unary RPCs named GetTombstonedPayloads/AckPurgedPayloads with different message shapes, but the vendored proto here defines GetLargePayloadTombstones and ReportLargePayloadPurgeResults (with additional disposition/reason enums). This makes it hard to reconcile the intended wire contract and increases the risk of incompatibility with the upstream protobuf PR.
    // Returns a bounded, deterministically ordered batch of due large-payload tombstones whose
    // external blobs the worker must delete. Scoped to the caller's authenticated task hub.
    // Only rows that are pending and whose next attempt time has arrived are returned; a row stays
    // pending until its outcome is reported, so this is safe under retries and duplicate callers.
    rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);

    // Reports the outcome of each attempted blob deletion. The backend owns retry scheduling: it
    // deletes rows reported as DELETED, reschedules RETRY with a reason-appropriate next attempt,
    // and moves QUARANTINED rows out of the active fetch while preserving their evidence.
    rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);

Copilot AI review requested due to automatic review settings August 11, 2026 22:39
CA1859: CreateOwnershipMetadata returned IDictionary<string, string> while
always constructing a Dictionary. The method is private static with two call
sites, both assigning to BlobOpenWriteOptions.Metadata (IDictionary<string,
string>), so returning the concrete type is implicitly compatible and lets the
JIT devirtualize the indexer.

SA1117: split the ArgumentOutOfRangeException arguments one per line.

CA1873 in BlobPurgeJobOrchestrator is left deliberately: unguarded logging is
the established convention here (77 occurrences solution-wide), so guarding one
new file would make it inconsistent with its neighbours.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:35

  • BlobPurgeJob.Create stores purgeBatchSize verbatim into entity state. If a client ever calls Create with 0/negative (or an excessively large value), the orchestrator will pass it to GetLargePayloadTombstonesActivity/GrpcDurableTaskClient.GetLargePayloadTombstonesAsync, which throws for limit <= 0 || limit > 1000 (GrpcDurableTaskClient.cs:631-635). That would put the job into an endless backoff loop. Consider validating/clamping purgeBatchSize here to keep the entity from entering an unrecoverable configuration state.
        this.State.Status = BlobPurgeJobStatus.Active;
        this.State.PurgeBatchSize = purgeBatchSize;
        this.State.CreatedAt ??= DateTimeOffset.UtcNow;
        this.State.LastModifiedAt = DateTimeOffset.UtcNow;

src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:82

  • BlobPurgeJobStarter gates auto-purge on store is BlobPayloadStore, but PayloadStore.DeleteAsync was introduced as a virtual extensibility point. This type-check prevents any custom PayloadStore that overrides DeleteAsync (and can safely delete its own payload objects) from enabling auto-purge, even though the API suggests it should be supported. Consider gating on whether DeleteAsync is actually 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/Client/BlobPurgeJobStarter.cs:109

  • StopAsync waits for either the ensure task or the host shutdown token, but it never observes ensureTask's completion result. If the background task faults, the exception can go unobserved (raising UnobservedTaskException later) and makes debugging harder. Consider awaiting the task when it completes (swallowing exceptions as intended) to ensure faults are observed.
        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);

src/Grpc/orchestrator_service.proto:795

  • The PR description documents new sidecar RPC names GetTombstonedPayloads / AckPurgedPayloads, but the vendored proto defines GetLargePayloadTombstones / ReportLargePayloadPurgeResults (and the client APIs follow those names). Please update the PR description (or rename the RPCs) so the documented contract matches the code.
    rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);

Copilot AI review requested due to automatic review settings August 11, 2026 22:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.LastError is 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
Copilot AI review requested due to automatic review settings August 11, 2026 23:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • PurgeBatchSize can 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
Copilot AI review requested due to automatic review settings August 11, 2026 23:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copilot AI review requested due to automatic review settings August 11, 2026 23:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants