Skip to content

fix(idempotency): store observe results in the business transaction - #158

Merged
senrecep merged 5 commits into
mainfrom
fix/idempotency-observe-transaction
Oct 20, 2026
Merged

senrecep merged 5 commits into
mainfrom
fix/idempotency-observe-transaction

Conversation

@senrecep

Copy link
Copy Markdown
Owner

Summary

Fixes a lost-work bug in Observe mode when idempotency runs inside the business transaction (Mediator Tx → Idem → Lock, or HTTP UseIdempotency inside a transaction) with the EF Core record store.

Bug: Observe reserved and completed through the Detached store, so the Completed record was committed before the business transaction. If the business commit then failed, a retry after switching to Enforce replayed a success that never committed.

Fix:

  • Observe reserves through Detached, releases that reservation detached after the operation, and writes the result through the attached store inside the business (or ambient) transaction. A failed commit rolls the record back too, so the retry runs the handler again.
  • EfCoreIdempotencyRecordStore.CompleteAsync guards a result for a token it did not reserve with a savepoint (EF savepoint API; raw SAVEPOINT for a PostgreSQL ambient transaction), so a failing result write does not abort the business transaction.
  • A failing savepoint rollback no longer hides the original database error; a PostgreSQL connection not enlisted in the ambient transaction writes without a savepoint.
  • Enforce is unchanged. No public API change.

Behavior notes (documented):

  • A retry after a failed observed commit is counted as reserved, not replay.
  • On PostgreSQL, Observe stores results only under READ COMMITTED.
  • On SQL Server, XACT_ABORT ON or a SNAPSHOT update conflict dooms the transaction, and a savepoint cannot recover it.
  • Stores that cannot join the transaction keep the previous residual risk.
  • ITransactionalIdempotencyRecordStore implementations: in Observe, CompleteAsync is now called with a token whose reservation has already been released, so it must succeed when the entry is gone, per the IIdempotencyRecordStore contract.

Tests

  • New: Observe, then the commit fails, then switch to Enforce and retry. The handler runs again with no replay (Mediator and HTTP, PostgreSQL), and these tests fail without the fix.
  • Also new: savepoint and no-transaction paths on SQLite, plus PostgreSQL Enlist=false.
  • Local results:
    • CSharpEssentials.Tests 8134
    • OpenApi.Tests 86
    • EntityFrameworkCore.Tests 20
    • Examples.Idempotency.EndToEnd 14
    • Examples.Idempotency.Redis 28
    • Build: 0 errors, 7 known warnings.

Follow-ups (next patch)

  • Store complete fencing, the Off kill switch, and plan drift.
  • A test for a failing savepoint rollback.

Observe mode reserved and completed through the detached store, so a
Completed record could outlive a business transaction whose commit
failed. After the switch to Enforce, a retry replayed a success that
never committed.

Observe now reserves through the detached store, releases that
reservation once the operation finishes, and writes the result through
the attached store inside the business or ambient transaction. A failed
commit rolls the result back with the business data, so the retry runs
again. Release paths stay detached and Enforce is unchanged.

The EF Core store guards a result write for a token it did not reserve
with a savepoint (the EF savepoint API, or raw SAVEPOINT statements in
an ambient PostgreSQL transaction), so a failed result write cannot
abort the business transaction and lose its data on commit.

The HTTP middleware applies the same flow.
…mmit

Observe with an EF Core store, through the mediator inside the transaction and through the HTTP middleware under a unit of work: a failed commit leaves no record, so the retry under Enforce runs again and leaves one business row. Also covers a committed observe being replayed, a failing result write behind the savepoint, and ambient transactions at read committed and serializable.
… transaction

A failing rollback to the result savepoint no longer replaces the database error
that caused it, and a PostgreSQL connection that is not enlisted in the ambient
transaction writes the result without a savepoint instead of failing.
Cover the savepoint path on SQLite, and assert the aborted request explicitly.
@codecov

codecov Bot commented Oct 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...rkCore/Idempotency/EfCoreIdempotencyRecordStore.cs 95.55% 2 Missing ⚠️
...ls.AspNetCore/Idempotency/IdempotencyMiddleware.cs 85.71% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@senrecep
senrecep merged commit bdcbdd1 into main Oct 20, 2026
10 checks passed
@senrecep
senrecep deleted the fix/idempotency-observe-transaction branch October 20, 2026 09:51
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