Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side) - #758
Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side)#758wangbill (YunchuWang) wants to merge 37 commits into
Conversation
82ae04d to
0ac2dc3
Compare
0752610 to
cab0e9a
Compare
cab0e9a to
c05b15a
Compare
Large orchestration payloads are externalized to Azure Blob Storage as `blob:v1:<container>:<blobName>` tokens. The DTS backend stores those tokens but cannot delete the backing blobs (it has no storage credentials) — only this SDK can. This adds an opt-in, whole-scheduler singleton durable entity + orchestration job (mirroring src/ExportHistory) that drains payload rows the backend has soft-deleted and deletes their blobs, then acks so the backend can hard-delete the rows. Design: - PayloadStore.DeleteAsync is virtual (default throws NotSupportedException so it is non-breaking for existing external subclasses); BlobPayloadStore overrides it to decode the token and call DeleteIfExistsAsync (idempotent). - BlobPurgeJob (TaskEntity singleton): Create is a no-op when already Active so racing client processes don't disturb the running job; Run starts a fixed-id orchestrator. - BlobPurgeJobOrchestrator (perpetual): fetch a batch of tombstones, delete the blobs with capped parallelism, ack the successful deletions (failed tokens stay tombstoned to retry), idle on a timer when empty, ContinueAsNew periodically. - ExecuteBlobPurgeJobOperationOrchestrator bridges client -> entity. - Two new unary RPCs on TaskHubSidecarService: GetTombstonedPayloads / AckPurgedPayloads (authoritative proto follow-up: microsoft/durabletask-protobuf#76). - LargePayloadStorageOptions gains AutoPurge (opt-in, default false) and PayloadPurgeBatchSize (default 500). - Client-side BlobPurgeJobStarter (IHostedService) ensures the singleton job when AutoPurge is enabled, without blocking host startup. Worker always registers the entity/orchestrators/activities so a client-enabled job has something to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
c05b15a to
306d19f
Compare
…er simplification - Drop the `Dto` suffix now that the payload records are first-class public types in `Microsoft.DurableTask.Client` (`TombstonedPayload`, `PayloadPurgeAck`). - Collapse the magic `500` batch-size literal into a single `BlobPurgeConstants.DefaultBatchSize` used everywhere. - Rename `BlobPurgeJobStatus.Stopped` -> `Pending` (still the zero value) and remove the dead `Failed` member (nothing ever set it; the job self-heals). - Make the perpetual orchestrator self-heal: wrap each cycle in try/catch so a transient backend/entity/activity failure logs, backs off, and continues instead of failing the orchestration and killing the eternal loop. - Ack poison tokens: `DeleteExternalBlobActivity` now returns a three-way `BlobDeleteResult` (Deleted/Discarded/Retry). Malformed tokens are discarded and acked so the backend can clear the stuck row instead of re-streaming it forever; transient failures stay tombstoned to retry. - Replace the single-value `BlobPurgeJobCreationOptions` record with a plain `int` on `BlobPurgeJob.Create`. - Guard the client fetch RPC: `GetTombstonedPayloadsAsync` throws `ArgumentOutOfRangeException` unless `0 < limit < 1000`. - Simplify `BlobPurgeJobStarter` to a fixed-instance-id fire-once: drop the entity-active pre-check and schedule the Create bridge once with a fixed instance id, retrying only until the backend is reachable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/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;
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
…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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Extensions/AzureBlobPayloads/AutoPurge/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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/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)) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
rename to notdtsowned
| public override async Task<object?> RunAsync( | ||
| TaskActivityContext context, List<LargePayloadPurgeResult> input) | ||
| { | ||
| if (input is null || input.Count == 0) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Client/Grpc/GrpcDurableTaskClient.cs:701
ReportLargePayloadPurgeResultsAsyncwill happily serialize and sendLargePayloadPurgeDisposition.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,
});
}
Summary
Large orchestration payloads are externalized to Azure Blob Storage by the
AzureBlobPayloadsextension, with a token persisted in SQL instead of the payload bytes. When the orchestration is purged, DTS removes the SQL state but cannot delete the backing blob — it has no customer storage credentials. Only the worker does.This PR implements the worker/SDK side. The backend records a durable tombstone for each externalized payload whose orchestration state is gone; the worker fetches due tombstones, deletes the blobs, and reports the outcome of every row so the backend can resolve, reschedule, or quarantine it.
Companion changes: contract in microsoft/durabletask-protobuf#76, backend in AAPT-DTMB PR 16368738.
gRPC contract
Two unary RPCs on
TaskHubSidecarService(worker is the client). The vendoredsrc/Grpc/orchestrator_service.protois byte-identical to protobuf#76 — verified mechanically by exact-substring comparison, not by eye.LargePayloadTombstone { partitionId, instanceKey, payloadId, token, revision }LargePayloadPurgeResult { identity, revision, disposition }google.protobuf.BoolValue large_payload_auto_purge_enabled = 12on the existingGetWorkItemsRequest— no new handshake, and null means "no opinion".revisionis echoed back unmodified as a compare-and-swap guard, so duplicate or stale reports are no-ops without a per-row lease.Dispositions
Three dispositions, split on whether a failure can self-heal. There is no
Discarded: a success is reported explicitly asDeleted, and every failure is carried byRetryorQuarantined.DeletedRetryQuarantinedThe worker never computes a retry delay. It reports the failure and the backend owns scheduling and backoff. The orchestrator reports the whole batch unconditionally — including retryable rows — because the backend needs to hear about a failure in order to defer the row. If every row comes back
Retry(a storage outage), the cycle appliesErrorBackoffso an outage cannot become a tight refetch loop.dispositionis the entire outcome. An earlier revision also carriedreasonandstorageErrorCode; both were removed after verifying they were write-only end to end — the backend persists them and nothing reads either one. Failure detail is logged by the worker instead, at the classification site, which is strictly richer than the enum was: 401/403, 5xx, and an unreachable account are distinct in telemetry where the enum collapsed all three into one value.Blob ownership marker
A recognized token proves only that the text looks like one this store emits — the column is customer-writable. So ownership is recorded on the object itself:
UploadAsyncwrites fixed blob metadatamanaged_by=dts, and the worker re-reads the target's metadata immediately before deleting.If-Matchon the ETag from that same read, so a mid-flight overwrite fails the delete rather than destroying newer content.Deleted, so the tombstone is resolved rather than retried forever. This is an expected outcome, not a defect, and must not be quarantined. The worker logs it distinctly so a customer whose payloads are all self-authored is still visible in telemetry.The metadata name uses an underscore because Azure requires blob metadata names to be valid C# identifiers;
managed-bywould be rejected at upload.Only
blob:v2:tokens are auto-purged. Av1token reaching this path is an invariant violation (v1 is excluded at insertion) and is quarantined rather than deleted.Testing
Verified on a clean (
--no-incremental) build:dotnet build Microsoft.DurableTask.sln— 0 errorstest/Extensions/AzureBlobPayloads.Tests— 54 passedtest/Client/Grpc.Tests— 56 passedLargePayloadPurgeEnumParityTestspins proto↔managed parity forLargePayloadPurgeDispositionby value and name in both directions, and asserts that no inbound type exposes an enum.Dispositionis the one enum crossing the wire, and its numeric cast inGrpcDurableTaskClientis what decides whether a row is deleted or quarantined; that cast is safe only because enums travel outbound-only, and the test fails the build if a future change breaks that invariant.Notes / intentional deviations
CA1873atBlobPurgeJobOrchestrator.cs:66(unguarded logging). Kept deliberately for consistency with 77 existing instances across the solution. Blame-based attribution againstmainconfirms this is the only warning this branch introduces.PurgedCountcounts everyDeletedrow, including blobs skipped for lacking the ownership marker. Excluding them would pin the counter at 0 for a customer whose payloads are all self-authored, making a healthy draining job read as wedged; the worker log supplies the precision instead.PayloadStore.DeleteAsyncisvirtualwith a default that throwsNotSupportedException, so existing external subclasses are unaffected.BlobPurgeJob.Createis a no-op when alreadyActiveso racing client processes don't disturb a running job.BlobPurgeJobStarterimplementsIDisposablerather than disposing its CTS inStopAsync: that method returns on the host shutdown token while the ensure task may still be live, so disposing there would fault it with an unobservedObjectDisposedException.