Skip to content

Add large-payload tombstone fetch and purge-result RPCs for blob auto-purge - #76

Open
wangbill (YunchuWang) wants to merge 8 commits into
mainfrom
yunchuwang-add-purge-external-payloads-rpc
Open

Add large-payload tombstone fetch and purge-result RPCs for blob auto-purge#76
wangbill (YunchuWang) wants to merge 8 commits into
mainfrom
yunchuwang-add-purge-external-payloads-rpc

Conversation

@YunchuWang

@YunchuWang wangbill (YunchuWang) commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

Adds the authoritative proto contract for the large-payload blob auto-purge feature. This is the source-of-truth definition that the other PRs vendor locally and re-sync from once this merges:

  • SDK: microsoft/durabletask-dotnet #758
  • DTS backend: AAPT-DTMB #16368738

The definitions here are byte-identical to the canonical contract shared with durabletask-dotnet #758.

Context

When an orchestration payload exceeds a threshold it is externalized to Azure Blob Storage and a token is persisted in the backend's SQL Payloads.Text column instead of the payload bytes. Purging the orchestration removes the SQL state, but the backend cannot delete the blob because it does not hold the customer's storage credentials, so the blob is orphaned.

Fix: the backend keeps the durable cleanup work and hands it to a worker that does have storage credentials. The backend records a tombstone for each externalized payload leaving live state; the worker fetches a bounded batch of due tombstones, deletes each blob, and reports the outcome of every attempt. The backend then resolves, reschedules, or quarantines each row.

Change

Edits only protos/orchestrator_service.proto. Nothing is added to the backend-facing service: the backend serves both services on one endpoint and the worker already reaches TaskHubSidecarService there, so authorization stays identical to the existing worker stream.

Opt-in on the existing handshakegoogle.protobuf.BoolValue large_payload_auto_purge_enabled = 12 on the existing GetWorkItemsRequest. GetWorkItems is already task-hub scoped and authenticated, so the setting binds to that task hub without a new RPC. This is a setting, not a WorkerCapability: WORKER_CAPABILITY_LARGE_PAYLOADS means a worker can resolve externalized payloads, not that the customer opted into deleting them. A repeated capability enum is presence-only and could never express an explicit false, so it could not turn the feature off.

Two bounded unary RPCs, placed immediately after GetWorkItems:

RPC Purpose
GetLargePayloadTombstones Hand the worker a bounded, deterministically ordered batch of due tombstones
ReportLargePayloadPurgeResults Record the outcome of each attempt; the backend owns retry scheduling

Dispositions — exactly three: DELETED, RETRY, QUARANTINED. The split is by whether a failure can self-heal. There is deliberately no Discarded: it was success-shaped and destroyed evidence.

No reason or error-code fields - disposition is the only outcome field on LargePayloadPurgeResult. Earlier revisions of this PR also carried a LargePayloadPurgeReason enum and a storageErrorCode string; both were removed because they were write-only. The backend persists them, but no SELECT, WHERE, API, or alert reads either one, and every backend action branches on disposition alone. Failure detail already lives in the worker's telemetry at full exception fidelity, where a row is correlated by (partitionId, instanceKey, payloadId), so the fields were a lossy copy nobody queried. No reserved statements were added: these messages have never shipped in a release, so no deployed peer has ever seen field 6 or 7. disposition stays at field 5 rather than being renumbered to close the gap.

revision — carried on both the tombstone and the result as a compare-and-swap guard, so duplicate or stale reports are no-ops without a per-row lease.

Tokens — documented as blob:v2:{fullBlobUrl}. Legacy v1 tokens are never tombstoned: v1 carries a container name but not the storage account, so a delete cannot be verified against the configured account. The backend hard-deletes v1 payload rows instead, and a v1 token reaching the worker is an invariant violation that is quarantined rather than discarded.

Supersedes the earlier shape

This replaces the previous draft contract, which is fully removed rather than extended:

  • GetTombstonedPayloads / AckPurgedPayloads → renamed and reshaped. The old pair was a fetch plus a bare identity-only ack, which cannot distinguish success from an unfixable failure and left retry scheduling ambiguous.
  • Messages TombstonedPayload, PayloadPurgeAck, GetTombstonedPayloadsRequest, GetTombstonedPayloadsResponse, AckPurgedPayloadsRequest, AckPurgedPayloadsResponse → removed.
  • protos/backend_service.proto → reverted; it is now byte-identical to main.

Because none of the related PRs has merged, this is not a compatibility constraint.

Verification

  • git diff origin/main -- protos/backend_service.proto is empty.
  • Field 12 was previously unused in GetWorkItemsRequest (which used 1, 2, 3, 10, 11); no field number collides.
  • google/protobuf/wrappers.proto was already imported; no duplicate import added.
  • All three protos compile with protoc (no errors; the only warnings are pre-existing unused imports in backend_service.proto). Descriptor introspection confirms the field numbers, RPC wiring, and enum values, and that no removed symbol survives.

No language stubs were regenerated: this is a proto-only repo (SDKs consume the protos via git submodule and generate code at build time into gitignored build/ dirs; no generated stubs are checked in).

wangbill (YunchuWang) and others added 2 commits July 8, 2026 14:45
Adds the authoritative bidirectional streaming RPC and its two messages (TombstonedPayload, PayloadPurged) that the backend and worker use to purge externalized large-payload blobs. The backend soft-deletes blob-externalized payload rows and streams the tombstoned tokens to a connected worker (which has storage credentials); the worker deletes each blob and acks so the backend can hard-delete the row.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Pure rename of the client->server ack message; field numbers/types unchanged and wire-compatible. Reads correctly (the row is not yet purged when the worker sends it) and the ...Ack suffix signals the upstream direction in the bidi signature.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@YunchuWang
wangbill (YunchuWang) marked this pull request as ready for review July 8, 2026 23:28
The DTFx AzureManaged SDK dials only BackendService, so it needs to drain externalized large-payload blobs via that service. Mirrors the RPC already on TaskHubSidecarService; reuses TombstonedPayload and PayloadPurgeAck, which are defined in orchestrator_service.proto (already imported here).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@YunchuWang
wangbill (YunchuWang) marked this pull request as draft July 9, 2026 17:27
Replaces the bidirectional streaming PurgeExternalPayloads on both TaskHubSidecarService and BackendService with two unary RPCs: GetTombstonedPayloads(limit) to fetch tombstoned large-payload rows and AckPurgedPayloads(acks) to confirm blob deletion so the backend hard-deletes those rows. Adds the four request/response messages to orchestrator_service.proto; backend_service.proto reuses them via its existing import. TombstonedPayload and PayloadPurgeAck are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
wangbill (YunchuWang) added a commit to microsoft/durabletask-dotnet that referenced this pull request Jul 13, 2026
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>
wangbill (YunchuWang) added a commit to microsoft/durabletask-dotnet that referenced this pull request Jul 13, 2026
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>
wangbill (YunchuWang) added a commit to microsoft/durabletask-dotnet that referenced this pull request Jul 13, 2026
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) marked this pull request as ready for review July 15, 2026 00:05
Aligns the contract with the finalized large-payload blob auto-purge
design. The previous shape (a fetch RPC plus a bare identity-only ack)
is superseded and removed rather than extended.

protos/backend_service.proto is reverted to main. The RPCs live only on
TaskHubSidecarService in orchestrator_service.proto: the backend serves
both services on one endpoint and the worker already reaches
TaskHubSidecarService there, so authorization stays identical to the
existing worker stream and nothing is added to the backend-facing
service (design section 3).

orchestrator_service.proto:

- Replace GetTombstonedPayloads/AckPurgedPayloads with
  GetLargePayloadTombstones and ReportLargePayloadPurgeResults. An
  identity-only ack cannot distinguish success from an unfixable
  failure, and the backend, not the worker, owns retry scheduling.
- Add LargePayloadPurgeDisposition with exactly three dispositions:
  DELETED, RETRY, and QUARANTINED. There is deliberately no Discarded;
  it was success-shaped and destroyed evidence.
- Add LargePayloadPurgeReason as a stable, bounded reason code. It never
  carries a token or raw exception text, because tokens expose the
  storage account, container, and blob path (design section 7).
  BLOB_NOT_STORE_OWNED sits under DELETED rather than QUARANTINED: an
  unmarked blob is an expected outcome, a customer's own blob whose
  reference happened to match the token grammar, not a defect
  (design section 5.5).
- Carry revision on both the tombstone and the result as a
  compare-and-swap guard, so duplicate or stale reports are no-ops
  without a per-row lease (design section 5.3).
- Add google.protobuf.BoolValue large_payload_auto_purge_enabled = 12 to
  the existing GetWorkItemsRequest. This is a setting, not a
  WorkerCapability: a repeated capability enum is presence-only and
  cannot express an explicit false, so it could never turn the feature
  off (design section 3.1).
- Document the token as blob:v2:{fullBlobUrl}. Legacy v1 tokens are
  never tombstoned, because v1 carries a container name but not the
  storage account (design sections 5.4 and 8).

Definitions are byte-identical to the canonical contract shared with
durabletask-dotnet#758.

Verified: protos/backend_service.proto diffs empty against main; field
12 was previously unused in GetWorkItemsRequest (1, 2, 3, 10, 11);
google/protobuf/wrappers.proto was already imported and is not
re-imported; all three protos compile with protoc.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@YunchuWang wangbill (YunchuWang) changed the title Add PurgeExternalPayloads RPC for large-payload blob auto-purge Add large-payload tombstone fetch and purge-result RPCs for blob auto-purge Aug 11, 2026

// The outcome of a single blob deletion attempt. The split is by whether a failure can self-heal.
enum LargePayloadPurgeDisposition {
LARGE_PAYLOAD_PURGE_DISPOSITION_UNSPECIFIED = 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.

no need for this?

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.

Good catch, and you're right that this needed justifying rather than just existing.

It can happen, and that's exactly why it's here. proto3 has no field presence for scalars, so if a client never sets disposition, the field still arrives — as 0. There's no way for the backend to tell "client omitted this" from "client meant the value at 0".

That makes the value at 0 load-bearing. If 0 were DELETED, then a client that forgot to set the field, or an older/buggy SDK build, would report every row as a success. The backend would delete those tombstones, and since the tombstone is the only durable record of the blob, the blobs would be orphaned permanently with no way to find them again. Making 0 a value the backend rejects turns that failure into a loud no-op instead of silent data loss.

It's also not optional: proto3 requires the first enum value to be zero. WorkerCapability in this same file follows the identical convention (WORKER_CAPABILITY_UNSPECIFIED = 0), so this is consistent with what's already here.

I've added that reasoning as a comment on the value itself so the next reader doesn't have to ask.

Comment thread protos/orchestrator_service.proto Outdated
LARGE_PAYLOAD_PURGE_DISPOSITION_UNSPECIFIED = 0;

// Terminal success. The blob was deleted, was already absent, or was deliberately left in
// place because it is not owned by the payload store (design §5.5). The backend deletes the

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.

update your comments to explain the fields without mentioning the design doc

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.

Done — removed every design-doc reference from this file.

You're right that they don't belong here. This is a public repo, and an external reader has no way to resolve §5.5. It also rendered as mojibake in the diff view, since the section sign was the only non-ASCII byte in the file.

Five sites were affected: the token field, DISPOSITION_DELETED, the reason enum header, BLOB_NOT_STORE_OWNED, and the old LEGACY_V1_TOKEN. Each is rewritten to state the actual reason inline instead of pointing elsewhere. For example, BLOB_NOT_STORE_OWNED now explains that the payload column is customer-writable, so matching token text isn't proof the store wrote the blob — which was the whole point the reference was standing in for.

The file now has zero non-ASCII bytes.

Comment thread protos/orchestrator_service.proto Outdated
// A stable, bounded reason for a disposition. Never carries a token or raw exception text
// (design §7: tokens expose storage account, container, and blob path).
enum LargePayloadPurgeReason {
LARGE_PAYLOAD_PURGE_REASON_UNSPECIFIED = 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.

why need unspecified when can that happen

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.

Same reasoning as the disposition UNSPECIFIED thread, and yes — it can happen.

proto3 gives scalars no field presence, so a client that never sets reason still sends it as 0. There's no wire-level way to distinguish "not set" from "set to the value at 0". Keeping 0 meaningless is what lets the backend tell a genuine report apart from one where the field was never populated — otherwise an unset field would silently masquerade as a real diagnostic and send whoever is debugging a stalled ledger down the wrong path.

It's also required: proto3 mandates that the first enum value be zero. WorkerCapability in this same file does the same thing.

Lower stakes than the disposition one, since nothing branches on reason — but for that exact reason a wrong value here is pure misinformation, which is worth guarding against.

While addressing this, I also cut the enum from 11 values to 7 based on your other comment.

Comment thread protos/orchestrator_service.proto Outdated

// A stable, bounded reason for a disposition. Never carries a token or raw exception text
// (design §7: tokens expose storage account, container, and blob path).
enum LargePayloadPurgeReason {

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.

why do we need purgereason code?

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.

Fair challenge — I checked before defending it, and you were right that it was overbuilt. Cut from 11 values to 7.

What I verified first: the backend has zero branches on reason. LargePayloadPurgePolicy never references it, and LastFailureReason shows up only in tests and one SELECT. It's write-only. disposition alone drives every backend action — delete, reschedule, quarantine.

So what is it for? Diagnostics, and only that. The worker runs in the customer's process, so when a task hub stops reclaiming blobs we can't read worker logs to find out why. reason is the one signal that crosses that boundary. Without it, a ledger that isn't draining looks identical whether storage is throttling, the store can't delete at all, or tokens are corrupt — three very different operator responses.

But that also sets the right granularity: it should match the number of distinct operator responses, not the number of distinct causes. storageErrorCode already carries the specific storage status, so four of the eleven values were encoding the same fact twice. Collapsed:

  • TRANSIENT_STORAGE_FAILURE + STORAGE_ACCOUNT_UNREACHABLE + STORAGE_AUTHORIZATION_FAILEDSTORAGE_FAILURE. All three are reconfigurable or self-healing and lead to the same response; the specific status stays in storageErrorCode.
  • MALFORMED_TOKEN + INVALID_STORAGE_REQUEST + LEGACY_V1_TOKEN + UNSUPPORTED_TOKEN_VERSIONTOKEN_NOT_PURGEABLE. Each means the token can't be acted on and no retry changes that. storageErrorCode separates the storage-rejected case (populated) from the parse cases (empty).

Two I deliberately kept:

  • STORE_CANNOT_DELETE is not folded into STORAGE_FAILURE because storage is never contacted, so storageErrorCode is empty. It's also deployment-wide rather than per-row — every payload fails identically — which is a different response.
  • BLOB_NOT_STORE_OWNED stays because without it a DELETED row can't distinguish "reclaimed the blob" from "left it alone, wasn't ours". Your PurgedCount decision depends on that staying countable, and folding it in would make the metric claim reclaimed bytes that were never reclaimed.

Dispositions are unchanged — still the same four values with the same numbers. Worth flagging one thing explicitly: UNSUPPORTED_TOKEN_VERSION used to be a Retry reason and now folds into TOKEN_NOT_PURGEABLE, which sits under Quarantined. No row's disposition actually flips as a result; only the reason narrows, and an unreadable token still can't be deleted either way.

wangbill (YunchuWang) and others added 3 commits August 11, 2026 16:03
Review feedback: "why do we need purgereason code?" The pushback was
correct and the enum was too granular.

The backend has zero branches on `reason`. LargePayloadPurgePolicy never
references it, and LastFailureReason appears only in tests and one
SELECT, so the field is write-only. `disposition` alone drives every
backend action. That makes `reason` diagnostics, and its granularity
should match the number of distinct operator responses rather than the
number of distinct causes. `storageErrorCode` already carries the
specific storage status, so four of the eleven values were encoding the
same fact twice.

Collapsed 11 values to 7:

- TRANSIENT_STORAGE_FAILURE, STORAGE_ACCOUNT_UNREACHABLE, and
  STORAGE_AUTHORIZATION_FAILED become STORAGE_FAILURE. All three are
  reconfigurable or self-healing and produce the same operator response;
  the specific status stays available in storageErrorCode.
- MALFORMED_TOKEN, INVALID_STORAGE_REQUEST, LEGACY_V1_TOKEN, and
  UNSUPPORTED_TOKEN_VERSION become TOKEN_NOT_PURGEABLE. Each means the
  token cannot be acted on and no retry changes that. storageErrorCode
  distinguishes the storage-rejected case, where it is populated, from
  the parse cases, where it is empty.

STORE_CANNOT_DELETE stays separate from STORAGE_FAILURE because storage
is never contacted, so storageErrorCode is empty and the two are not
diagnosable the same way. BLOB_NOT_STORE_OWNED stays because without it
a DELETED row cannot distinguish reclaiming a blob from deliberately
leaving one alone, and the purged-count metric depends on that
distinction staying countable.

Dispositions are unchanged: the enum still has exactly the same four
values with the same numbers. UNSUPPORTED_TOKEN_VERSION was previously a
RETRY reason and folds into a QUARANTINED one, but no row's disposition
flips as a result; only the reason narrows, and an unreadable token
still cannot be deleted.

Also from review:

- Removed every design-doc section reference. This is a public repo and
  external readers cannot resolve them. Five sites are rewritten to be
  self-contained: the token field, DISPOSITION_DELETED, the reason enum
  header, BLOB_NOT_STORE_OWNED, and the former LEGACY_V1_TOKEN. The file
  now contains no non-ASCII bytes, which also removes the mojibake the
  section signs produced in the diff view.

- Documented why UNSPECIFIED exists, in answer to "why need unspecified
  when can that happen" and "no need for this?". It can happen: proto3
  has no field presence for scalars, so an unset field arrives as 0. If 0
  meant DELETED, a client that failed to set the field would make the
  backend delete tombstones and orphan the blobs permanently. It is also
  mandatory, since proto3 requires the first enum value to be zero, and
  WorkerCapability in this same file follows the identical convention.

- Corrected the ReportLargePayloadPurgeResults comment. It claimed the
  backend "reschedules RETRY with a reason-appropriate next attempt",
  which is false because the backend never reads reason. It now states
  that the backend branches solely on disposition and that the worker
  never computes a retry delay.

Definitions remain byte-identical to the canonical contract shared with
durabletask-dotnet#758.

Verified: protos/backend_service.proto still diffs empty against main;
all seven removed value names occur zero times; the reason enum has
exactly 7 values and the disposition enum still has 4; protoc compiles
all three protos.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment-only. No enum value, field number, or disposition changes; the
compiled descriptor surface is byte-for-byte identical before and after.

The reason enum grouped its values under "--- Reported with DELETED ---",
"--- Reported with RETRY ---", and "--- Reported with QUARANTINED ---"
banners, which asserted a 1:1 reason-to-disposition mapping. That mapping
does not hold. TOKEN_NOT_PURGEABLE sat under the QUARANTINED banner and
claimed "no retry can change that: ... it names a version this worker
does not support", which is false: the unknown-version case is reported
with RETRY, because a newer worker can read that token and an SDK upgrade
resolves it.

The banners were the actual defect rather than that one sentence, so
fixing the sentence alone would have left the grouping asserting
something untrue. The banners are removed, and each value now names its
own disposition inline. TOKEN_NOT_PURGEABLE documents both dispositions
explicitly and states that a consumer must not assume either.

The enum header now states that reason and disposition are orthogonal
with no fixed mapping, and that no reason-to-disposition mapping may be
asserted anywhere. This matters because this file is the public contract:
a backend implementer reading the old grouping could reasonably have
added an assertion that TOKEN_NOT_PURGEABLE never arrives with RETRY,
which would reject valid reports.

Also records why the asymmetry is deliberate. Quarantining an
unknown-version token is permanent and unrecoverable, whereas a retry
that never succeeds only leaves the row idle and visible, so the
recoverable failure mode is the correct default.

Definitions remain byte-identical to the canonical contract shared with
durabletask-dotnet#758.

Verified: the descriptor surface (all enum values and numbers, all field
numbers, all RPC signatures) is unchanged, 63 entries before and after;
the file remains at 0 non-ASCII bytes; the three group banners and the
false claim occur zero times; protoc compiles; backend_service.proto
still diffs empty against main.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both fields were 100% write-only across the system: the backend persists
LastFailureReason and StorageErrorCode but no SELECT, WHERE, API, or alert
reads either, and every backend action branches on disposition alone. The
number of distinct operator responses driven by reason is zero, so the
granularity rule that produced the 11->7 collapse, applied to the field
itself, says it should not exist. Failure detail already lives in the
worker's telemetry at full exception fidelity; these columns were a lossy
copy nobody queried.

Deleting reason also removes the reason-to-disposition mapping hazard
entirely, including the TOKEN_NOT_PURGEABLE case that spanned two
dispositions.

No reserved statements: these messages have never shipped in a release, so
no deployed peer has ever seen field 6 or 7. disposition stays at field 5;
the numbering gap costs nothing and renumbering would be one more way for
the three copies to diverge.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 38169e74-8326-44b2-ae45-77d14e34ba32
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.

1 participant