Skip to content

Recreate GH-4505 transactional deduplication for Polecat #4570

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

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