Skip to content

M9-S7: fix CatalogListingView cross-queue create race#112

Merged
erikshafer merged 1 commit into
mainfrom
m9-s7-listings-cross-queue-race-fix
Jun 17, 2026
Merged

M9-S7: fix CatalogListingView cross-queue create race#112
erikshafer merged 1 commit into
mainfrom
m9-s7-listings-cross-queue-race-fix

Conversation

@erikshafer

Copy link
Copy Markdown
Owner

What

Fixes the CatalogListingView last-writer-wins race between the Listings read-model handlers on different RabbitMQ queues. Two handlers could both LoadAsync the same catalog row as null and both Store a fresh document; the second commit silently overwrote the first, losing selling fields (Title, SellerId, StartingBid). Observed live on 2026-06-13.

OQ-1 spike (the gate) — reshaped the fix

The slice was gated on whether the planned UseNumericRevisions(true) would make the losing Store throw. A diagnostic spike against live Postgres said no:

Strategy Loser throws? Final Title mt_version
Store vs Store (current) no exception '' (lost) 2
Insert vs Insert (fix) DocumentAlreadyExistsException 'SELLING-RECORD' (kept) 1

UseNumericRevisions bumps mt_version but a plain Store still commits and last-writer-wins — enforcement needs a revision-checked write path, which sagas get from Wolverine's saga-persistence frame but plain read-model handlers do not (the same lesson AuctionClosingSaga records from M8-S3c). So the mechanism is the document primary key, not the version column.

Fix

  • session.Insert on the create branch of all twelve CatalogListingView write methods across five handlers — via a shared IDocumentSession.InsertOrStore helper for the ?? new sites, and direct Insert in the two explicit-null branches.
  • New ListingsConcurrencyRetryPolicies : IWolverineExtension retries on DocumentAlreadyExistsException (mirrors AuctionsConcurrencyRetryPolicies). The retried handler re-loads the committed row and merges via the existing load-and-preserve Store path, so both field sets survive.
  • The spike's IRevisioned/UseNumericRevisions edits were reverted — inert and misleading for a plain-Store path. CatalogListingView.cs is byte-identical to baseline.

Scope boundary

Closes the insert-insert create race (the observed bug). The residual update-update race (two handlers updating an existing row) is deferred as OQ-3 — never the observed failure, and closing it would mean UpdateRevision across four handler files. Tracked alongside the analogous Operations BC read-model pattern.

Tests

  • 2 new bidirectional regression tests (collision throws + retry merges, both commit orderings).
  • No contracts/events/spec changes. No existing test modified.
  • 328 backend tests green (+2). Build clean (1 pre-existing unrelated AppHost MessagePack advisory).

Prompt + retrospective included: docs/prompts/implementations/M9-S7-… and docs/retrospectives/M9-S7-….

Two Listings read-model handlers on different RabbitMQ queues could both
LoadAsync the same catalog row as null and both Store a fresh document; the
second commit silently overwrote the first, losing selling fields (Title,
SellerId, StartingBid). Observed 2026-06-13.

An OQ-1 spike against live Postgres refuted the planned UseNumericRevisions
approach: numeric revisions bump mt_version but a plain session.Store still
last-writer-wins with no exception. Enforcement needs a revision-checked write
path, which sagas get from Wolverine's saga-persistence frame but plain
read-model handlers calling session.Store do not have (the same lesson the
AuctionClosingSaga docstrings record from M8-S3c).

Fix: session.Insert on the create branch of all twelve CatalogListingView write
methods across five handlers (shared IDocumentSession.InsertOrStore helper +
direct Insert in the two explicit-null branches). The losing concurrent creator
collides on the document primary key (DocumentAlreadyExistsException), caught by
a new ListingsConcurrencyRetryPolicies retry-with-cooldown; the retried handler
re-loads the committed row and merges via the existing load-and-preserve Store
path, so both writers' field sets survive.

No new contracts, events, or numeric-revision config. The CatalogListingView
wire shape is byte-identical to baseline. 328 backend tests green (+2).
@erikshafer erikshafer self-assigned this Jun 17, 2026
@erikshafer erikshafer merged commit 78443c1 into main Jun 17, 2026
14 checks passed
@erikshafer erikshafer deleted the m9-s7-listings-cross-queue-race-fix branch June 17, 2026 04:18
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.

1 participant