Skip to content

Recreate GH-4505 transactional deduplication for Polecat #4570

Closed

Description

@jeremydmiller

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.cs
  • MartenTests/AncillaryStores/deduplication_rides_an_ancillary_marten_transaction.cs
  • Wolverine.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 unscoped Discard() 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) so SaveChangesAsync still 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 — ResetResourceState clears Wolverine's tables
    but leaves the application's documents, so it passes once and fails on every rerun.

Follow-up to #4505. Marten reference implementation: #4568.

Activity

  1. jeremydmiller commented on Sep 23, 2026

    @jeremydmiller
    MemberAuthor

    Resolved the AddItem question flagged above — the answer is no, and it changes the plan for the
    optimistic check.

    Checked against the pinned versions (Directory.Packages.props: Polecat 5.29.0, Marten 9.35.0):

    IBatchedQuery members
    Marten AddItem · CheckExists · Execute · Load · LoadMany · Query · QueryByPlan
    Polecat CheckExists · EventsExist · Execute · FetchForWritingByTags · QueryByPlan

    Polecat.Batching.IBatchedQuery has no AddItem<T>(IQueryHandler<T>), which is the seam the Marten
    implementation uses to fold the "has this been claimed?" query into the batch
    (DeduplicationClaimExistsHandler). It also has no raw-SQL Query overload at all. QueryByPlan takes an
    IBatchQueryPlan<T>, which composes other batch operations rather than injecting SQL, so it is not a way
    in either.

    Polecat.IQuerySession likewise exposes only Query<T>() (the LINQ entry point) and
    QueryByPlanAsync<T> — no Query<T>(string sql, ...).

    What this means

    Two options, and they are separable:

    1. Ship without batching. The claim still rides the transaction, which is the whole point — no
      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.
    2. Upstream ask on Polecat for an AddItem<T>-equivalent on Batching.IBatchedQuery, then fold the
      check in as a follow-up.

    For the standalone check, Polecat.IAdvancedSql exists and is the likely route. Unlike Fisher, running the
    check 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 = false opt-out still matters regardless: PolecatBatchingPolicy.SortThroughFrames is
    unconditional and has no equivalent of Marten's IsBatchable filter.

  2. added a commit that references this issue on Sep 23, 2026
  3. added a commit that references this issue on Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions