Skip to content

GH-5160: [DeduplicatedWithResponse(ClaimInTransaction = true)] writes the claim and its response inside the endpoint's transaction - #5161

Closed
uniquelau wants to merge 3 commits into
JasperFx:mainfrom
uniquelau:lg/deduplicated-response-in-transaction
Closed

uniquelau wants to merge 3 commits into
JasperFx:mainfrom
uniquelau:lg/deduplicated-response-in-transaction

Conversation

@uniquelau

@uniquelau uniquelau commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5160.

Adds an opt-in ClaimInTransaction to [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 before SaveChangesAsync. 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:

  • A rollback, or a crash before the commit, leaves no claim.
  • A commit leaves the claim answered.
  • There's no release and no record left to fail afterwards.

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

  • Before the endpoint runs, the chain reads any committed claim through the Marten session and answers from it: the replay, 422 or 409. When the key is required, the lookup joins the chain's batched query, so a [WriteAggregate] endpoint pays one round trip for the lookup and FetchForWriting together.
  • Just before the commit, after IHttpAware has set the status and Location, it builds the claim with the response and queues it onto the session.
  • If the commit fails, the request is answered from a claim committed in the meantime, if there is one; otherwise the failure is rethrown unchanged.
    • The failure can be on the claim's primary key, or earlier on the endpoint's own work, such as a [WriteAggregate] stream version conflict, so there is no table-scoped classifier.
    • A failure inside SaveChangesAsync after 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.
  • After the commit, a marker keeps later failures (writing the response) from being treated as a lost race.
  • An endpoint that asks for it but can't take it keeps claim-and-release, and the host logs a warning naming it. That covers:
    • another store, or database per tenant
    • a message store in another database
    • an endpoint whose response is produced after the commit, such as UpdatedAggregate
  • The same-database check is repeated at runtime. Pre-generated code built against a different configuration fails loudly instead of writing claims where they're never read.

Compared with [Deduplicated] on Marten (GH-4505)

The same pattern, with the departures below.

[Deduplicated] (GH-4505) This PR Why
Existing-claim check through the session, batchable when the key is required the same, returning the stored claim instead of a bool a repeat is answered from it
Runtime seam IMartenDeduplicator, public interface, internal implementation IMartenDeduplicatedResponses, the same shape
Claim queued on the session in middleware queued on the session just before the commit the response it stores doesn't exist until the endpoint has run
Race wraps the rest of the chain; catches a key violation on the deduplication table only wraps the commit only; answers from any committed claim, otherwise rethrows a concurrent [WriteAggregate] loser fails on the stream version before reaching the claim. Reproduced: under [Deduplicated] that loser gets a 500, not the 409 (#5166)
Switching on automatic opt-in ClaimInTransaction see above
Where the message store lives not checked must be the PostgreSQL store in the session's database; checked when the code is generated, and again at runtime otherwise claims are written where they are never read
Provider seam default member on the public IPersistenceFrameProvider separate internal interface, beside it, as IEventSourcingFrameProvider is no new public extension point while only Marten implements it

API

  • Public: DeduplicatedWithResponseAttribute.ClaimInTransaction and DeduplicatedWithResponseRequirement.ClaimInTransaction.
  • Public only because generated code calls them:
    • PendingDeduplicatedResponseClaim and IMartenDeduplicatedResponses (its implementation is internal)
    • DeduplicatedResponses.FindAsync and DeduplicatedResponses.PrepareClaim
    • three HttpHandler members
    • MessageDatabase.DeduplicatedResponsesFullName
  • Internal, through the existing InternalsVisibleTo:
    • the provider seam, ITransactionalReplayableDeduplicationProvider and TransactionalReplayableDeduplication
    • the ownership query, IReplayableDeduplicationClaimOwnership
  • No existing interface changes. That includes IDeduplicationStore, IReplayableDeduplicationStore, IMartenDeduplicator and IPersistenceFrameProvider.

Trade-offs, worth a look

  • A concurrent repeat that reaches its own answer first gets that answer, for example a validation that sees the winner's committed work and returns 400.
  • The stored body is serialized before the commit, so a value only the commit assigns isn't in it.
  • The losing request's response is cleared before it is answered, as ASP.NET Core's exception handler does. This also clears headers other middleware set directly; OnStarting headers survive.
  • An endpoint that also calls SaveChangesAsync itself 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_marten has 20 tests, 16 of them new. They cover:

  • the transactional frames, and that nothing is written outside the commit
  • with [WriteAggregate], the claim lookup joining the batched query
  • that no claim exists while the endpoint runs
  • a failed endpoint, a 4xx answer and a request without an optional key: none of them claims
  • a concurrent loser answered from the winner, on the claim's primary key and on a [WriteAggregate] version conflict, without its own headers
  • a commit failure with no winner, which is rethrown
  • a failure after the commit, which is rethrown and leaves an unanswered claim that refuses repeats with 409
  • a failure inside SaveChangesAsync after the database commit (a session listener throwing from AfterCommitAsync), which is rethrown: the answered claim and the work stay committed once, and the retry replays
  • 422 and 409 from claims written by claim-and-release
  • the fallbacks: the default with the option off, an endpoint that commits nothing, and UpdatedAggregate

deduplicated_with_response_on_stores_sharing_a_data_source covers a main and an ancillary Marten store handed one NpgsqlDataSource, 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:

  • The old path: the structural, "no claim while running" and race tests fail.
  • Without the post-commit marker: the post-commit failure test fails.
  • Without the existing-claim check: the repeat and 409 tests fail. A run counter stops the commit-race catch from hiding this.
  • Without the own-claim check: the after-database-commit test fails, because the request is answered as a success.
  • With the claims-table cache keyed by data source only: the shared-data-source test fails.
  • Without the UpdatedAggregate guard: only the generated-source assertion fails. Marten's FetchLatest still 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:

  • Wolverine.Http.Tests, every deduplication test, plus the ancillary-store natural-key suite that shares the new endpoints' assembly: 98/98
  • Wolverine.Http.FSharpTests, including the coverage ratchet and the compile gate: 11/11
  • MartenTests deduplication: 17/17
  • PostgresqlTests replayable-store compliance: 23/23 (on the first commit; the store is unchanged since)
  • dotnet build wolverine.slnx -c Release -f net9.0: 0 errors

Not covered by any test:

  • the F# emitters for the new frames, because no F# fixture has a Marten endpoint
  • a message store in another database (the fallback, and the runtime check for pre-generated code)

Separately: the default claim-and-release path re-runs committed work when SaveChangesAsync fails after the database commit. It predates this PR, and is #5165.

Docs: docs/guide/http/deduplication.md has 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

…] 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
Copilot AI balanced review requested due to automatic review settings October 23, 2026 12:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment on lines +177 to +197
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@uniquelau

Copy link
Copy Markdown
Contributor Author

Closing in favour of #5169, which includes these three commits unchanged and adds the fixes for #5165 and #5166.

@uniquelau uniquelau closed this Oct 23, 2026
jeremydmiller added a commit that referenced this pull request Oct 23, 2026
…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>
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.

[DeduplicatedWithResponse] on Marten: an option to write the claim and the response inside the endpoint's transaction

2 participants