Skip to content

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

Open
wangbill (YunchuWang) wants to merge 58 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 58 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.Tests94 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 ReportLargePayloadPurgeResultsActivity 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:88 (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 38 out of 38 changed files in this pull request and generated 1 comment.

Comment thread src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs Outdated
…urge paths

Close the remaining auto-purge test gaps with tests only; no product code changes.

- Named client and worker builders: resolve LargePayloadStorageOptions, the
  entity-support flip, the intercepted gRPC options, the starter/store and the
  purge activities under a non-empty builder name, plus a negative test proving
  configuration does not leak to other names or the default name.
- Combined client + worker host: exactly one shared PayloadStore and one
  client-registered BlobPurgeJobStarter (not one per builder), with the purge
  activity still constructible.
- Empty fetch: an Active job whose fetch returns nothing idles on the timer
  without invoking the delete or report activities.
- Continue-as-new bound: the perpetual orchestrator runs exactly
  ContinueAsNewFrequency cycles before a single ContinueAsNew that resets the
  cycle count and carries the job identity and batch size forward.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:268

  • The exception message here says the payload lives in a different storage account, but this branch is taken whenever the token’s container URI doesn’t exactly match the configured container URI (which can also be the same account with a different container). This can mislead operators when diagnosing why deletes are being rejected.
            throw new PayloadStorageException(
                $"The externalized payload lives in a different storage account ('{decoded.ContainerUri}') than the " +
                $"currently-configured payload store ('{this.containerClient.Uri}'). Cross-account payload deletes " +
                "require identity (AAD) authentication with access to both accounts; connection-string / " +
                "account-key credentials are account-specific and cannot delete in another account.");

wangbill (YunchuWang) and others added 2 commits August 17, 2026 15:14
Blob soft delete and blob versioning are storage-account-level settings (Blob
service properties), not container-level. Correct the three doc/comment sites
added for the auto-purge storage-semantics note so customers reading the public
XML docs are not misinformed:

- LargePayloadStorageOptions.AutoPurge <remarks>
- PayloadDeleteOutcome.Deleted <summary>
- BlobPayloadStore delete comment above DeleteIfExistsAsync

The correction strengthens the existing argument: because these are account-level
policies, a single container cannot opt out, so retained versions / soft-deleted
bytes are even further outside the SDK's control. Also clarify that reclamation is
governed by an account lifecycle-management policy whose rule can be scoped to the
payload container's blob prefix.

Comments / XML docs only; no code, signature, or behavior change. IncludeSnapshots
is unchanged and version enumeration remains deliberately not implemented.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34
The virtual PayloadStore.DeleteAsync <remarks> already binds third-party stores to
idempotency and ownership (NotStoreOwned), but its <returns> says only "whether the
object was deleted" - silent on what a successful delete actually guarantees, which
is exactly the ambiguity flagged in review.

Add product-neutral contract prose to the <remarks>: a Deleted outcome means the
store no longer references the object and the underlying storage accepted the
delete, NOT that the bytes were reclaimed; storage-level retention features such as
versioning, soft delete, or retention policies may keep the content for a
policy-defined period, and implementations are not expected to defeat them.

Store-agnostic by design - PayloadStore is a public extensibility point - with no
Azure/blob/container specifics, mirroring the product-neutral NotStoreOwned naming.
The Azure-specific reclamation semantics remain documented on the concrete
BlobPayloadStore path (inline comment, PayloadDeleteOutcome.Deleted, AutoPurge
remarks), so this adds the missing base-contract sentence without duplicating them.

Docs only; no code, signature, or behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

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

  • StopAsync waits for the background reconciliation task to finish but never observes its result. If the background task faults (e.g., unexpected exception escapes the loop), the exception remains unobserved and can surface later via TaskScheduler.UnobservedTaskException. It’s safer to observe/ignore the completion explicitly once the task has finished.
    public async Task StopAsync(CancellationToken cancellationToken)
    {
        this.cts?.Cancel();

        Task? pending = this.backgroundTask;
        if (pending is not null)
        {
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
        }

src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:268

  • This exception message claims the token points to a “different storage account”, but the comparison is against the full container URI (scheme/host/port/path). A token in the same account but a different container would also hit this branch, making the message/action guidance misleading. Consider wording this as “different storage account or container”, or splitting account-vs-container mismatch if you want to keep the stronger wording.
            throw new PayloadStorageException(
                $"The externalized payload lives in a different storage account ('{decoded.ContainerUri}') than the " +
                $"currently-configured payload store ('{this.containerClient.Uri}'). Cross-account payload deletes " +
                "require identity (AAD) authentication with access to both accounts; connection-string / " +
                "account-key credentials are account-specific and cannot delete in another account.");

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 38 out of 38 changed files in this pull request and generated 3 comments.

Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs

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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Extensions/AzureBlobPayloads/AutoPurge/Orchestrations/ExecuteBlobPurgeJobOperationOrchestrator.cs:22

  • RunAsync calls CallEntityAsync(...) and returns it as object. With the default JsonDataConverter, deserializing JSON into typeof(object) produces JsonElement/primitive wrappers rather than the entity operation's actual return type, so this orchestrator does not reliably “return the result” as its XML doc claims. Since the only current caller (BlobPurgeJobStarter) uses this orchestrator for side-effects only, prefer the non-generic CallEntityAsync overload and return null to avoid type ambiguity and unnecessary deserialization.
        public override async Task<object> RunAsync(
            TaskOrchestrationContext context, BlobPurgeJobOperationRequest input)
        {
            return await context.Entities.CallEntityAsync<object>(input.EntityId, input.OperationName, input.Input);
        }
    

…yloads

Externalized payloads are configured per host, not per named builder: the
PayloadStore and the purge TaskHubSidecarServiceClient are registered as
container-wide singletons, so the first builder that calls
UseExternalizedPayloads supplies the configuration the whole host shares.
Configuring multiple named workers/clients in one host with different storage
accounts or different backends is therefore not supported - later builders
silently reuse the first registration.

Adds class-level <remarks> to both the worker and client
DurableTask*BuilderExtensionsAzureBlobPayloads classes stating the constraint,
and extends the inline comment above the worker's sidecar-client
TryAddSingleton to explain why a keyed/named registration cannot work here:
the consuming purge activities are constructed from the plain IServiceProvider
with no worker name in scope, so a keyed client would have no resolvable
consumer.

Comments and XML docs only - no behavior, options, or DI registration change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs:129

  • RegisterBlobPurgeJobStarter adds a new IHostedService every time UseExternalizedPayloads() is called. Because PayloadStore is registered via TryAddSingleton (first builder wins), multiple named client builders in the same host can end up with multiple BlobPurgeJobStarter instances that each read different named options (and may try to start vs stop the same singleton job), while all sharing the same PayloadStore. This contradicts the per-host singleton behavior described in the remarks and can lead to conflicting reconciliation behavior.
    static void RegisterBlobPurgeJobStarter(IDurableTaskClientBuilder builder)
    {
        string builderName = builder.Name;
        builder.Services.AddSingleton<IHostedService>(sp => new BlobPurgeJobStarter(
            sp.GetRequiredService<IDurableTaskClientProvider>(),
            sp.GetRequiredService<PayloadStore>(),
            sp.GetRequiredService<IOptionsMonitor<LargePayloadStorageOptions>>(),
            builderName,
            sp.GetRequiredService<ILogger<BlobPurgeJobStarter>>()));
    }

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

  • StopAsync claims it will "swallow any faulted/cancelled result", but it never observes backgroundTask exceptions (it only awaits Task.WhenAny). If the background task faults before/while StopAsync runs, the exception can remain unobserved and surface later as an UnobservedTaskException. Consider awaiting the task when it completes (and catching) so faults are actually observed.
    public async Task StopAsync(CancellationToken cancellationToken)
    {
        this.cts?.Cancel();

        Task? pending = this.backgroundTask;
        if (pending is not null)
        {
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
        }

…ent-facing purge APIs

F1: DeleteExternalBlobActivity now quarantines a blob that carries no ownership
marker (PayloadDeleteOutcome.NotStoreOwned) instead of reporting it Deleted. The
ownership marker is new in this PR while main has been emitting v2 tokens without
writing it, so on upgrade every pre-existing v2 blob reaches this path; reporting
Deleted would make the backend hard-delete the tombstone row, orphaning the blob
permanently and destroying the only durable record of it. Quarantine preserves the
row and its token backend-side as evidence, matching the v1 and malformed-token
branches. Removes the now-unused BlobPurgeBlobNotStoreOwned log (EventId 821).

F2: BlobPurgeJobStarter.SignalJobStopAsync now reconciles on a fixed interval like
the enabled path instead of signalling Stop once and returning. AutoPurge is
per-host config, so during a rolling deployment a replica still on the enabled
path re-creates the job every interval; a disabled replica that stopped it once
would let it come straight back and the fleet would never converge. The loop is
governed by the same cancellationToken as the enabled path, so shutdown ends it,
and each pass reads state first so a job that is already stopped is a no-op read.

F3: removes the client-facing GetLargePayloadTombstonesAsync and
ReportLargePayloadPurgeResultsAsync from DurableTaskClient and their
GrpcDurableTaskClient overrides. The purge activities inject the protobuf
TaskHubSidecarServiceClient directly and carry their own enum mapping, so these
virtuals were a duplicate of the product path, not the product path. The
LargePayloadTombstone, LargePayloadPurgeResult, and LargePayloadPurgeDisposition
contract types are kept. Deletes the duplicate LargePayloadPurgeUnimplementedTests
(the same Unimplemented to NotImplementedException behavior is covered on the
actual activity path by PurgeActivityUnimplementedTests) and repoints the
enum-parity test at ReportLargePayloadPurgeResultsActivity.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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/Client/BlobPurgeJobStarter.cs:167

  • StopAsync awaits Task.WhenAny(...) but never observes exceptions from the background task. If the reconcile loop faults, this can surface as an unobserved task exception (process-level event / potential crash depending on runtime settings). Swallowing faults is fine here, but the exception should still be observed when the task completes.
        if (pending is not null)
        {
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);

src/Extensions/AzureBlobPayloads/AutoPurge/Activities/DeleteExternalBlobActivity.cs:182

  • PayloadDeleteOutcome.NotStoreOwned is mapped to LargePayloadPurgeDisposition.Quarantined, but the contract semantics (proto comment for LARGE_PAYLOAD_PURGE_DISPOSITION_DELETED and managed LargePayloadPurgeDisposition.Deleted docs) treat "blob not store-owned" as a terminal success that should be reported as Deleted (tombstone resolved). Quarantining this case will unnecessarily move rows into the operator-only path and diverges from the documented wire meaning. (Any tests asserting Quarantined for NotStoreOwned should be updated accordingly.)
            if (outcome == PayloadDeleteOutcome.NotStoreOwned)
            {
                this.logger.BlobPurgeDeleteQuarantined("NotStoreOwned", null);
                return new BlobPurgeOutcome(LargePayloadPurgeDisposition.Quarantined);
            }

…loop

RunDisabledStarterAsync's shutdown comment justified determinism by the background
task running to completion on its own. After the disable path was made to reconcile
on an interval, the loop no longer self-completes: it parks in the reconcile delay
and StopAsync's cancellation is what ends it, after pass one's signal decision.
Reword the comment to match; no behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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 1 comment.

The F1 change (a814ffc) made an unowned/unmarked blob report Quarantined
instead of Deleted, but several comments and XML docs still described the
old Deleted-covers-unowned behavior. Doc/comment only - no product logic
or test-logic change.

- src/Grpc/orchestrator_service.proto: DELETED now covers two cases (blob
  deleted, blob already absent), not three; the "left in place because
  unowned" case is QUARANTINED now. Language-neutral wire semantics only;
  the same hunk mirrors into microsoft/durabletask-protobuf #76.
- src/Client/Core/LargePayloadPurgeDisposition.cs: same two-case fix on the
  C# mirror of that enum ("all three cases" -> "both cases").
- src/Extensions/AzureBlobPayloads/PayloadStore/PayloadDeleteOutcome.cs: the
  NotStoreOwned summary no longer claims the reference "is still resolved";
  it states the store-layer fact plus a neutral note that the outcome is not
  proof of deletion and the caller decides how to dispose of the reference.
  No disposition named - that mapping is the activity's decision.
- src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs: same
  correction at the HasOwnershipMarker return site.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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 (3)

src/Extensions/AzureBlobPayloads/AutoPurge/Activities/DeleteExternalBlobActivity.cs:182

  • This maps PayloadDeleteOutcome.NotStoreOwned (missing ownership marker) to Quarantined. However the PR description states that when the marker is absent the blob should be left untouched and the row should still be reported Deleted so the tombstone is resolved (i.e., “must not be quarantined”). Either the implementation or the PR description needs to be updated, because this changes backend behavior (quarantine vs resolve) and alerting semantics.
            // The blob exists but this store never wrote it - it carries no ownership marker - so it is
            // quarantined, not reported as deleted. The marker is new in this PR, but main has been emitting v2
            // tokens without writing it, so on upgrade every pre-existing v2 blob reaches this branch. Reporting
            // Deleted here would tell the backend to hard-delete the tombstone row, orphaning that blob
            // permanently AND destroying the only durable record that it exists. Quarantine keeps the row and its
            // token backend-side as evidence and stops the backend polling it - the same disposition, and for the
            // same reason, as the v1 and malformed-token branches: a delete that cannot be verified as this
            // store's own must not be discarded as a success.
            if (outcome == PayloadDeleteOutcome.NotStoreOwned)
            {
                this.logger.BlobPurgeDeleteQuarantined("NotStoreOwned", null);
                return new BlobPurgeOutcome(LargePayloadPurgeDisposition.Quarantined);
            }

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

  • StopAsync uses Task.WhenAny without ever awaiting/observing the background task when it completes. If the background loop faults, the exception can go unobserved (and may surface via UnobservedTaskException) even though the comment says faulted results are swallowed. Consider explicitly awaiting the task when it wins the WhenAny (inside a try/catch) so faults are observed.
    public async Task StopAsync(CancellationToken cancellationToken)
    {
        this.cts?.Cancel();

        Task? pending = this.backgroundTask;
        if (pending is not null)
        {
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
        }
    }

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

  • Dispose unconditionally disposes the CancellationTokenSource even if StopAsync returned early due to the host shutdown token, which can still leave the background task running and potentially fault it with ObjectDisposedException (the same risk called out in the remarks for StopAsync). Consider only disposing the CTS once the background task has completed (or avoid disposing it entirely in the timeout case).
    /// Deliberately not disposed in <see cref="StopAsync"/>: that method stops waiting as soon as the host's
    /// shutdown token fires, so the background task may still hold this source's token. Disposing it there would
    /// fault that still-running task with an <see cref="ObjectDisposedException"/> when it next registers a
    /// callback. The container disposes singletons after every <see cref="StopAsync"/> has returned, which is
    /// the safe point.
    /// </remarks>
    public void Dispose()
    {
        this.cts?.Dispose();
    }

The user overruled F1. The backend treats a non-empty quarantine set as a
poison/investigate signal: it emits an OTel-flagged
LargePayloadPurgeQuarantineOutstanding on every GetLargePayloadTombstones
poll (once per IdleDelay = 1 minute, forever), quarantine never auto-expires,
and there is no operator tool to clear it. Because the managed_by=dts
ownership marker is new in this PR, every pre-existing v2 blob would hit
NotStoreOwned on upgrade and permanently wedge that alarm. So an
unowned/unmarked blob reports Deleted (terminal success) again, as before F1.

Reverts F1 only. F2 (the disable path keeps reconciling) and F3 (the two
client-facing purge APIs stay removed) are unchanged.

- DeleteExternalBlobActivity.cs, Logs.cs, DeleteExternalBlobActivityTests.cs:
  restored byte-identical to their pre-F1 (0034bb0) state. NotStoreOwned
  logs BlobPurgeBlobNotStoreOwned (EventId 821 restored in its 820-822 slot)
  and falls through to Deleted; the test asserts Deleted again.
- orchestrator_service.proto, LargePayloadPurgeDisposition.cs,
  PayloadDeleteOutcome.cs, BlobPayloadStore.cs: the ce3b547 doc sweep only
  existed to describe the quarantine semantics, so it is reverted too -
  restored byte-identical to 9be9931, back to the three-case DELETED wording
  that matches Deleted semantics and the untouched protobuf #76 copy.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6373d1ba-104b-492a-9737-456a26c8ea34

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/Client/BlobPurgeJobStarter.cs:167

  • StopAsync waits for the background task to complete but never observes its final status. If the background task faults, the exception can remain unobserved (potentially surfacing later via UnobservedTaskException). If the task completed before the shutdown token fires, explicitly await it in a try/catch to observe and swallow the exception as intended.
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
        }

src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs:128

  • RegisterBlobPurgeJobStarter uses AddSingleton, so calling UseExternalizedPayloads on multiple client builders will register multiple BlobPurgeJobStarter instances. That can start duplicate reconciliation loops and contradicts the per-host singleton behavior described in the remarks. Use a TryAdd-style registration (e.g., TryAddEnumerable) so only one starter is registered per container.
        builder.Services.AddSingleton<IHostedService>(sp => new BlobPurgeJobStarter(

The purge contract has moved off the general-purpose TaskHubSidecarService and
onto a DTS-only LargePayloadPurge service, and tombstone row identity is now a
single backend-issued opaque token instead of (partitionId, instanceKey,
payloadId, revision).

Proto:
  - Vendor protos/durable-task-scheduler/large_payload_purge.proto and restore
    orchestrator_service.proto, both from durabletask-protobuf commit
    b12ce77986ce891cc53297c7bb68b3c8d5128734. Both staged blobs hash identically
    to that commit's blobs (f9b7b43 and 3d9194a), so no purge RPC, message, enum
    or GetWorkItems field remains in orchestrator_service.proto.
  - Add the new file to refresh-protos.ps1. Grpc.csproj already globs **/*.proto.

SDK:
  - LargePayloadTombstone is (TombstoneToken, PayloadToken); LargePayloadPurgeResult
    is (TombstoneToken, Disposition). The tombstone token is opaque: the SDK never
    parses it and echoes it back unchanged.
  - Both purge activities now use LargePayloadPurgeClient, registered on the
    worker's own CallInvoker so the RPCs ride the existing channel and
    interceptors rather than opening a second connection.
  - The auto-purge opt-in is sent with SetLargePayloadAutoPurge once per worker
    connection, after Hello and before GetWorkItems, replacing the removed
    GetWorkItemsRequest field. A worker with no opinion sends nothing at all.
    Every failure of that RPC is swallowed and logged (EventIds 79/80/81):
    it is an optional cleanup setting on a service only DTS implements, and
    letting it escape ConnectAsync would drive the reconnect and channel-recreate
    counters, turning a missing service into an orchestration execution outage.
    Cancellation is deliberately still propagated so shutdown is not swallowed.

Tests:
  - New: the orchestrator sends PayloadToken to storage and echoes TombstoneToken
    to the backend (both are strings, so a swap would otherwise compile silently).
  - New: SetLargePayloadAutoPurge is sent with the configured value and ordered
    before GetWorkItems; is not sent at all when the worker has no opinion; and
    neither Unimplemented nor a transient failure stops the work-item stream,
    while cancellation still does.

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 43 out of 43 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/Grpc/durable-task-scheduler/large_payload_purge.proto:46

  • The PR description says GetLargePayloadTombstones/ReportLargePayloadPurgeResults are unary RPCs on TaskHubSidecarService and that opt-in is carried via a google.protobuf.BoolValue field on GetWorkItemsRequest. In the code, these RPCs (plus opt-in) are instead defined on a separate LargePayloadPurge service (SetLargePayloadAutoPurge, GetLargePayloadTombstones, ReportLargePayloadPurgeResults).

Please update the PR description (or the contract) so reviewers and downstream implementers aren’t working from an incorrect service/opt-in shape.

service LargePayloadPurge {
  // Records whether the caller's task hub has opted into large-payload blob auto-purge.
  //
  // This is a setting, not a WorkerCapability: a worker being able to resolve externalized payloads
  // is not the same as the customer opting into deleting them, and a capability list is presence-only
  // so it cannot carry an explicit false.
  //
  // Send this once per worker connection, before requesting work items. The backend only writes
  // tombstones for a task hub that has opted in, and a worker about to start draining them should not
  // observe a stale setting. Not calling it at all leaves the stored value untouched, which is what
  // keeps a worker that does not know about auto-purge from silently disabling a task hub that opted
  // in.
  rpc SetLargePayloadAutoPurge(SetLargePayloadAutoPurgeRequest) returns (SetLargePayloadAutoPurgeResponse);

  // 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 and
  // branches solely on `disposition`: it deletes rows reported as DELETED, reschedules RETRY on
  // its own backoff, and moves QUARANTINED rows out of the active fetch while preserving their
  // evidence. The worker never computes a retry delay.
  rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);
}

Comment on lines +390 to +395
try
{
await this.purgeClient.SetLargePayloadAutoPurgeAsync(
new LP.SetLargePayloadAutoPurgeRequest { Enabled = enabled },
cancellationToken: cancellation);
this.Logger.LargePayloadAutoPurgeSet(enabled);
wangbill (YunchuWang) and others added 4 commits August 27, 2026 12:45
Resolves the conflict between this branch and #797, which changed how the
AzureBlobPayloads extension attaches its sidecar interceptor.

Both conflicts were confined to using blocks; each was resolved by need rather
than by taking a side:

  - DurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs
      keep    Grpc.Core                            (CallInvoker, purge client registration)
      keep    Microsoft.DurableTask.AzureBlobPayloads (BlobPurgeJob, orchestrators, activities)
      drop    Grpc.Core.Interceptors               (#797 removed the .Intercept() calls;
                                                    Interceptors.Add does not name the type)
      drop    Microsoft.DurableTask.Converters     (unused on both sides)

  - DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs
      keep    Microsoft.DurableTask.AzureBlobPayloads (BlobPurgeJobStarter)
      drop    Grpc.Core.Interceptors               (same reason)

#797's design is preserved as-is: the PostConfigure registers the interceptor
through opt.Interceptors.Add and no longer nulls Channel or replaces a
configured CallInvoker, so Address-only configuration and gRPC channel
recreation both keep working. The worker's immutable interceptor snapshot is
applied to the initial invoker and to every recreated one.

This branch's feature set is preserved whole: the vendored protos are unchanged
(blobs f9b7b43 and 3d9194a), and the Processor still builds both the
TaskHubSidecarServiceClient and the LargePayloadPurgeClient from the same
post-interceptor CallInvoker, so the purge RPCs continue to ride the worker's
own transport and interceptor chain.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
While the backend believes large-payload auto-purge is off it hard-deletes
payload metadata without writing a tombstone, so the blob reference is lost
permanently. That makes the persisted setting a precondition for consuming
work, not a best-effort announcement: the worker must not open GetWorkItems
until it knows the backend has recorded the opinion it is about to rely on.

SetLargePayloadAutoPurge previously swallowed every RpcException so that a
failure could not affect orchestration execution. Narrow that to the single
case where absorbing is actually safe - StatusCode.Unimplemented, meaning a
backend that has no purge service and therefore is not tombstoning at all.
Every other failure now propagates out of ConnectAsync into the existing
connection retry/backoff/recreation path, so the stream stays closed until a
later attempt confirms the setting.

Bound the call so a half-open channel cannot park a worker forever. Rather
than adding a second timeout knob, reuse the existing connection deadline
interval and hand each pre-stream unary RPC a fresh absolute deadline of its
own; a single shared absolute deadline would let a slow Hello starve the Set
that follows it. The deadline interval is no longer Hello-specific, so widen
its option documentation, the DeadlineExceeded comment, and EventId 70's
message from "Hello handshake" to connection setup.

Remove EventId 81 (LargePayloadAutoPurgeSetFailed). Failures are no longer
absorbed here, so they are already reported by ExecuteAsync's own handlers
and a dedicated log would double-report the same fault. The id was introduced
by this unmerged branch and never shipped, so nothing reclaims a used number.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
The purge activities resolved a LargePayloadPurge client from a CallInvoker
captured once at DI time as `options.CallInvoker ?? options.Channel.CreateCallInvoker()`.
After the interceptor redesign that capture is wrong three ways: it is a raw
invoker that bypasses every configured interceptor including auth, it throws for
an Address-only worker where both properties are null, and it stays bound to the
old channel after the worker recreates its transport, so purge calls keep
targeting a disposed channel while orchestration traffic has already moved.

The worker now publishes its current effective post-interceptor CallInvoker
through a new internal hook, at initial connect and after every successful
channel recreation. AzureBlobPayloads registers a RebindableCallInvoker as the
subscriber and builds the purge client on top of it, so an already-constructed
client routes later calls through the replacement. In-flight calls finish on the
invoker they captured, which matches the existing deferred channel disposal.

This keeps a single worker-owned transport: no second channel, no DurableTaskClient
registered merely for purge.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
The LargePayloadPurgeResult doc claimed a result carrying a token the caller
"invented or altered" resolves nothing. That is not true of the actual token: it
is unsigned versioned Base64, so a structurally valid fabricated token for the
same task hub can resolve if the keys are known or guessed. Stating otherwise
invites callers to treat a well-formed token as proof of entitlement. The doc now
says what is true - the token is opaque encapsulation, not an authentication
credential and not an integrity guarantee, and authentication plus task-hub scope
are the security boundary - while keeping the real requirement that callers must
not parse, derive, or construct one and must echo the backend value unchanged.

Two docs also outlived the gating change that made the auto-purge announcement a
connection-setup RPC:

- SetHelloDeadline still described the deadline as applying to "the initial Hello
  RPC", though the announcement now carries the same deadline.
- ChannelRecreateFailureThreshold still enumerated connect failures as "Hello
  timeouts", though an announcement failure now counts toward the threshold too.

Documentation only. No proto, behaviour, or API-shape change.

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 47 out of 47 changed files in this pull request and generated 2 comments.

Comment on lines 326 to 330
await this.client!.HelloAsync(
EmptyMessage,
deadline: this.NextConnectionSetupDeadline(),
cancellationToken: cancellation);
this.Logger.EstablishedWorkItemConnection();
Comment on lines +423 to +426
await this.purgeClient.SetLargePayloadAutoPurgeAsync(
new LP.SetLargePayloadAutoPurgeRequest { Enabled = enabled },
deadline: this.NextConnectionSetupDeadline(),
cancellationToken: cancellation);
Comment on lines +457 to +461
HttpResponseMessage response = new(HttpStatusCode.OK)
{
Version = new Version(2, 0),
Content = new ByteArrayContent(Array.Empty<byte>()),
};
Comment on lines +259 to +261
catch (RpcException)
{
}
ConnectAsync logged EventId 4 ("Sidecar work-item streaming connection
established.") immediately after Hello, before the large-payload auto-purge
Set gate and before GetWorkItems. When the gate correctly stops intake, every
retry of the connect attempt claimed a streaming connection that was never
opened.

Move the log to the end of ConnectAsync: capture the GetWorkItems result in a
local, then log, then return it. A throwing announcement or a synchronous
GetWorkItems failure now leaves no established-connection log behind. The
EventId and message text are unchanged and no new event is introduced.

Tests cover the ordering against the one call that matters, snapshotting the
event count from inside the GetWorkItems callback, plus the failed, timed-out
and cancelled announcement paths, and the Unimplemented degrade path as a
positive control. The two existing retry tests are additionally asserted for
the event count so the original symptom is pinned directly.

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 47 out of 47 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs:70

  • The file remarks say externalized payloads are configured per-host and later named workers “silently share the first builder’s registration”, but this registration uses builder.Name to read LargePayloadStorageOptions in PostConfigure. If multiple named workers call UseExternalizedPayloads, later builders can apply different options (interceptor behavior + LargePayloadAutoPurgeEnabled) than the singleton PayloadStore was constructed with, leading to inconsistent runtime behavior that contradicts the documented constraint.

Consider capturing the first builder name used for the shared PayloadStore in a shared singleton and using that single name consistently for all option lookups in this extension (or explicitly throw when a second distinct builder name attempts to configure externalized payloads).

        // Reuse the shared payload store when one is already registered (e.g. via AddExternalizedPayloadStore or
        // the client builder in the same process); only register our own as a fallback so we never create a
        // second, redundant PayloadStore.
        builder.Services.TryAddSingleton<PayloadStore>(sp =>
        {
            LargePayloadStorageOptions opts = sp.GetRequiredService<IOptionsMonitor<LargePayloadStorageOptions>>().Get(builder.Name);
            return new BlobPayloadStore(opts);
        });

src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs:115

  • RegisterBlobPurgeJobStarter unconditionally adds a new IHostedService each time UseExternalizedPayloads is called. In a host with multiple named DurableTask clients, this can register multiple BlobPurgeJobStarter instances even though the type-level remarks say externalized payloads are configured per-host and later builders share the first registration. Multiple starters will each run a reconciliation loop, causing redundant background work and potentially conflicting “enabled/disabled” decisions if different names are configured.

Consider ensuring only one BlobPurgeJobStarter is registered per process (e.g., gate on a shared singleton registration, or record the first builder name and ignore subsequent calls).

    static void RegisterBlobPurgeJobStarter(IDurableTaskClientBuilder builder)
    {
        string builderName = builder.Name;
        builder.Services.AddSingleton<IHostedService>(sp => new BlobPurgeJobStarter(
            sp.GetRequiredService<IDurableTaskClientProvider>(),
            sp.GetRequiredService<PayloadStore>(),
            sp.GetRequiredService<IOptionsMonitor<LargePayloadStorageOptions>>(),
            builderName,
            sp.GetRequiredService<ILogger<BlobPurgeJobStarter>>()));
    }

src/Grpc/durable-task-scheduler/large_payload_purge.proto:46

  • The PR description states the auto-purge opt-in is carried via a BoolValue field on GetWorkItemsRequest and that the purge RPCs are on TaskHubSidecarService. However, this change introduces a separate LargePayloadPurge service with a SetLargePayloadAutoPurge RPC (in addition to the fetch/report RPCs) and the worker calls that service during connection setup.

Please update the PR description to match the implemented gRPC contract/service shape so reviewers and downstream implementers don’t follow stale guidance.

service LargePayloadPurge {
  // Records whether the caller's task hub has opted into large-payload blob auto-purge.
  //
  // This is a setting, not a WorkerCapability: a worker being able to resolve externalized payloads
  // is not the same as the customer opting into deleting them, and a capability list is presence-only
  // so it cannot carry an explicit false.
  //
  // Send this once per worker connection, before requesting work items. The backend only writes
  // tombstones for a task hub that has opted in, and a worker about to start draining them should not
  // observe a stale setting. Not calling it at all leaves the stored value untouched, which is what
  // keeps a worker that does not know about auto-purge from silently disabling a task hub that opted
  // in.
  rpc SetLargePayloadAutoPurge(SetLargePayloadAutoPurgeRequest) returns (SetLargePayloadAutoPurgeResponse);

  // 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 and
  // branches solely on `disposition`: it deletes rows reported as DELETED, reschedules RETRY on
  // its own backoff, and moves QUARANTINED rows out of the active fetch while preserving their
  // evidence. The worker never computes a retry delay.
  rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);
}

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