Repository navigation
GH-5160: [DeduplicatedWithResponse(ClaimInTransaction = true)] writes the claim and its response inside the endpoint's transaction - #5161
Conversation
…] writes the claim and its response inside the endpoint's transaction An opt-in ClaimInTransaction on [DeduplicatedWithResponse]. On an endpoint that commits through a Marten session with Wolverine's transactional middleware, on a single-database store with the PostgreSQL message store in the same database, the claim and its stored response are written as one row, queued onto the session before SaveChangesAsync. A rollback, or a crash before the commit, leaves no claim; a commit leaves it answered. There is no release and no record left to fail afterwards. The default is unchanged. - Before the endpoint runs, the chain answers from any committed claim. - A commit that fails is answered from a claim a concurrent request committed meanwhile, on the claim's primary key or on the endpoint's own work (a stream version conflict); otherwise it is rethrown. A failure after this request's own database commit (the outbox flush runs inside SaveChangesAsync on Marten) is told apart by the claim token and rethrown. - An endpoint that asks for it but cannot take it keeps claim-and-release and logs a warning, including one whose response is produced after the commit (UpdatedAggregate). The same-database check is repeated at runtime for pre-generated code. - The provider seam (ITransactionalReplayableDeduplicationProvider) and the ownership query are internal; no existing interface changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4
There was a problem hiding this comment.
🟡 Changes recommended
The claim-table cache can route writes to the wrong schema when stores share a data source, preventing correct response replay.
2 open issues
What changed in this PR
Adds opt-in transactional response deduplication for Marten HTTP endpoints, extending Wolverine’s existing transactional deduplication support. Eligible endpoints commit the claim, response, and application work together; the default claim-and-release behavior remains unchanged.
Changes:
- Adds
ClaimInTransaction, eligibility checks, fallback warnings, and documentation. - Introduces transactional claim storage, response replay, and commit-failure handling.
- Expands Marten HTTP tests for rollback, concurrent requests, replay, and fallbacks.
| File | Description |
|---|---|
| src/Wolverine/Persistence/TransactionalReplayableDeduplication.cs | Defines the internal transactional provider contract. |
| src/Wolverine/Persistence/Durability/IReplayableDeduplicationStore.cs | Adds claim ownership and pending-claim types. |
| src/Persistence/Wolverine.RDBMS/MessageDatabase.DeduplicatedResponses.cs | Exposes ownership checks through the message store. |
| src/Persistence/Wolverine.RDBMS/MessageDatabase.cs | Exposes the response-claim table name. |
| src/Persistence/Wolverine.RDBMS/Deduplication/RdbmsReplayableDeduplicationStore.cs | Implements claim-token ownership queries. |
| src/Persistence/Wolverine.Marten/WolverineOptionsMartenExtensions.cs | Registers transactional response-claim support. |
| src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs | Checks eligibility and supplies transaction frames. |
| src/Persistence/Wolverine.Marten/Persistence/Operations/ClaimDeduplicatedResponse.cs | Inserts the claim and response within Marten’s transaction. |
| src/Persistence/Wolverine.Marten/MartenDeduplication.cs | Resolves claim tables and validates database alignment. |
| src/Persistence/Wolverine.Marten/Codegen/MartenDeduplicationFrames.cs | Generates claim-queueing calls. |
| src/Http/Wolverine.Http/Runtime/DeduplicatedResponses.cs | Adds claim lookup, preparation, and ownership helpers. |
| src/Http/Wolverine.Http/Policies/DeduplicatedResponseFrames.cs | Generates transactional deduplication control flow. |
| src/Http/Wolverine.Http/HttpHandler.cs | Handles commit-race replay and committed-state tracking. |
| src/Http/Wolverine.Http/HttpChain.DeduplicatedWithResponse.cs | Selects and assembles the transactional path. |
| src/Http/Wolverine.Http/HttpChain.Codegen.cs | Passes configuration into deduplication assembly. |
| src/Http/Wolverine.Http/DeduplicatedWithResponseRequirement.cs | Adds the opt-in requirement. |
| src/Http/Wolverine.Http/DeduplicatedWithResponseAttribute.cs | Exposes and propagates the attribute option. |
| src/Http/Wolverine.Http.Tests/Marten/deduplicated_with_response_on_marten.cs | Expands transactional behavior and fallback coverage. |
| docs/guide/http/deduplication.md | Documents guarantees, concurrency trade-offs, and limitations. |
Review effort: Balanced
| private ImHashMap<NpgsqlDataSource, string> _tables = ImHashMap<NpgsqlDataSource, string>.Empty; | ||
|
|
||
| private string tableFor(IDocumentSession session, Type? ancillaryStoreMarker) | ||
| { | ||
| // A session's data source belongs to one store, and so to one marker | ||
| var sessionDataSource = session.Database.As<PostgresqlDatabase>().DataSource; | ||
| if (_tables.TryFind(sessionDataSource, out var table)) return table; | ||
|
|
||
| var store = MessageStoreFor(_runtime, ancillaryStoreMarker) ?? throw new InvalidOperationException( | ||
| $"[DeduplicatedWithResponse] needs {nameof(DurabilitySettings)}.{nameof(DurabilitySettings.EnableDeduplicatedResponses)} = true on the PostgreSQL message store this Marten store integrates with. See GH-4742"); | ||
|
|
||
| // Checked again here, not only when the endpoint was compiled: pre-generated code carries that decision | ||
| // from whatever machine generated it, and a claim written to another database is never read back | ||
| if (!IsSameDatabase(store.NpgsqlDataSource, sessionDataSource)) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"[DeduplicatedWithResponse(ClaimInTransaction = true)] was compiled to write its claim inside the Marten transaction, but this host's message store is in a different database from that Marten store, so the claim would never be read back. Regenerate the code against this configuration, or turn ClaimInTransaction off. See GH-5160"); | ||
| } | ||
|
|
||
| table = store.DeduplicatedResponsesFullName; | ||
| _tables = _tables.AddOrUpdate(sessionDataSource, table); |
There was a problem hiding this comment.
Confirmed, and fixed in 2cfee6b. The cache is now keyed by data source and store marker.
I reproduced it before fixing. It happens when the application hands one NpgsqlDataSource to both stores through StoreOptions.Connection(dataSource); ancillary stores have no UseNpgsqlDataSource(). The ancillary claim was written to the main store's table, and the repeat ran the endpoint again and failed at commit with MartenCommandException. With the same connection string on both stores, each gets its own data source and there was no problem.
deduplicated_with_response_on_stores_sharing_a_data_source covers it: the main store claims first through the shared data source, then the ancillary claim must land in the ancillary table, and its repeat must replay without running the endpoint. It fails with the old cache key.
| DeduplicatedResponseClaim? winner; | ||
| try | ||
| { | ||
| if (ownClaim != null && await responses.HoldsClaimAsync(ownClaim, ancillaryStoreMarker).ConfigureAwait(false)) |
There was a problem hiding this comment.
Added in 2cfee6b: a_failure_inside_the_commit_after_the_database_commit_is_rethrown_and_its_retry_replays.
A Marten session listener throws from AfterCommitAsync for one marker document. The test asserts:
- the exception reaches the caller;
- the claim is committed and answered, with the work committed once;
- the retry replays the 201 without running the endpoint.
It fails if the own-claim check is disabled, because the request is then answered as a success.
The same probe showed that the default claim-and-release path doesn't handle this case. The work commits, the claim is released, and the retry runs the endpoint again. That predates this PR, so I've raised it separately as #5165.
…in a failure after the database commit From the Copilot review on JasperFx#5161, both reproduced before fixing. - MartenDeduplicatedResponses cached the claims table by the session's NpgsqlDataSource alone. An application that hands one data source to its main and ancillary stores (StoreOptions.Connection(dataSource)) got the first store's table for both: the ancillary claim landed where it is never read, and the repeat ran the endpoint again and failed at commit. The cache is now keyed by data source and store marker. - A Marten session listener that throws from AfterCommitAsync fails SaveChangesAsync after the database commit. The exception now has a test: it reaches the caller, the answered claim and the work stay committed once, and the retry replays without running the endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4
…batchable, as [Deduplicated] does (JasperFxGH-4505) Aligns the in-transaction path with [Deduplicated] on Marten. - The existing-claim lookup now goes through the Marten session, supplied by the provider as TransactionalDeduplication.Check is. It is a query handler returning the stored claim, and it joins the chain's batched query when the key is required, so a [WriteAggregate] endpoint pays one round trip for the lookup and FetchForWriting together. An optional key stays standalone, so a request without one costs no round trip. - The runtime seam is now IMartenDeduplicatedResponses, a public interface with an internal implementation, as IMartenDeduplicator is. - HTTP computes the hashed storage id and answers from the provider's lookup; the separate-connection lookup frame is gone. The race handling deliberately stays different. A probe of [Deduplicated] showed that its concurrent [WriteAggregate] loser fails on the stream version before reaching the claim, and gets a 500 rather than the 409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4
…ion, plus two Marten commit-failure fixes (#5169) * GH-5160: [DeduplicatedWithResponse(ClaimInTransaction = true)] writes the claim and its response inside the endpoint's transaction An opt-in ClaimInTransaction on [DeduplicatedWithResponse]. On an endpoint that commits through a Marten session with Wolverine's transactional middleware, on a single-database store with the PostgreSQL message store in the same database, the claim and its stored response are written as one row, queued onto the session before SaveChangesAsync. A rollback, or a crash before the commit, leaves no claim; a commit leaves it answered. There is no release and no record left to fail afterwards. The default is unchanged. - Before the endpoint runs, the chain answers from any committed claim. - A commit that fails is answered from a claim a concurrent request committed meanwhile, on the claim's primary key or on the endpoint's own work (a stream version conflict); otherwise it is rethrown. A failure after this request's own database commit (the outbox flush runs inside SaveChangesAsync on Marten) is told apart by the claim token and rethrown. - An endpoint that asks for it but cannot take it keeps claim-and-release and logs a warning, including one whose response is produced after the commit (UpdatedAggregate). The same-database check is repeated at runtime for pre-generated code. - The provider seam (ITransactionalReplayableDeduplicationProvider) and the ownership query are internal; no existing interface changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4 * GH-5160: key the Marten claim-table cache by store too, and pin a failure after the database commit From the Copilot review on #5161, both reproduced before fixing. - MartenDeduplicatedResponses cached the claims table by the session's NpgsqlDataSource alone. An application that hands one data source to its main and ancillary stores (StoreOptions.Connection(dataSource)) got the first store's table for both: the ancillary claim landed where it is never read, and the repeat ran the endpoint again and failed at commit. The cache is now keyed by data source and store marker. - A Marten session listener that throws from AfterCommitAsync fails SaveChangesAsync after the database commit. The exception now has a test: it reaches the caller, the answered claim and the work stay committed once, and the retry replays without running the endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4 * GH-5160: read the existing claim through the Marten session, batchable, as [Deduplicated] does (GH-4505) Aligns the in-transaction path with [Deduplicated] on Marten. - The existing-claim lookup now goes through the Marten session, supplied by the provider as TransactionalDeduplication.Check is. It is a query handler returning the stored claim, and it joins the chain's batched query when the key is required, so a [WriteAggregate] endpoint pays one round trip for the lookup and FetchForWriting together. An optional key stays standalone, so a request without one costs no round trip. - The runtime seam is now IMartenDeduplicatedResponses, a public interface with an internal implementation, as IMartenDeduplicator is. - HTTP computes the hashed storage id and answers from the provider's lookup; the separate-connection lookup frame is gone. The race handling deliberately stays different. A probe of [Deduplicated] showed that its concurrent [WriteAggregate] loser fails on the stream version before reaching the claim, and gets a 500 rather than the 409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4 * GH-5165, GH-5166: hear Marten's database commit before anything inside SaveChangesAsync can still throw Marten runs its session listeners' AfterCommitAsync inside SaveChangesAsync, after the database commit, Wolverine's outbox flush among them. From outside the call a failure there looked the same as a failed commit. A DatabaseCommitMarker listener, inserted at the front of the session's listeners, now records when the commit started and when the database commit succeeded. GH-5165: on a claim-and-release [DeduplicatedWithResponse] Marten endpoint, a failure after the database commit released the claim, so the client's retry ran the committed work again. The chain now hears the commit (internal IDatabaseCommitHookProvider seam, Marten only) and keeps the claim unanswered on a failure over committed work: repeats get 409 until it expires, the same as a failed record. A request that completes normally keeps the existing rule. GH-5166: on [Deduplicated] with Marten (GH-4505), a concurrent [WriteAggregate] loser fails on the stream version before reaching the claim's INSERT, so the unique-violation classifier never matched and it escaped as a 500. A commit that fails for any other reason is now checked on a fresh query session: if the key is claimed, it gets the same refusal (409 / discard), otherwise the failure is rethrown unaltered. Failures before the commit or after the database commit are never looked up. 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 --------- Co-authored-by: Laurence Gillian <lau@eddlondon.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>


Closes #5160.
Adds an opt-in
ClaimInTransactionto[DeduplicatedWithResponse]. On an endpoint that commits through a Marten session with Wolverine's transactional middleware, the claim and its stored response are then written as one row, queued onto the session beforeSaveChangesAsync. The default is unchanged: the claim is taken up front and the response recorded after the commit, each on a connection of its own.With the option on:
This follows GH-4505, which moved
[Deduplicated]'s claim inside the transaction on Marten, and #5129 (R2/E1), which did the same for eager idempotency.Why opt-in
It changes what a concurrent repeat does. Nothing is claimed up front, so a repeat that arrives while the first request is running runs the endpoint too. Normally its commit fails and it is answered from the first's claim. But anything outside the transaction (an external call, a send without the outbox) runs for both, where claim-and-release refused the repeat with 409 before it did anything. That's a behaviour change existing endpoints didn't ask for, so it is off by default.
@jeremydmiller, a question for you:
[Deduplicated]took the same trade-off automatically in GH-4505, with no switch. Would you rather this were automatic too? Going from opt-in to automatic later is easy; going the other way would be a breaking change.How it works
[WriteAggregate]endpoint pays one round trip for the lookup andFetchForWritingtogether.IHttpAwarehas set the status andLocation, it builds the claim with the response and queues it onto the session.[WriteAggregate]stream version conflict, so there is no table-scoped classifier.SaveChangesAsyncafter the database commit (on Marten, the outbox flush and session listeners run there) finds the request's own claim. It's identified by its claim token and rethrown, as on claim-and-release.UpdatedAggregateCompared with
[Deduplicated]on Marten (GH-4505)The same pattern, with the departures below.
[Deduplicated](GH-4505)boolIMartenDeduplicator, public interface, internal implementationIMartenDeduplicatedResponses, the same shape[WriteAggregate]loser fails on the stream version before reaching the claim. Reproduced: under[Deduplicated]that loser gets a 500, not the 409 (#5166)ClaimInTransactionIPersistenceFrameProviderIEventSourcingFrameProviderisAPI
DeduplicatedWithResponseAttribute.ClaimInTransactionandDeduplicatedWithResponseRequirement.ClaimInTransaction.PendingDeduplicatedResponseClaimandIMartenDeduplicatedResponses(its implementation is internal)DeduplicatedResponses.FindAsyncandDeduplicatedResponses.PrepareClaimHttpHandlermembersMessageDatabase.DeduplicatedResponsesFullNameInternalsVisibleTo:ITransactionalReplayableDeduplicationProviderandTransactionalReplayableDeduplicationIReplayableDeduplicationClaimOwnershipIDeduplicationStore,IReplayableDeduplicationStore,IMartenDeduplicatorandIPersistenceFrameProvider.Trade-offs, worth a look
OnStartingheaders survive.SaveChangesAsyncitself commits its work before the claim. This is documented, not detected. Deduplication should ride the native transaction on Marten, Polecat and Fisher — batched optimistic check, no compensating release #4505 has the same gap.Tests
deduplicated_with_response_on_martenhas 20 tests, 16 of them new. They cover:[WriteAggregate], the claim lookup joining the batched query[WriteAggregate]version conflict, without its own headersSaveChangesAsyncafter the database commit (a session listener throwing fromAfterCommitAsync), which is rethrown: the answered claim and the work stay committed once, and the retry replaysUpdatedAggregatededuplicated_with_response_on_stores_sharing_a_data_sourcecovers a main and an ancillary Marten store handed oneNpgsqlDataSource, each with its own message schema: the ancillary claim lands in its own table, and its repeat replays.Mutation checks, each run against this change and then reverted:
UpdatedAggregateguard: only the generated-source assertion fails. Marten'sFetchLateststill returned the updated aggregate when moved before the commit in that test, so the guard is a precaution rather than a reproduced bug.Run locally:
dotnet build wolverine.slnx -c Release -f net9.0: 0 errorsNot covered by any test:
Separately: the default claim-and-release path re-runs committed work when
SaveChangesAsyncfails after the database commit. It predates this PR, and is #5165.Docs:
docs/guide/http/deduplication.mdhas a new "Claiming inside the transaction" section, and the attribute's XML docs are updated.🤖 Generated with Claude Code
https://claude.ai/code/session_01JxXDGaqdYTzZ54ZmdzUwH4