Skip to content

fix(outbox): provider-aware PerKey guard + OrderedConsumers sample 2PC fix - #38

Merged
asawicki merged 2 commits into
mainfrom
asawicki/fix/outbox-hardening-and-sample-2pc
Jun 28, 2026
Merged

fix(outbox): provider-aware PerKey guard + OrderedConsumers sample 2PC fix#38
asawicki merged 2 commits into
mainfrom
asawicki/fix/outbox-hardening-and-sample-2pc

Conversation

@asawicki

Copy link
Copy Markdown
Collaborator

Summary

Two related changes in the outbox area, hardening the transactional outbox and fixing the
OrderedConsumers sample so it actually delivers end to end.

1. Outbox hardening (fix(outbox): …)

  • Provider-aware PerKey ordering guard. The startup guard now enforces a dialect's
    head-of-line capability only when that dialect is the active claim path (the active EF Core
    provider matches the dialect ProviderName). When another provider is active, the store uses the
    client-side fallback claim, which enforces per-key head-of-line ordering itself — so a valid
    single-instance deployment is no longer falsely rejected at startup or forced to set
    AllowDegradedOrdering.
  • Cleanup startup gating. OutboxCleanupService's destructive DELETEs are gated on
    IHostApplicationLifetime.ApplicationStarted, mirroring the dispatcher, so cleanup never runs from
    a process that never became healthy.
  • Drain-loop pacing. The dispatcher paces with a relative Task.Delay and a burst cap (instead
    of a fixed timer grid) and gates its first poll on ApplicationStarted.
  • Explicit capability flag. IOutboxSqlDialect.SupportsPerKeyHeadOfLineOrdering is an explicit
    declaration (with a flag-plus-SQL consistency check); PostgresOutboxSqlDialect declares it.
  • AllowDegradedOrdering opt-out downgrades the PerKey fail-fast to a warning.
  • Documents the custom-dialect trust boundary and the PostgreSQL prepared-transactions (2PC)
    requirement in the outbox README.

2. OrderedConsumers sample fix (fix(samples): …)

Root cause: the OrderedConsumers consumer persists a ProcessedRecord through its own
DbContext inside the transactional outbox middleware's TransactionScope. That second database
connection makes the scope escalate to a two-phase (prepared) commit, which PostgreSQL rejects by
default (max_prepared_transactions=0, SqlState 55000). Every message was retried and
dead-lettered, and no record was written — pre-existing, independent of the hardening above.

Fix:

  • AppHost starts PostgreSQL with -c max_prepared_transactions=100.
  • The single-sample README command is corrected (it had the wrong password and created no matching
    database) and gains the prepared-transactions flag, matching the samples' default connection string.
  • The 2PC requirement is documented at the consumer's DbContext registration.

Verification

  • Unit: 2039 passing; Contract (public API + layer rules): 36 passing.
  • Outbox PerKey full-circuit E2E (real PostgreSQL + RabbitMQ) passing.
  • All three sample smoke E2E tests passing (OrderedConsumers, CompetingResponders,
    ConsumerRoutingKeys). Before the fix OrderedConsumers dead-lettered all 20 messages; after it,
    the SAC endpoint records 15 healthy + 4 poison-resume per run and only the poison head is parked.
  • Standalone run reproduced and confirmed the fix verbatim against the sample defaults.

asawicki added 2 commits June 28, 2026 16:16
…tup gating

- PerKey ordering startup guard is now provider-aware: it enforces the dialect's
  head-of-line capability only when that dialect is the active claim path (the active
  EF Core provider matches the dialect ProviderName). Providers that use the client-side
  fallback claim enforce ordering themselves, so a valid single-instance deployment is no
  longer falsely rejected at startup or forced to set AllowDegradedOrdering.
- Gate OutboxCleanupService's destructive DELETEs on IHostApplicationLifetime.ApplicationStarted,
  mirroring the dispatcher, so cleanup never runs from a process that never became healthy.
- Dispatcher drain loop paces with a relative Task.Delay and a burst cap instead of a fixed timer
  grid, and gates its first poll on ApplicationStarted.
- Add an explicit SupportsPerKeyHeadOfLineOrdering capability flag to IOutboxSqlDialect with a
  flag-plus-SQL consistency check, and declare it on PostgresOutboxSqlDialect.
- Add the AllowDegradedOrdering opt-out to downgrade the PerKey fail-fast to a warning.
- Document the custom-dialect trust boundary and the PostgreSQL prepared-transactions (2PC)
  requirement in the outbox README.
…essed records

The OrderedConsumers consumer persists a ProcessedRecord through its own DbContext inside the
transactional outbox middleware's TransactionScope. That second database connection makes the
scope escalate to a two-phase (prepared) commit, which PostgreSQL rejects by default
(max_prepared_transactions=0, SqlState 55000 "prepared transactions are disabled"). Every message
was retried and dead-lettered, and no ProcessedRecord was written.

- AppHost: start PostgreSQL with -c max_prepared_transactions=100.
- README: correct the single-sample docker command (it set the wrong password and created no
  matching database) and add the prepared-transactions flag, so it matches the samples' default
  connection string.
- Document the 2PC requirement at the OrderedConsumers DbContext registration.

Verified end to end: the OrderedConsumers smoke test passes, and a standalone run produces the
expected per-key records with only the poison head dead-lettered.
@asawicki
asawicki merged commit 9f58e46 into main Jun 28, 2026
1 check passed
@asawicki
asawicki deleted the asawicki/fix/outbox-hardening-and-sample-2pc branch June 28, 2026 20: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