Repository navigation
Recreate GH-4505 transactional deduplication for Polecat #4570
Description
Recreate for Polecat what #4568 did for Marten: write the logical deduplication claim
inside the Polecat session's own transaction instead of on a separate connection, so a failed or refused
chain never claimed at all and the compensating release disappears.
#4564 is the shared design brief for both stores — read it for what core already provides and why this is
not a copy of the Marten implementation. Fisher is tracked separately.
The one structural difference from Marten
Marten works because Wolverine's message store is built from Marten's own NpgsqlDataSource
(WolverineOptionsMartenExtensions.BuildSinglePostgresqlMessageStore), so QueueOperation on the session
writes on the same connection the handler commits through.
Polecat's is not. WolverineOptionsPolecatExtensions.BuildSqlServerMessageStore passes only the
connection string, and SqlServerMessageStore's base call then does:
: base(database, SqlClientFactory.Instance.CreateDataSource(database.ConnectionString!), ...)Same SQL Server database, different DbDataSource and connection pool. So the claim has to be written
on the connection Polecat's session is holding, which means ITransactionParticipant:
// the pattern Wolverine.Polecat/Persistence/Operations/PolecatStorageExtensions.cs already uses
session.AddTransactionParticipant(new ClaimDeduplicationIdParticipant(store.DeduplicationFullName, id, expires));ITransactionParticipant.BeforeCommitAsync(SqlConnection, SqlTransaction, CancellationToken) is handed the
live connection and transaction, and runs after Polecat's own SQL but before the commit.
Note also that Polecat's IDocumentOperations.QueueSqlCommand has only the
(string, params object[]) overload — no char placeholder variant, unlike Marten and Fisher
(PolecatOps.Documents.cs says so in its file header). A typed participant avoids the question.
The optimistic check
Marten enlists an IQueryHandler<bool> via IBatchedQuery.AddItem, so the "has this been claimed?" query
shares the round trip with a [WriteAggregate] FetchForWriting. PolecatBatchingPolicy and
Polecat.Batching.IBatchedQuery have the same shape and the policy is registered
(PolecatIntegration.cs), but whether Polecat.Batching.IBatchedQuery exposes an AddItem<T>
equivalent is unverified — check that first, because if it does not, this needs an upstream Polecat ask
before it can be done in one round trip.
Also: PolecatBatchingPolicy.SortThroughFrames is unconditional — it has no equivalent of Marten's
IsBatchable filter. That filter is what lets a chain with an optional deduplication id opt out of the
batch, which is load-bearing: batch enlistment is unconditional code, and unkeyed traffic on a
Required = false chain is supposed to cost no round trip at all.
Schema
MessageDatabase.DeduplicationFullName (added in #4568) renders it correctly. The Polecat message-store
schema and the document schema are the same by default (WolverineOptionsPolecatExtensions, default
"wolverine"), but not in the multi-tenant path, and the ancillary default
(AncillaryWolverineOptionsPolecatExtensions) does not inherit store.Options.DatabaseSchemaName.
Multi-database tenancy falls back
Polecat has the same split as Marten — store.Options.Tenancy.Cardinality != DatabaseCardinality.Single
builds a separate main store from PolecatIntegration.MainConnectionString. That chain must fall through
to claim-and-release, exactly as the Marten guard does.
The guard is not CanApply
Repeating this from #4564 because getting it wrong is silent and total. CanApply answers "could this
provider own the chain's transaction?", which is true for any chain that merely takes an
IDocumentSession. A claim queued onto a unit of work nothing saves is never written — so the endpoint
reports itself as deduplicated and every replay runs, with no exception and nothing logged, while every
source-level assertion still passes. Require the actual CreateDocumentSessionFrame and
DocumentSessionSaveChanges postprocessor, as MartenPersistenceFrameProvider.TryBuildTransactionalDeduplication
does.
Tests to mirror
From #4568:
MartenTests/deduplication_rides_the_marten_transaction.csMartenTests/AncillaryStores/deduplication_rides_an_ancillary_marten_transaction.csWolverine.Http.Tests/Marten/deduplication_on_a_marten_transaction.cs(includes the no-commit fallback)
Two of those need copying carefully rather than simplifying:
- The concurrency test. Two plain concurrent sends pass against a build with no commit-race handling
at all, because the optimistic check refuses the second one whenever the first has already committed. It
uses a barrier inside the handler to hold both executions past their check before either commits.
Verified as a negative control on Marten: with the wrapper removed the loser escaped as a raw
MartenCommandException. Polecat's equivalent classifier has to be scoped to the deduplication table —
see Polecat and Fisher discard ANY unique-constraint violation, not just a duplicate inbox row #4565, which is a live bug in the existing unscopedDiscard()rule. the_claim_is_written_even_when_it_is_the_only_thing_in_the_unit_of_work. Polecat needed an upstream
fix (polecat#161, shipped in 4.2.1) soSaveChangesAsyncstill runs queued participants with no document
operations outstanding, which is exactly the shape a claim-only commit takes.- Any test that counts documents must clear them in setup —
ResetResourceStateclears Wolverine's tables
but leaves the application's documents, so it passes once and fails on every rerun.
Activity
jeremydmiller commentedon Sep 23, 2026
Resolved the
AddItemquestion flagged above — the answer is no, and it changes the plan for theoptimistic check.
Checked against the pinned versions (
Directory.Packages.props: Polecat 5.29.0, Marten 9.35.0):IBatchedQuerymembersAddItem·CheckExists·Execute·Load·LoadMany·Query·QueryByPlanCheckExists·EventsExist·Execute·FetchForWritingByTags·QueryByPlanPolecat.Batching.IBatchedQueryhas noAddItem<T>(IQueryHandler<T>), which is the seam the Martenimplementation uses to fold the "has this been claimed?" query into the batch
(
DeduplicationClaimExistsHandler). It also has no raw-SQLQueryoverload at all.QueryByPlantakes anIBatchQueryPlan<T>, which composes other batch operations rather than injecting SQL, so it is not a wayin either.
Polecat.IQuerySessionlikewise exposes onlyQuery<T>()(the LINQ entry point) andQueryByPlanAsync<T>— noQuery<T>(string sql, ...).What this means
Two options, and they are separable:
compensating release, no status-code test, correct by construction. Only the check costs a round trip
it could have shared. This is a complete, shippable v1 and I would do this first.
AddItem<T>-equivalent onBatching.IBatchedQuery, then fold thecheck in as a follow-up.
For the standalone check,
Polecat.IAdvancedSqlexists and is the likely route. Unlike Fisher, running thecheck on the Wolverine message store's own connection is also defensible here — SQL Server handles
concurrent connections from the same process without the self-contention SQLite has — but it is one more
connection per deduplicated message, so prefer the session.
The
Required = falseopt-out still matters regardless:PolecatBatchingPolicy.SortThroughFramesisunconditional and has no equivalent of Marten's
IsBatchablefilter.GH-4570: let a deduplication claim ride the Polecat transaction (#4573)
Bump to 6.40.0 (#4608)