Skip to content

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

Open
wangbill (YunchuWang) wants to merge 37 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 37 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 review requested due to automatic review settings August 12, 2026 23:13

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.

The disable path signalled Stop unconditionally. Entity state is persisted
after every operation, so that signal created the job entity - in its default,
never-started state - for every app that externalizes payloads without ever
enabling auto-purge, and persisted a fresh write on every host start for an app
whose job was already stopped.

The starter now reads the job through the client first and signals only when a
job exists and is Active. The read is a query against the instance store rather
than an entity operation, so it never dispatches to the entity and cannot
materialize one; that is the property that makes it different from the Get
operation the orchestrator calls.

Three things the pre-check must not break, and does not:

- The Stop guard in the entity stays. The pre-check is read-then-signal across
  two round trips with nothing holding the state still, so it is racy by
  construction; the guard is what makes losing that race harmless. Its comment
  now states the race as the justification rather than the steady state, which
  the pre-check has just made false.
- A read that fails falls through to signalling in the same iteration rather
  than being retried. Every 'entities are not supported' gate in this SDK lives
  on the DurableTaskClient.Entities property, not on individual methods, so a
  client that cannot answer the query cannot receive the signal either -
  retrying a permanent failure would spin forever and the running job would
  never be told to stop.
- EventId 824 now fires only when a stop is actually requested. New EventIds
  825 (no running job found) and 826 (state unreadable, stopping anyway) cover
  the paths where it would otherwise have asserted something that did not
  happen.

The status is reached through EntityMetadata.IncludesState rather than by
reading .State, which throws when the metadata carries none - reachable for an
entity whose state was cleared but which still reports as existing.

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 13, 2026 03:28

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

  • StopAsync waits for the background task to complete, but it never observes the task result. If EnsureJobAsync/SignalJobStopAsync faults, the exception can remain unobserved and surface later via UnobservedTaskException (or be lost), making failures harder to diagnose.

Consider awaiting the task when it completes first (and swallowing exceptions intentionally) so any fault is observed deterministically.

            // 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/Orchestrations/BlobPurgeJobOrchestrator.cs:87

  • cycleBatchSize can become 0 when both the stored batch size and the orchestrator input are non-positive (e.g., an older/cleared entity state, or an external call that created the job with 0). In that case GetLargePayloadTombstonesActivity will end up calling DurableTaskClient.GetLargePayloadTombstonesAsync(0), which throws ArgumentOutOfRangeException and will put the orchestrator into an infinite "cycle failed" + backoff loop.

To keep the job self-healing, consider clamping the effective batch size to a safe default when both sources are unset.

                // Fall back to the input when the stored value is not positive. An entity written by an older
                // build carries no batch size at all, and asking the backend for zero rows every cycle would be
                // a silent, permanent stall.
                int cycleBatchSize = state.PurgeBatchSize > 0 ? state.PurgeBatchSize : batchSize;

// Assert
run.Signal.Should().NotBeNull("the disable path must tell a running job to stop");
run.Signal!.Value.Id.Should().Be(new EntityInstanceId(nameof(BlobPurgeJob), BlobPurgeConstants.JobId));
run.Signal.Value.Operation.Should().Be(nameof(BlobPurgeJob.Stop));
Create's already-active branch previously did not re-signal Run, on the
stated grounds that starting a second orchestrator over a live one is
destructive. That premise is false. An entity-initiated start carries no
reuse policy - neither TaskEntityShim.ScheduleNewOrchestration nor the
StartNewOrchestrationAction proto message has a field for one - so the
backend resolves it atomically: OkToPurge refuses to replace an instance
that is not IsCompleted, and the start message is discarded; a completed,
terminated, failed or canceled instance is purged and replaced. Healthy
jobs are therefore undisturbed and dead ones are recovered, with no
check-then-act on our side.

Create now re-signals Run after updating the batch size, which makes the
job recover from a dead orchestrator on the next host start.

Also corrects two comments and one log message that asserted the old,
false premise, including a longer restatement of it in BlobPurgeJobStarter
that described manual recovery as the only option.

Run had no test coverage; both its guarded and unguarded paths are now
pinned, since the guard is what makes a Stop win against an in-flight
Create.

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 13, 2026 04:10

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.

…bol names

LastModifiedAt was written by five operations, read by none, and two of
those writes fired on every host start - Create's already-active branch
and Run, the latter on every start since Run began being re-signalled.
The field had degenerated into "the most recent host start, or the most
recent non-empty purge, whichever is later", which also made Stop's
idempotence guard vacuous: it deliberately preserves the field, but the
next host start overwrote it anyway.

Run no longer writes it - it schedules an orchestrator and changes
nothing about the job - and Create's already-active branch writes it only
when the batch size actually differs. An entity from a build predating
the field carries zero, which differs from any configured size, so the
first Create after an upgrade still repairs it. The field now means what
its documentation says: the last time the job was started, stopped,
reconfigured, or recorded a non-zero purge. Entity state is persisted
after every operation regardless, so nothing about persistence changes.

Also removes the names of two private backend methods from a comment.
This repository is public and cannot reference them, so they were both a
leak and certain to rot; the observable behaviour they described is kept.
A sweep of the rest of the diff found no other instance outside the
proto, whose comments are copied verbatim from the canonical contract.

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 13, 2026 04:32

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

  • StopAsync waits with Task.WhenAny but never observes/awaits the background task when it completes, so if EnsureJobAsync/SignalJobStopAsync faults the exception can remain unobserved (risking UnobservedTaskException and making failures harder to diagnose). Consider awaiting the task (in a try/catch) when it wins the race, while still returning early when the host shutdown token fires.
        {
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
        }

The comment on LargePayloadPurgeResult.disposition told the reader to
correlate a row with worker telemetry by (partitionId, instanceKey,
payloadId) plus LastAttemptAt. The first three are fields on both
messages. The fourth is a column on a backend table that is never sent
to the worker - LargePayloadTombstone carries partitionId, instanceKey,
payloadId, token and revision, and nothing else - so the advice named a
value its audience can neither receive nor query, while also naming
backend internals in a public repository.

The remaining three identifiers are a complete correlation key for the
telemetry this comment is about.

The wire contract is unaffected: only a comment changed, and the
serialized file descriptor embedded in the generated code is unchanged.
The generated C# does move, because protoc copies proto comments into
XML documentation, and that one documentation line now matches.

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 13, 2026 04:51

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/Models/BlobPurgeJobState.cs:41

  • BlobPurgeJobState.LastError is documented as carrying the last error message, but nothing in the auto-purge implementation ever sets it (it’s only cleared in BlobPurgeJob.Create). Either wire this up (e.g., an entity operation the orchestrator calls in its catch-all) or adjust the documentation so consumers don’t assume it will be populated.
    /// <summary>
    /// Gets or sets the last error message, if any.
    /// </summary>
    public string? LastError { get; set; }

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

  • StopAsync waits for the background task to finish but never observes its result. If the background task faults, this can leave an unobserved exception (and it also makes it harder to diagnose failures). Consider awaiting the task when it completes, swallowing exceptions only after they’ve been observed.
        {
            // The background loop observes cancellation and returns promptly; swallow any faulted/cancelled
            // result.
            await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
        }

…st start

The ensure loop returned as soon as one schedule succeeded, so it was a
retry-until-success loop rather than a periodic one. That call is the
only thing that re-signals the job's Run, so an orchestrator that died
in the middle of a host's lifetime stayed down until the process was
restarted.

Every path now picks a delay and the loop waits once at its tail: the
two success paths take a five minute reconcile interval, and the failure
path keeps the short retry delay, because a backend that is unreachable
at host start has to be retried quickly - until one pass succeeds the
job may not exist at all.

Nothing else changes. A pass that finds everything healthy is already a
no-op at both hops it drives: the bridge's Create no-ops while the
entity is Active, and the Run it signals is discarded by the backend
while the orchestrator is alive.

Two logs on the repeating path drop from Information to Debug, since
they now fire once per host per interval forever. EventId 811 also said
the batch size "was updated", which stopped being true when that write
became conditional; it now says it was reconciled, which is true whether
or not it changed.

The comments that described recovery as bounded by host starts are
rewritten, in the starter, in the entity and in the tests. The interval
carries the cost it trades: this is per host, not per task hub, because
staggered hosts each find the previous bridge already Completed and
therefore replaceable, so a fleet of N pays roughly N bridge runs and N
entity calls per interval. What keeps the purge work itself single is
the fixed orchestrator instance id, not the bridge's dedupe policy.

Tests cover that the loop repeats, and that shutdown cancels a pass
parked in the interval rather than sitting it out - the latter being the
one real regression risk, and one the other tests could only show as a
hang. Both were confirmed to fail when the behaviour they name is
removed.

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 13, 2026 05:23

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

  • EnsureJobAsync captures batchSize once at StartAsync and then reuses it forever, so runtime configuration updates (IOptionsMonitor) won't ever reach the job entity. Since reconciliation runs for the lifetime of the process, re-read the current PayloadPurgeBatchSize each pass before scheduling the bridge orchestration.
            try
            {
                BlobPurgeJobOperationRequest request = new(
                    this.entityId, nameof(BlobPurgeJob.Create), batchSize);

src/Client/Grpc/GrpcDurableTaskClient.cs:675

  • ReportLargePayloadPurgeResultsAsync will happily send a result whose Disposition is Unspecified (0). Since the proto explicitly requires 0 to be rejected by the backend, validate client input and throw before sending to avoid backend-side failures (or accidental misreporting if a future backend changes behavior).
        foreach (LargePayloadPurgeResult result in results)
        {
            request.Results.Add(new P.LargePayloadPurgeResult
            {
                PartitionId = result.PartitionId,

// Classify the token's version prefix before consulting the store. The store reports every token it
// cannot decode as the same ArgumentException, but the three cases have opposite dispositions, so they
// are separated here, where the prefix is still visible.
if (token.StartsWith(BlobPayloadStore.TokenPrefixV1, StringComparison.Ordinal))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

v1 token should not even be in tomberstone, backend only put v2 tokens to tomberstone

// outcome, not a defect - the token text merely matched the v2 grammar - and quarantining it would
// fill the quarantine set with non-defects. The tombstone is still resolved, because a blob the
// store does not own is not the store's to delete.
if (outcome == PayloadDeleteOutcome.NotStoreOwned)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

rename to notdtsowned

public override async Task<object?> RunAsync(
TaskActivityContext context, List<LargePayloadPurgeResult> input)
{
if (input is null || input.Count == 0)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

move this check outside the activity to reduce activity call if not needed

…ction

Two comments in BlobPurgeJobStarter said the client is resolved "lazily"
so that a DurableTaskClient is not constructed at host start for apps that
externalize payloads without auto-purge. That is not what happens.
DefaultDurableTaskClientProvider takes IEnumerable<ClientContainer>, which
the DI container materializes eagerly, and each ClientContainer is built by
running the client builder. The provider is constructed before any hosted
service's StartAsync runs, so every client already exists by the time either
path calls GetClient - GetClient is only a FirstOrDefault lookup and
constructs nothing. Deferring that call defers no construction.

What is true, and is kept, is the real asymmetry between the two paths. The
enabled path resolves in StartAsync's body, so a builder name matching no
client throws ArgumentOutOfRangeException straight out of host startup;
placing it after the AutoPurge and store gates keeps apps that never enabled
auto-purge from reaching it. The disabled path resolves inside the stop
loop's try, where the same failure is caught, logged and retried rather than
faulting the background task. The "resolved yet" wording is dropped: the
client set is fixed when the provider is constructed, so a name that misses
once misses always.

Also documents why both background loops are launched with Task.Run and
CancellationToken.None: an async method runs synchronously until its first
suspending await, and these loops' awaits land on client implementations this
code does not own, so Task.Run makes "StartAsync returns immediately" a local
property; CancellationToken.None keeps a fast StopAsync from dropping the stop
signal before it is sent.

Comments only; no behavioural 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 36 out of 36 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Client/Grpc/GrpcDurableTaskClient.cs:701

  • ReportLargePayloadPurgeResultsAsync will happily serialize and send LargePayloadPurgeDisposition.Unspecified (0) (or any out-of-range enum value) via a raw numeric cast. The proto contract explicitly requires 0 to be rejected by the backend; validating here gives callers an immediate, clear exception and avoids a round-trip that can only fail server-side.
    public override async Task ReportLargePayloadPurgeResultsAsync(
        IEnumerable<LargePayloadPurgeResult> results, CancellationToken cancellation = default)
    {
        Check.NotNull(results);

        P.ReportLargePayloadPurgeResultsRequest request = new();
        foreach (LargePayloadPurgeResult result in results)
        {
            request.Results.Add(new P.LargePayloadPurgeResult
            {
                PartitionId = result.PartitionId,
                InstanceKey = result.InstanceKey,
                PayloadId = result.PayloadId,
                Revision = result.Revision,

                // The managed disposition enum declares the same numeric values as its protobuf counterpart,
                // so it maps across by value. This is the only enum on the message and it only travels
                // outbound, so the SDK can never receive a value it does not know.
                Disposition = (P.LargePayloadPurgeDisposition)result.Disposition,
            });
        }

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