Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions samples/BareWire.Samples.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@
.WithLifetime(ContainerLifetime.Session)
.WithManagementPlugin();

// Enable PostgreSQL prepared transactions (2-phase commit) via max_prepared_transactions.
// System.Transactions.TransactionScope — used by the BareWire transactional outbox middleware —
// escalates to 2PC when a consumer enlists a SECOND database connection inside the consume
// transaction (e.g. the OrderedConsumers sample persists a ProcessedRecord via its own DbContext
// alongside the outbox/inbox writes). PostgreSQL ships with max_prepared_transactions=0 (2PC
// disabled), which makes such a consume abort with SqlState 55000 ("prepared transactions are
// disabled") and dead-letter every message. A small nonzero pool enables the atomic commit.
// No max_prepared_transactions configuration is needed. Every transactional-outbox sample shares the
// outbox middleware's pinned connection for its consumer business write (via IOutboxConnectionAccessor),
// so each consume commits single-phase — no second connection enlists, and System.Transactions.TransactionScope
// never escalates to a two-phase (prepared) commit. PostgreSQL therefore runs with its default
// max_prepared_transactions=0 (2PC disabled).
var postgresServer = builder.AddPostgres("postgres")
.WithLifetime(ContainerLifetime.Session)
.WithArgs("-c", "max_prepared_transactions=100");
.WithLifetime(ContainerLifetime.Session);

var postgres = postgresServer.AddDatabase("barewiredb");

Expand Down
23 changes: 22 additions & 1 deletion samples/BareWire.Samples.InboxDeduplication/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,28 @@
// 3. EF Core — application DbContext for notification log entities
// ─────────────────────────────────────────────────────────────────────────────

builder.Services.AddDbContext<NotificationDbContext>(o => o.UseNpgsql(dbConnectionString));
// Single-commit connection sharing: while the transactional outbox middleware processes a message it
// has pinned ONE connection (exposed via IOutboxConnectionAccessor). Both consumers below persist a
// NotificationLog through THIS context — share the pinned connection so each write commits single-phase
// with the inbox marker — one physical connection, one enlistment, no escalation to a two-phase
// (prepared) commit, and no max_prepared_transactions requirement. Outside a consume operation (startup
// schema init, the HTTP endpoints below) the accessor returns null, so we fall back to a standalone
// connection. The (sp, options) overload makes the EF options Scoped, so each per-message consumer scope
// binds to the live pinned connection at resolution time.
builder.Services.AddDbContext<NotificationDbContext>((sp, o) =>
{
System.Data.Common.DbConnection? sharedOutboxConnection =
sp.GetRequiredService<IOutboxConnectionAccessor>().Current;

if (sharedOutboxConnection is not null)
{
o.UseNpgsql(sharedOutboxConnection);
}
else
{
o.UseNpgsql(dbConnectionString);
}
});

// ─────────────────────────────────────────────────────────────────────────────
// 4. BareWire messaging — serializer, transport, topology, endpoints
Expand Down
35 changes: 28 additions & 7 deletions samples/BareWire.Samples.OrderedConsumers/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,34 @@
// 3. EF Core — application DbContext for processed-record log
// ─────────────────────────────────────────────────────────────────────────────

// NOTE (PostgreSQL 2PC): the consumers below persist a ProcessedRecord through THIS DbContext while
// the transactional outbox middleware holds an ambient TransactionScope on its own OutboxDbContext.
// That second connection makes the scope escalate to a two-phase (prepared) commit, which requires
// max_prepared_transactions > 0 on PostgreSQL (disabled by default → consume aborts with SqlState
// 55000 and every message is dead-lettered). The Aspire AppHost sets it; a manual Postgres container
// needs `-c max_prepared_transactions=100`. See BareWire.Outbox.EntityFramework/README.md.
builder.Services.AddDbContext<OrderedConsumersDbContext>(o => o.UseNpgsql(dbConnectionString));
// SINGLE-COMMIT connection sharing: the consumers below persist a ProcessedRecord through THIS
// DbContext while the transactional outbox middleware holds an ambient TransactionScope on its own
// OutboxDbContext. If this context opened its OWN connection, the scope would enlist two connections
// and escalate to a two-phase (prepared) commit — slower, and disabled by default on PostgreSQL
// (max_prepared_transactions=0 → SqlState 55000, every message dead-lettered).
//
// Instead we share the SINGLE connection the outbox middleware has already pinned for the in-flight
// message (exposed via IOutboxConnectionAccessor). One physical connection → one enlistment → a
// single-phase commit of the business write + outbox messages + inbox marker. No 2PC, no
// max_prepared_transactions requirement. The accessor returns null outside a consume operation
// (startup schema init, the HTTP endpoints below), so we fall back to a standalone connection there.
//
// The (sp, options) overload makes the EF options Scoped — rebuilt per DI scope — so each per-message
// consumer scope reads the accessor at resolution time and binds to the live pinned connection.
builder.Services.AddDbContext<OrderedConsumersDbContext>((sp, o) =>
{
System.Data.Common.DbConnection? sharedOutboxConnection =
sp.GetRequiredService<IOutboxConnectionAccessor>().Current;

if (sharedOutboxConnection is not null)
{
o.UseNpgsql(sharedOutboxConnection);
}
else
{
o.UseNpgsql(dbConnectionString);
}
});

// No singleton PoisonKeyHolder needed — the poison-head indicator is stamped as a
// transport header ("poison-head-demo: true") on seq=0 of the poison key only. This
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ public async Task ConsumeAsync(ConsumeContext<TransferInitiated> context)
await Task.Delay(millisecondsDelay: 50, context.CancellationToken).ConfigureAwait(false);

// Update the transfer status in the same transaction managed by TransactionalOutboxMiddleware.
// The OutboxDbContext and TransferDbContext share the same connection string,
// so both participate in the same ambient TransactionScope.
// This TransferDbContext shares the SAME physical connection the middleware pinned for this
// message (via IOutboxConnectionAccessor — see Program.cs), so the UPDATE commits single-phase
// with the outbox + inbox writes: one connection, one enlistment, no two-phase (prepared) commit.
Transfer? transfer = await dbContext.Transfers
.FirstOrDefaultAsync(t => t.TransferId == message.TransferId, context.CancellationToken)
.ConfigureAwait(false);
Expand Down
22 changes: 21 additions & 1 deletion samples/BareWire.Samples.TransactionalOutbox/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,27 @@
// 3. EF Core — application DbContext for Transfer entities
// ─────────────────────────────────────────────────────────────────────────────

builder.Services.AddDbContext<TransferDbContext>(o => o.UseNpgsql(dbConnectionString));
// Single-commit connection sharing: while the transactional outbox middleware processes a message it
// has pinned ONE connection (exposed via IOutboxConnectionAccessor). Share it so the consumer's UPDATE
// commits single-phase with the outbox + inbox writes — one physical connection, one enlistment, no
// escalation to a two-phase (prepared) commit, and no max_prepared_transactions requirement. Outside a
// consume operation (startup schema init, the HTTP endpoints below) the accessor returns null, so we
// fall back to a standalone connection. The (sp, options) overload makes the EF options Scoped, so each
// per-message consumer scope binds to the live pinned connection at resolution time.
builder.Services.AddDbContext<TransferDbContext>((sp, o) =>
{
System.Data.Common.DbConnection? sharedOutboxConnection =
sp.GetRequiredService<IOutboxConnectionAccessor>().Current;

if (sharedOutboxConnection is not null)
{
o.UseNpgsql(sharedOutboxConnection);
}
else
{
o.UseNpgsql(dbConnectionString);
}
});

// ─────────────────────────────────────────────────────────────────────────────
// 4. BareWire messaging — serializer, transport, topology, endpoints
Expand Down
22 changes: 14 additions & 8 deletions samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ If you prefer to run a sample individually, start RabbitMQ and PostgreSQL first:

```bash
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:management
docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=barewiredb postgres -c max_prepared_transactions=100
docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=barewiredb postgres
```

> **Why `-c max_prepared_transactions=100`?** The transactional-outbox samples wrap each consume in a
> `System.Transactions.TransactionScope`. When a consumer also persists business state through **its own**
> `DbContext` (a second database connection) — as `OrderedConsumers` does with its `ProcessedRecord` log —
> the scope escalates to a two-phase (prepared) commit. PostgreSQL ships with `max_prepared_transactions=0`
> (2PC disabled), so without this flag those consumes abort with `55000: prepared transactions are disabled`
> and every message is dead-lettered. The Aspire AppHost sets this automatically; a manual container needs
> it explicitly. See the [transactional outbox limitation](../src/BareWire.Outbox.EntityFramework/README.md).
> **No `max_prepared_transactions` needed.** A consumer that persists its own business state while the
> transactional outbox middleware holds an ambient `System.Transactions.TransactionScope` would normally
> open a **second** database connection, escalating the commit to a two-phase (prepared) commit — which
> PostgreSQL disables by default (`max_prepared_transactions=0` → `55000: prepared transactions are
> disabled`). Every outbox sample here avoids that by **sharing the outbox's pinned connection** for the
> consumer write (via `IOutboxConnectionAccessor`), so each consume commits **single-phase**: faster, and
> with no `max_prepared_transactions` requirement. See the
> [single-commit vs 2PC guidance](../src/BareWire.Outbox.EntityFramework/README.md).

Then run the sample:

Expand Down Expand Up @@ -132,6 +133,11 @@ GET /events/processing-log — verify per-correlation ordering
End-to-end per-key consumer ordering with competing consumer instances (Aspire `WithReplicas(2)`),
transactional outbox (`OrderingMode.PerKey`), and poison-head parking via DLX.

The consumers persist a `ProcessedRecord` through the **same connection the outbox middleware pinned**
for the in-flight message (via `IOutboxConnectionAccessor`), so the business write commits **single-phase**
with the outbox and inbox writes — one commit, no two-phase (prepared) commit, and no
`max_prepared_transactions` requirement on PostgreSQL.

Demonstrates two ordering tiers (ADR-026):

1. **Cross-instance (SAC)** — `ordered-processing` queue with `x-single-active-consumer`. RabbitMQ
Expand Down
47 changes: 47 additions & 0 deletions src/BareWire.Outbox.EntityFramework/IOutboxConnectionAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Data.Common;

namespace BareWire.Outbox.EntityFramework;

/// <summary>
/// Exposes the database connection that the transactional outbox middleware has pinned for the
/// message currently being consumed on the active asynchronous flow.
/// </summary>
/// <remarks>
/// <para>
/// The transactional outbox middleware opens a single physical connection for the lifetime of a
/// consume operation and enlists it once in the ambient transaction. A consumer can persist its
/// own business state through that <em>same</em> connection — instead of opening a second one — so
/// that the business write, the outbox messages, and the inbox processed marker all commit as a
/// single-phase commit. Sharing one connection avoids escalation to a two-phase (prepared) commit,
/// which is both faster and free of the PostgreSQL <c>max_prepared_transactions</c> requirement.
/// </para>
/// <para>
/// Typical usage is to configure a consumer's <c>DbContext</c> to use <see cref="Current"/> when it
/// is non-<see langword="null"/> and fall back to its own connection otherwise (startup schema
/// initialization, HTTP request handlers, or any path that runs outside a consume operation):
/// </para>
/// <code>
/// services.AddDbContext&lt;MyDbContext&gt;((sp, options) =&gt;
/// {
/// DbConnection? shared = sp.GetRequiredService&lt;IOutboxConnectionAccessor&gt;().Current;
/// if (shared is not null)
/// options.UseNpgsql(shared); // share the outbox connection → single commit
/// else
/// options.UseNpgsql(connectionString); // standalone connection
/// });
/// </code>
/// <para>
/// The accessor is registered as a singleton by <see cref="ServiceCollectionExtensions.AddBareWireOutbox"/>.
/// It is backed by an asynchronous-flow-local value, so <see cref="Current"/> reflects the
/// connection pinned by the outbox middleware on the caller's logical execution context.
/// </para>
/// </remarks>
public interface IOutboxConnectionAccessor
{
/// <summary>
/// Gets the open <see cref="DbConnection"/> the transactional outbox middleware has pinned for
/// the in-flight consume operation on the current asynchronous flow, or <see langword="null"/>
/// when no outbox consume operation is in progress.
/// </summary>
DbConnection? Current { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Data.Common;

namespace BareWire.Outbox.EntityFramework.Internal;

/// <summary>
/// Default <see cref="IOutboxConnectionAccessor"/> implementation. Reads the connection pinned by
/// <see cref="TransactionalOutboxMiddleware"/> on the current asynchronous flow. Stateless and
/// thread-safe — safe to register as a singleton.
/// </summary>
internal sealed class OutboxConnectionAccessor : IOutboxConnectionAccessor
{
public DbConnection? Current => TransactionalOutboxMiddleware.CurrentConnection;
}
61 changes: 43 additions & 18 deletions src/BareWire.Outbox.EntityFramework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,51 @@ The inbox `ProcessedAt` marker is committed **atomically** within the same trans
consumer's business state and buffered outbox messages, so a crash after the business commit
cannot leave a message reprocessable (see ADR-033).

### PostgreSQL: prepared transactions (2PC) requirement
### PostgreSQL: consumer business writes — single-commit vs 2PC

The atomic commit above uses a `System.Transactions.TransactionScope`. The middleware pins **one**
physical connection for its own inbox/outbox writes, so the common case stays single-connection (no
two-phase commit). However, if your **consumer also persists business state through a separate
`DbContext`** (a second database connection enlisted in the same scope), `TransactionScope` escalates
to a **two-phase (prepared) commit**. PostgreSQL ships with `max_prepared_transactions = 0` (prepared
transactions disabled), so such a consume aborts with `55000: prepared transactions are disabled` and
the message is retried until dead-lettered.

To support a consumer that writes to its own `DbContext` under the transactional outbox on PostgreSQL,
either:

- set a nonzero `max_prepared_transactions` on the server (e.g. start Postgres with
`-c max_prepared_transactions=100`), enabling 2PC; or
- have the consumer persist through a connection **shared** with `OutboxDbContext` (single connection →
no escalation).

The BareWire samples take the first option — the Aspire AppHost configures
`max_prepared_transactions` automatically (see `samples/README.md`).
physical connection for its own inbox/outbox writes, so the common case stays single-connection. But a
frequent pattern is for the **consumer to also persist business state through its own `DbContext`**
inside the same transaction. How that second write enlists decides whether the commit is one phase or
two:

- **Two physical connections → two-phase (prepared) commit.** If the consumer's `DbContext` opens its
own connection, `TransactionScope` enlists two resources and escalates to a 2PC. PostgreSQL ships with
`max_prepared_transactions = 0` (prepared transactions disabled), so the consume aborts with
`55000: prepared transactions are disabled` and the message is retried until dead-lettered. Enabling it
(start Postgres with `-c max_prepared_transactions=100`) makes it work, but a prepared commit is also
**slower** — an extra `PREPARE` / `COMMIT PREPARED` round-trip and fsync per message.

- **One shared connection → single-phase commit (recommended).** Have the consumer's `DbContext` use the
**same** connection the middleware already pinned for the in-flight message, exposed via
`IOutboxConnectionAccessor`. One physical connection enlists exactly once, so the business write, the
buffered outbox messages, and the inbox marker all commit in a single local transaction — **faster**,
and with **no** `max_prepared_transactions` requirement at all.

Wire the consumer's `DbContext` to prefer the shared connection, falling back to a standalone connection
outside a consume operation (startup schema creation, HTTP request handlers, background jobs):

```csharp
services.AddDbContext<MyConsumerDbContext>((sp, options) =>
{
// System.Data.Common.DbConnection — non-null only while the outbox middleware is processing
// a message on the current async flow; null on startup / HTTP / background paths.
DbConnection? shared = sp.GetRequiredService<IOutboxConnectionAccessor>().Current;
if (shared is not null)
options.UseNpgsql(shared); // share the outbox connection → single-phase commit
else
options.UseNpgsql(connectionString); // standalone connection
});
```

> Use the `(IServiceProvider, DbContextOptionsBuilder)` overload so EF builds the options **per scope** —
> each per-message consumer scope then binds to the live pinned connection. The consumer keeps calling
> `SaveChangesAsync()` as usual; because it runs inside the middleware's `TransactionScope`, its write
> commits atomically with the outbox and inbox writes — now as one single-phase commit.

Every BareWire outbox sample whose consumer persists business state — `OrderedConsumers`,
`TransactionalOutbox`, `InboxDeduplication` — uses this single-commit pattern, so the samples need no 2PC
and the Aspire AppHost runs PostgreSQL with the default `max_prepared_transactions = 0`.

## Horizontal Scaling and Row Claims

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public static IServiceCollection AddBareWireOutbox(

services.AddSingleton(instanceId);

// Exposes the connection the transactional middleware pins per consume operation so a consumer
// DbContext can share it (single-phase commit instead of a two-phase prepared commit). Stateless
// singleton over an async-flow-local — see IOutboxConnectionAccessor for the consumer wiring.
services.TryAddSingleton<IOutboxConnectionAccessor, OutboxConnectionAccessor>();

// Default outbox claim dialect: PostgreSQL (FOR UPDATE SKIP LOCKED). The store invokes a
// dialect only when its IOutboxSqlDialect.ProviderName matches the active EF Core provider,
// so this default is used on PostgreSQL and is inert elsewhere. To get an atomic claim on
Expand Down
Loading