Skip to content

GH-5205, GH-5206: [DeduplicatedWithResponse] claim-in-transaction gaps ([WriteAggregate] T / [BoundaryModel], and repeats refused over committed work) - #5208

Merged
jeremydmiller merged 8 commits into
mainfrom
gh-5205-5206-deduplicated-with-response
Oct 25, 2026
Merged

jeremydmiller merged 8 commits into
mainfrom
gh-5205-5206-deduplicated-with-response

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Closes #5205. Closes #5206. Also fixes the same refusal race for HTTP [Deduplicated], and covers all of it on an ancillary store.

GH-5205: [WriteAggregate] T (and [BoundaryModel]) chains find their store's transaction

The first commit is @uniquelau's fix from lg/fix/claim-in-transaction-write-aggregate, cherry-picked with authorship kept. GetPersistenceProviders falls back to the store the aggregate handler workflow applied when no provider's CanApply claims the chain. AutoApplyTransactions, [Transactional] and CanApply are unchanged.

Follow-ups from a closer look:

  • [DcbModel] / [BoundaryModel] had the same gap. They apply their store's transaction themselves but record no AggregateHandling. BoundaryHandlingTag now carries the provider, and AggregateHandling.TransactionOwner consults it too. New test: MartenTests.Dcb.deduplication_rides_a_boundary_model_transaction.
  • The fallback also changes Polecat and Fisher. Their CanApply ignores the workflow just as Marten's does, so a [Deduplicated] handler with [WriteAggregate] T now rides their transaction too. Pinned by new codegen and replay tests in PolecatTests and FisherTests. ClaimInTransaction for [DeduplicatedWithResponse] is still Marten-only, as documented.
  • The [DeduplicatedWithResponse] on Marten: a failure inside SaveChangesAsync after the database commit releases the claim, so the retry runs the work again #5165 commit hook now reaches [WriteAggregate] T on the default claim-and-release path. New test: a failure inside SaveChangesAsync after the database commit keeps the claim, so the retry gets 409 rather than running the work again.
  • Marten registered but not integrated (reported on this PR by @uniquelau, with the fix). [WriteAggregate] names a new provider whether or not its store is integrated, so with AddMarten(...) minus IntegrateWithWolverine() and PersistMessagesWithPostgresql, a [Deduplicated] handler with [WriteAggregate] T failed codegen on IMartenDeduplicator. The fallback now answers with the registered provider of that store's type, or none, so that host keeps claim-and-release as on main. New test: MartenTests.deduplication_without_marten_integration, which fails with the earlier fallback. That commit credits @uniquelau as co-author.
  • The GH-???? placeholders now say [DeduplicatedWithResponse] on Marten: ClaimInTransaction falls back to claim-and-release when [WriteAggregate] binds the aggregate rather than IEventStream<T> #5205.

All three new Marten tests fail with the fallback removed.

GH-5206: a concurrent repeat refused over the first request's committed work is answered from its claim

On the ClaimInTransaction path, a repeat could miss the claim at the front of the chain and then read the first request's committed work. If Load/Before/Validate refused it over that work (a 409 admission rule, a 404, a throw), it never reached the commit the race frame wraps. The client got the refusal for a request that had succeeded.

The claim commits in the same transaction as that work, so a second lookup finds it. This PR adds that lookup:

  • Once the front lookup has found nothing, the generated code calls WatchForDeduplicatedRefusal. That holds the response back in a buffer until the commit, which releases it.
  • BuildEndpoint wraps every [DeduplicatedWithResponse] endpoint. When the request fails before its commit (status ≥ 400, or a throw), the wrapper looks the claim up again. If it finds one, it answers from it: the stored response, or 422 for different bytes. Otherwise the endpoint's own answer goes out unchanged.
  • A failure of the commit itself is still decided by AnswerDeduplicatedResponseRaceFrame. One that finds the request's own claim committed (a listener throwing after the database commit) is still rethrown.
  • Cost: one MemoryStream per keyed request on these endpoints until commit. One extra read, only on a failure before the commit.
  • The wrapper is runtime code, so C# and F# codegen each gain a single call.

The "Claiming inside the transaction" docs bullet is updated to match. Please check its wording, since it reverses what the docs said before.

New tests in deduplicated_with_response_on_marten, with a barrier that lets both requests miss the claim deterministically:

  • the issue's repro, with ProblemDetails and with a throw;
  • the same key with a different body gets 422;
  • a refusal with no claim is answered as the endpoint answered it.

The same race on HTTP [Deduplicated]

An HTTP [Deduplicated] endpoint whose claim rides its own transaction (Marten, Polecat, Fisher) checks optimistically up front too. A concurrent repeat refused over the first request's committed work therefore answered with its own refusal: a 409 from the rule, a 404, or a 500 from a throw. It should get the endpoint's duplicate answer.

  • Shared watch: the [DeduplicatedWithResponse(ClaimInTransaction = true)]: a concurrent repeat that sees the first request's committed work answers the endpoint's own refusal, not the first response #5206 watch now takes either kind of answer: replay for [DeduplicatedWithResponse], refuse as a duplicate for [Deduplicated].
  • Commit boundary: TransactionalDeduplication gains an optional Commit frame, which the three providers set. The HTTP chain arms WatchForDuplicateRefusal after the queued claim, and calls MarkDeduplicationCommitStarted just before the commit. From that point the existing commit-race wrapper decides, as before.
  • Lookup: a failure before the commit is looked up again through IDeduplicationClaimLookup. That's a new optional interface, so nothing breaks; the RDBMS dedup store implements it with a select count(*) keyed the same way as its other statements. A match is answered with the same status and problem detail the up-front check gives.
  • Unchanged: a custom IMessageDeduplicator, or a store without the lookup, keeps the old behaviour. EF Core claims inside its transaction before the handler runs, so a concurrent repeat already waits on the first one's row.
  • Docs: a paragraph in durability/idempotency.md, next to the commit-race paragraph.

Tests:

  • deduplication_on_a_marten_transaction: the race with ProblemDetails and with a throw, and a refusal with no claim. Both race tests fail with the watch disabled.
  • Polecat and Fisher HTTP codegen tests check that the watch is woven, with the commit-started mark ahead of SaveChangesAsync.
  • A compliance test for IsClaimedAsync in both comparison modes, run against PostgreSQL, SQL Server, SQLite and MySQL.

Ancillary store

deduplication_races_on_an_ancillary_store uses a [MartenStore(typeof(INkThingStore))] host with the ancillary store in its own schema:

The four recheck tests fail when the lookup is pointed at the main store.

Verification

  • dotnet build wolverine.slnx -c Release -f net9.0: 0 errors, 0 warnings.
  • Wolverine.Http.Tests full suite: 1353 passed, 10 skipped, 0 failed.
  • Wolverine.Http.FSharpTests: 11/11.
  • MartenTests dedup + DCB classes, with the new unintegrated host: all pass.
  • After the last fix: Wolverine.Http.Tests dedup tests 119/119, PolecatTests dedup 20/20, FisherTests dedup 21/21, pinned build 0 errors.
  • PolecatTests dedup: 20/20. FisherTests dedup: 21/21.
  • Dedup store compliance (new lookup test): PostgreSQL, SQL Server, SQLite, MySQL.

Not covered

  • Oracle runs the same select count(*) through the same store, but I didn't run it locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U

uniquelau and others added 7 commits October 25, 2026 08:56
…egate itself

[DeduplicatedWithResponse(ClaimInTransaction = true)], and [Deduplicated] on Marten, ask
GetPersistenceProviders for the provider that owns the chain's transaction. A [WriteAggregate]
that binds the aggregate rather than IEventStream<T> leaves nothing for Marten's CanApply to see:
it ignores the aggregate handler workflow on purpose, because the workflow applies its store's
transaction itself (GH-3893). The lookup found no provider, and the endpoint fell back to
claim-and-release although it commits through a Marten session.

GetPersistenceProviders now falls back to the store AggregateHandling applied, only when
ProvidersClaiming finds nothing. AutoApplyTransactions, [Transactional] and CanApply are
unchanged, so no chain that resolves an owner today changes, and a mixed-store chain gets no
second claimant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion too

[DcbModel] and [BoundaryModel] apply their store's transaction themselves, as the aggregate handler workflow
does, without recording an AggregateHandling, so a [Deduplicated] chain whose only tie to Marten was a boundary
model still fell back to claim-and-release. BoundaryHandlingTag now records the provider, and
AggregateHandling.TransactionOwner consults it alongside the workflow's.

Also replaces the GH-???? placeholders with GH-5205.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
…d work is answered from its claim

On the ClaimInTransaction path a repeat looks its claim up once, at the front. One that missed it could then read
the first request's committed work and fail over it in Load/Before/Validate (a 409 admission rule, a 404, a
throw) before its commit, so the race handling never ran and the client was refused for a request that had
succeeded.

The claim commits in the same transaction as that work, so a second lookup finds it. Once the front lookup has
found nothing, the generated code arms WatchForDeduplicatedRefusal, which holds the response back in a buffer
until the commit. A wrapper that BuildEndpoint puts around every [DeduplicatedWithResponse] endpoint then looks a
failure before the commit (400 and up, or a throw) up again, and answers it from a claim if there is one: the
stored response, or 422 for different bytes. Otherwise the endpoint's own answer goes out unchanged. A failure of
the commit itself is still decided by AnswerDeduplicatedResponseRaceFrame, so one that finds this request's own
claim is still rethrown.

The wrapper is runtime code, so the C# and F# generated code only gain a one-line call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
… claim-and-release

GH-5205's fallback also reaches Polecat and Fisher, whose CanApply ignores the aggregate handler workflow the same
way Marten's does: a [Deduplicated] handler with [WriteAggregate] of the aggregate itself now rides their
transaction too. And on Marten's default claim-and-release path the GH-5165 commit hook is now attached to such an
endpoint, so a failure inside SaveChangesAsync after the database commit keeps the claim. Tests for each; all three
Marten ones fail without the fallback.

GH-5206: a concurrent request with the same key but different bytes, refused over the first's committed work, is
answered 422 as any such repeat is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
…mmitted work is refused as a duplicate

The same race as [DeduplicatedWithResponse(ClaimInTransaction)], on an HTTP [Deduplicated] endpoint whose claim rides
its own transaction (Marten, Polecat, Fisher). A concurrent repeat missed the up-front check, read the first
request's committed work and failed over it before its commit, so it answered its own refusal (a 409 admission
rule, a 404, a 500 from a throw) instead of the endpoint's duplicate answer. EF Core claims inside its transaction
before the handler runs, so a concurrent repeat already waits on the first one's row; it is unchanged.

The watch from the previous commit now takes either answer. TransactionalDeduplication gains an optional Commit
frame, which those three providers set; the HTTP chain arms WatchForDuplicateRefusal after the queued claim and
marks the commit as started just before it, from which point the commit race wrapper decides as before. A failure
before that is looked up again through IDeduplicationClaimLookup, a new optional interface the RDBMS deduplication
store implements, and answered with the duplicate status and problem detail the up-front check gives. A custom
IMessageDeduplicator, or a store without the lookup, keeps the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
A [WriteAggregate] of the aggregate itself with ClaimInTransaction, and the refusal recheck for both
[DeduplicatedWithResponse] and [Deduplicated], on a [MartenStore] endpoint: each claim lands in the ancillary
store's message tables and is looked up again there, never in the main store's. The four recheck tests fail when
the lookup is pointed at the main store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
…dpoints

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
@uniquelau

Copy link
Copy Markdown
Contributor

One regression the GH-5205 fallback introduces, in a setup that works on main:

A host with AddMarten(...) but no IntegrateWithWolverine(), with PersistMessagesWithPostgresql, and a [Deduplicated] handler taking [WriteAggregate] T. On main it compiles and keeps claim-and-release. On this branch, codegen fails with UnResolvableVariableException on IMartenDeduplicator. [WriteAggregate] names a new Marten provider whether or not Marten is integrated, so the fallback can return a provider that isn't registered.

Answering with the registered provider of that type fixes it:

var applied = AggregateHandling.TransactionOwner(chain)?.GetType();
return ordered.FirstOrDefault(x => x.GetType() == applied) ?? _nullo;

(ordered is rules.OrderedPersistenceProviders(), with the claimant returned first when there is one.)

It's an undocumented setup, so take it or leave it.

…low's store

[WriteAggregate] names a new provider instance whether or not its store is
integrated with Wolverine. With Marten registered but not integrated, and the
messages persisted in PostgreSQL, a [Deduplicated] handler taking
[WriteAggregate] T failed codegen on IMartenDeduplicator where main kept
claim-and-release. The fallback now answers with the registered provider of
that store's type, or none.

Reported on #5208 by @uniquelau, with the fix.

Co-authored-by: Laurence Gillian <lau@eddlondon.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
@jeremydmiller
jeremydmiller merged commit 8eb280f into main Oct 25, 2026
50 of 97 checks passed
This was referenced Oct 26, 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

2 participants