Repository navigation
GH-5205, GH-5206: [DeduplicatedWithResponse] claim-in-transaction gaps ([WriteAggregate] T / [BoundaryModel], and repeats refused over committed work) - #5208
Merged
Conversation
…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
Contributor
|
One regression the GH-5205 fallback introduces, in a setup that works on A host with Answering with the registered provider of that type fixes it: var applied = AggregateHandling.TransactionOwner(chain)?.GetType();
return ordered.FirstOrDefault(x => x.GetType() == applied) ?? _nullo;( 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
This was referenced Oct 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 transactionThe first commit is @uniquelau's fix from
lg/fix/claim-in-transaction-write-aggregate, cherry-picked with authorship kept.GetPersistenceProvidersfalls back to the store the aggregate handler workflow applied when no provider'sCanApplyclaims the chain.AutoApplyTransactions,[Transactional]andCanApplyare unchanged.Follow-ups from a closer look:
[DcbModel]/[BoundaryModel]had the same gap. They apply their store's transaction themselves but record noAggregateHandling.BoundaryHandlingTagnow carries the provider, andAggregateHandling.TransactionOwnerconsults it too. New test:MartenTests.Dcb.deduplication_rides_a_boundary_model_transaction.CanApplyignores the workflow just as Marten's does, so a[Deduplicated]handler with[WriteAggregate] Tnow rides their transaction too. Pinned by new codegen and replay tests inPolecatTestsandFisherTests.ClaimInTransactionfor[DeduplicatedWithResponse]is still Marten-only, as documented.[WriteAggregate] Ton the default claim-and-release path. New test: a failure insideSaveChangesAsyncafter the database commit keeps the claim, so the retry gets 409 rather than running the work again.[WriteAggregate]names a new provider whether or not its store is integrated, so withAddMarten(...)minusIntegrateWithWolverine()andPersistMessagesWithPostgresql, a[Deduplicated]handler with[WriteAggregate] Tfailed codegen onIMartenDeduplicator. The fallback now answers with the registered provider of that store's type, or none, so that host keeps claim-and-release as onmain. New test:MartenTests.deduplication_without_marten_integration, which fails with the earlier fallback. That commit credits @uniquelau as co-author.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
ClaimInTransactionpath, a repeat could miss the claim at the front of the chain and then read the first request's committed work. IfLoad/Before/Validaterefused 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:
WatchForDeduplicatedRefusal. That holds the response back in a buffer until the commit, which releases it.BuildEndpointwraps 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.AnswerDeduplicatedResponseRaceFrame. One that finds the request's own claim committed (a listener throwing after the database commit) is still rethrown.MemoryStreamper keyed request on these endpoints until commit. One extra read, only on a failure before the commit.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 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.[DeduplicatedWithResponse], refuse as a duplicate for[Deduplicated].TransactionalDeduplicationgains an optionalCommitframe, which the three providers set. The HTTP chain armsWatchForDuplicateRefusalafter the queued claim, and callsMarkDeduplicationCommitStartedjust before the commit. From that point the existing commit-race wrapper decides, as before.IDeduplicationClaimLookup. That's a new optional interface, so nothing breaks; the RDBMS dedup store implements it with aselect 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.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.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.SaveChangesAsync.IsClaimedAsyncin both comparison modes, run against PostgreSQL, SQL Server, SQLite and MySQL.Ancillary store
deduplication_races_on_an_ancillary_storeuses a[MartenStore(typeof(INkThingStore))]host with the ancillary store in its own schema:ClaimInTransactionwith[WriteAggregate]of the aggregate itself. A concurrent race (the loser fails on the stream version and is answered from the winner's ancillary claim), then a replay. The claim is in the ancillary store, not the main one, andQueueClaimis generated.[DeduplicatedWithResponse]: the refusal recheck, with ProblemDetails and with a throw.[Deduplicated]: the refusal recheck, with the claim counted in the ancillary schema and absent from the main one.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.Not covered
select count(*)through the same store, but I didn't run it locally.🤖 Generated with Claude Code
https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U