Skip to content

feat(batching): ApplyItemException to isolate poison items from a batch (GH-3289 Phase 3a)#3302

Merged
jeremydmiller merged 2 commits into
mainfrom
feat-batching-phase3a-applyitem-3289
Jul 5, 2026
Merged

feat(batching): ApplyItemException to isolate poison items from a batch (GH-3289 Phase 3a)#3302
jeremydmiller merged 2 commits into
mainfrom
feat-batching-phase3a-applyitem-3289

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Phase 3a of the #3289 batch-processing plan: item 8 (ApplyItemException, "build FIRST") plus the shared reduced-batch re-execution primitive that items 2 & 3 will build on. (Phases 0/1 merged as #3299/#3300; Phase 2 is #3301.)

I split the plan's Phase 3 into two PRs for reviewability — this one is item 8 + the primitive; ProbeIndividuallyAfter (item 2) and IsolateBatchMembers (item 3) follow in a second PR that reuses the primitive.

The problem

Today a failed batch is retried and dead-lettered as a unit — one poison message takes every other message in the batch to the DLQ with it (MessageContext.MoveToDeadLetterQueueAsync fans over Envelope.Batch). When the handler already knows which item is bad, that's needless collateral.

The feature

A batch handler throws ApplyItemException naming the poison item(s) plus an explicit disposition for the survivors. Static factories only:

throw ApplyItemException.DeadLetterAndReplayOthers(badOrder);            // DLQ it, re-run the rest
throw ApplyItemException.DeadLetterAndAckOthers(bad1, bad2);             // DLQ them, ack the rest as-is
throw ApplyItemException.DeadLetter(poison: bads, ackItems: committed);  // DLQ bads, ack what I committed, replay the remainder

Resolution is a built-in FailureRule keyed on the exception type, registered in the WolverineOptions constructor as an every-attempt ContinueWith (mirrors the existing OnException<DuplicateIncomingEnvelopeException>().Discard() / rate-limit precedents). Throwing the exception is the opt-in — no config.

ApplyItemContinuation partitions Envelope.Batch by reference identity into poison / ack / replay:

  • poison → new internal MessageContext.MoveBatchMembersToDeadLetterQueueAsync — dead-letters just those members (native or durable path) without completing the batch;
  • replayBatchReplay.EnqueueReducedBatchAsync — the shared primitive: rebuilds a fresh, reduced typed T[] batch (fresh member envelopes carrying the items by reference so a subsequent ApplyItemException still maps) and enqueues it onto the batch's own local queue (runtime.Endpoints.AgentForLocalQueue(batch.Destination));
  • then CompleteAsync() settles every original member + the batch.

Tests (DB-free)

src/Testing/CoreTests/Acceptance/batch_item_isolation.cs:

  • ApplyItemException factory + empty/null-poison guard unit tests;
  • end-to-end isolation for each disposition — asserting the replayed survivor set and that only the poison item was dead-lettered. The DLQ assertion is captured via a registered IDeadLetterInterceptor (its BeforeStoreAsync runs even under NullMessageStore, so no database is needed).

All 8 green; existing batch acceptance tests unaffected. dotnet build wolverine.slnx -c Release clean.

Scope note

Items are matched by reference identity, which is exact for a normal batch. Under CoalesceBy the handler sees the last-wins instance for a key (the Message of only one member), so today only that member is dead-lettered and the earlier same-key members fall into the survivor set. Poisoning every member that collapsed into a coalesced key is a deliberate follow-up (called out in a code comment and the docs), handled in the items 2/3 PR.

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits July 5, 2026 15:39
…ch (GH-3289 Phase 3a)

Phase 3a of the #3289 batch-processing plan: item 8 (ApplyItemException) plus the
shared reduced-batch re-execution primitive that items 2 & 3 will build on.

A batch handler that already knows which item(s) are bad can throw ApplyItemException
to isolate them instead of dead-lettering the whole batch. Static factories make the
intent read at the call site:
  throw ApplyItemException.DeadLetterAndReplayOthers(badOrder);   // DLQ it, re-run the rest
  throw ApplyItemException.DeadLetterAndAckOthers(bad1, bad2);    // DLQ them, ack the rest
  throw ApplyItemException.DeadLetter(poison: bads, ackItems: committed); // DLQ, ack some, replay rest

Resolution is a built-in FailureRule keyed on the exception type (registered in the
WolverineOptions ctor as an every-attempt ContinueWith; throwing the exception is the
opt-in, no config). ApplyItemContinuation partitions Envelope.Batch by reference
identity into poison/ack/replay:
- poison -> new MessageContext.MoveBatchMembersToDeadLetterQueueAsync (per-member DLQ
  without completing the batch),
- replay -> BatchReplay.EnqueueReducedBatchAsync rebuilds a fresh reduced T[] batch on
  the same local queue (the shared primitive),
- then CompleteAsync settles every original member + the batch.

Tests (DB-free): ApplyItemException factory/guard unit tests + end-to-end isolation for
each disposition, asserting the replayed survivor set and that only the poison item was
dead-lettered (captured via an IDeadLetterInterceptor, which runs even under
NullMessageStore).

Known follow-up (items 2/3): poisoning every member that collapsed into a coalesced
CoalesceBy key (today only the surfaced last-wins member is matched by reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-3289 Phase 3a)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 251ded2 into main Jul 5, 2026
26 checks passed
knotekbr pushed a commit to knotekbr/wolverine that referenced this pull request Jul 6, 2026
…d key (JasperFxGH-3289 Phase 3d)

Final follow-up of the JasperFx#3289 batch-processing plan: the CoalesceBy interplay
flagged in JasperFx#3302. Under CoalesceBy the handler only sees the last-wins instance
for a key, so ApplyItemException previously dead-lettered only that one member and
left the earlier same-key members in the survivor set. Now flagging a coalesced item
poisons EVERY member that collapsed into that key.

- New internal Envelope.BatchGroupId (JsonIgnore, reset in Reset()): the coalesced
  group index, stamped per key by CoalescingMessageBatcher; null for normal batches.
- ApplyItemContinuation expands the poison set to all members sharing a poisoned
  member's BatchGroupId. No-op when BatchGroupId is null (non-coalesced batch), so
  existing reference-identity behavior is unchanged.

Test (DB-free): a key with three members whose coalesced last-wins is poison
dead-letters all three versions, while the healthy key survives and is replayed to
success. 3a ApplyItemException + Phase 1 CoalesceBy tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 9, 2026
This was referenced Jul 13, 2026
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