Skip to content

Commit bc22461

Browse files
authored
Merge pull request #291 from Vulthil/fix/inbox-ambient-conflict
Fix inbox marker conflicts committing duplicate work inside ambient transactions
2 parents 7911e5c + 18a8f21 commit bc22461

6 files changed

Lines changed: 188 additions & 20 deletions

File tree

docs/articles/inbox-pattern.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ This is an *idempotent receiver*, not a store-and-forward inbox: it rides on top
1616

1717
If the consumer throws, the transaction is rolled back (marker included) and the message is reprocessed cleanly on redelivery. The marker is written **on commit, not on receipt**, so an interrupted delivery never leaves a marker that would suppress reprocessing.
1818

19-
The check (step 2) and the marker write (step 5) are serialized only at the **marker insert**, not across the whole unit. Two duplicates of the same key in flight *at the same time* — delivered to two consumers, or across two channels — can both pass the step-2 check and run the consumer body before either commits; the unique marker then lets only one commit, while the other fails the insert and is settled as a duplicate. So deduplication is exactly-once for *sequential* redelivery, but **concurrent** duplicates can each execute the handler body once. Keep side effects idempotent if concurrent duplicate delivery is possible.
19+
The check (step 2) and the marker write (step 5) are serialized only at the **marker insert**, not across the whole unit. Two duplicates of the same key in flight *at the same time* — delivered to two consumers, or across two channels — can both pass the step-2 check and run the consumer body before either commits; the unique marker then lets only one commit, while the other loses the insert race. So deduplication is exactly-once for *sequential* redelivery, but **concurrent** duplicates can each execute the handler body once. Keep side effects idempotent if concurrent duplicate delivery is possible.
20+
21+
How the loser is settled depends on who owns the transaction. When the relational store opens its own transaction (no outer filter already started one), it rolls back, rechecks the marker, and returns `false` — the delivery is settled as a duplicate inline, without an exception. When the store instead joins a transaction an outer filter already opened (see [Filter Registration Order](#filter-registration-order)), it cannot commit or roll back a transaction it doesn't own, so the marker conflict is **rethrown**. The transaction's owner rolls back — discarding this delivery's business writes along with the marker — and the message is redelivered; the step-2 check then deduplicates it. Either way no duplicate side effect is ever committed, but the ambient case surfaces as an exception and a redelivery round-trip rather than a quiet skip.
2022

2123
## Guarantees
2224

@@ -109,6 +111,19 @@ builder.Services.AddRelationalInbox<AppDbContext>();
109111

110112
The consumer and the store must share the same scoped `DbContext` instance (the default with `AddDbContext`), so the consumer's writes and the marker enlist in the same transaction. The consumer keeps calling `SaveChanges` as usual — the store owns the transaction, not `SaveChanges`. Add an EF Core migration for the `InboxMessage` table as you would for any entity.
111113

114+
### Filter Registration Order
115+
116+
`IConsumeFilter<TMessage>` instances compose in plain DI registration order — the first one resolved becomes the outermost — and nothing enforces a particular order between the inbox and other transactional filters, such as `Vulthil.Messaging.Outbox`'s transactional consumer filter. Register the idempotency filter **before** (outside) a transactional consumer filter:
117+
118+
```csharp
119+
messaging.AddIdempotentInbox<OrderPlaced>(context => context.Message.OrderId.ToString());
120+
messaging.AddTransactionalConsumer<OrderPlaced>();
121+
```
122+
123+
With this order the inbox filter runs first and opens its own transaction (the relational store's `ProcessAsync` sees no ambient transaction yet), and the transactional consumer filter joins it. On a concurrent duplicate the loser rolls back and rechecks inline — the efficient path described in [How It Works](#how-it-works).
124+
125+
Registering them in the opposite order still produces correct results — the relational store's ambient-conflict rethrow (above) guarantees a duplicate is never committed either way — but the transactional consumer filter becomes the transaction owner, so the inbox store always runs its ambient path. A concurrent duplicate then always surfaces as an exception and a redelivery, instead of the cheaper inline rollback-and-recheck. Prefer the order above.
126+
112127
### Cosmos store
113128

114129
The Cosmos store is wired the same way — apply the Cosmos mapping with `ApplyCosmosInbox()` and call `AddCosmosInbox<AppDbContext>()`:

src/Vulthil.Messaging.Inbox.Relational/RelationalIdempotencyStore.cs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ namespace Vulthil.Messaging.Inbox.Relational;
1515
/// (<c>Database.CreateExecutionStrategy().ExecuteAsync</c>), so it works whether or not a retrying execution strategy
1616
/// is configured — there is no need to disable EF Core retries. Under a retrying strategy a transient fault re-runs
1717
/// the unit (including the consumer) on a cleared change tracker, which is consistent with at-least-once redelivery.
18+
/// If an outer filter already opened the transaction, the store joins it instead of opening its own; in that case a
19+
/// concurrent marker conflict is rethrown rather than swallowed, since the store cannot commit or roll back a
20+
/// transaction it does not own. The exception propagates to the transaction's owner, which rolls back — including
21+
/// this delivery's business writes — and the transport redelivers the message, at which point the pre-check
22+
/// deduplicates it.
1823
/// </remarks>
1924
/// <typeparam name="TContext">The application's <see cref="DbContext"/> type, which must expose the inbox set.</typeparam>
2025
internal sealed class RelationalIdempotencyStore<TContext>(TContext dbContext, TimeProvider timeProvider)
@@ -97,21 +102,8 @@ private async Task<bool> ProcessInAmbientTransactionAsync(string idempotencyKey,
97102

98103
await process(cancellationToken);
99104
AddMarker(idempotencyKey);
100-
101-
try
102-
{
103-
await dbContext.SaveChangesAsync(cancellationToken);
104-
return true;
105-
}
106-
catch (DbUpdateException)
107-
{
108-
if (await HasProcessedAsync(idempotencyKey, cancellationToken))
109-
{
110-
return false;
111-
}
112-
113-
throw;
114-
}
105+
await dbContext.SaveChangesAsync(cancellationToken);
106+
return true;
115107
}
116108

117109
private Task<bool> HasProcessedAsync(string idempotencyKey, CancellationToken cancellationToken) =>

src/Vulthil.Messaging.Inbox/IInboxKeySelector.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ public interface IInboxKeySelector<in TMessage>
1111
where TMessage : notnull
1212
{
1313
/// <summary>
14-
/// Returns the idempotency key for the given delivery, or <see langword="null"/> to fall back to
15-
/// <see cref="IMessageContext.MessageId"/>.
14+
/// Returns the idempotency key for the given delivery, or <see langword="null"/> or an empty string to fall
15+
/// back to <see cref="IMessageContext.MessageId"/>.
1616
/// </summary>
1717
/// <param name="context">The message context for the current delivery.</param>
18-
/// <returns>The idempotency key, or <see langword="null"/> to use the message id.</returns>
18+
/// <returns>The idempotency key, or <see langword="null"/> or an empty string to use the message id.</returns>
1919
string? GetKey(IMessageContext<TMessage> context);
2020
}

src/Vulthil.Messaging.Inbox/IdempotentConsumeFilter.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ internal sealed class IdempotentConsumeFilter<TMessage>(
3232
public async Task ConsumeAsync(IMessageContext<TMessage> context, ConsumeDelegate<TMessage> next)
3333
{
3434
var messageType = typeof(TMessage).FullName ?? typeof(TMessage).Name;
35-
var key = _keySelector.GetKey(context) ?? context.MessageId;
35+
var key = _keySelector.GetKey(context);
36+
key = string.IsNullOrEmpty(key) ? context.MessageId : key;
3637

3738
if (string.IsNullOrEmpty(key))
3839
{
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using Microsoft.Data.Sqlite;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Storage;
4+
using Vulthil.Messaging.Abstractions.Consumers;
5+
using Vulthil.Messaging.Inbox.EntityFrameworkCore;
6+
using Vulthil.xUnit;
7+
8+
namespace Vulthil.Messaging.Inbox.Relational.Tests;
9+
10+
public sealed class RelationalIdempotencyStoreConflictTests : BaseUnitTestCase
11+
{
12+
private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"vulthil-inbox-conflict-{Guid.NewGuid():N}.db");
13+
14+
protected override async ValueTask Initialize()
15+
{
16+
await using var context = NewContext();
17+
await context.Database.EnsureCreatedAsync(CancellationToken);
18+
}
19+
20+
protected override ValueTask Dispose()
21+
{
22+
SqliteConnection.ClearAllPools();
23+
File.Delete(_databasePath);
24+
return ValueTask.CompletedTask;
25+
}
26+
27+
[Fact]
28+
public async Task ConcurrentDeliveriesForTheSameKeyLetExactlyOneCommit()
29+
{
30+
// Arrange
31+
await using var contextA = NewContext();
32+
await using var contextB = NewContext();
33+
34+
// Act — SQLite serializes the two own-transaction attempts under the hood (a shared busy timeout
35+
// resolves the lock contention instead of failing fast), so of two genuinely concurrent deliveries
36+
// for the same key, exactly one commits its business write and marker; the loser observes the
37+
// marker as already processed and never runs its consumer body.
38+
var taskA = CreateStore(contextA).ProcessAsync("race-key", MessageContext, async token =>
39+
{
40+
contextA.Things.Add(new Thing { Id = 1, Name = "a" });
41+
await contextA.SaveChangesAsync(token);
42+
}, CancellationToken);
43+
44+
var taskB = CreateStore(contextB).ProcessAsync("race-key", MessageContext, async token =>
45+
{
46+
contextB.Things.Add(new Thing { Id = 2, Name = "b" });
47+
await contextB.SaveChangesAsync(token);
48+
}, CancellationToken);
49+
50+
var results = await Task.WhenAll(taskA, taskB);
51+
52+
// Assert
53+
results.Count(processed => processed).ShouldBe(1);
54+
await using var verify = NewContext();
55+
(await verify.Things.CountAsync(CancellationToken)).ShouldBe(1);
56+
(await verify.InboxMessages.CountAsync(CancellationToken)).ShouldBe(1);
57+
}
58+
59+
[Fact]
60+
public async Task AmbientTransactionConflictPropagatesInsteadOfCommittingDuplicateWork()
61+
{
62+
// Act
63+
var propagated = await RunAmbientConflictScenarioAsync();
64+
65+
// Assert — a marker conflict inside an ambient transaction must never let the caller commit this
66+
// delivery's business write: it either surfaces so the caller rolls back (fixed) or is silently
67+
// absorbed while the caller commits anyway (the defect this pins).
68+
propagated.ShouldNotBeNull();
69+
await using var verify = NewContext();
70+
(await verify.Things.AnyAsync(thing => thing.Name == "losing-consumer-write", CancellationToken)).ShouldBeFalse();
71+
(await verify.InboxMessages.AnyAsync(CancellationToken)).ShouldBeFalse();
72+
}
73+
74+
private async Task<DbUpdateException?> RunAmbientConflictScenarioAsync()
75+
{
76+
await using var outerContext = NewContext();
77+
await using var outerTransaction = await outerContext.Database.BeginTransactionAsync(CancellationToken);
78+
var store = CreateStore(outerContext);
79+
80+
try
81+
{
82+
await store.ProcessAsync("race-key", MessageContext, async token =>
83+
{
84+
outerContext.Things.Add(new Thing { Id = 1, Name = "losing-consumer-write" });
85+
await outerContext.SaveChangesAsync(token);
86+
87+
await InsertConflictingMarkerOnTheSameTransactionAsync(outerContext, outerTransaction, token);
88+
}, CancellationToken);
89+
90+
await outerTransaction.CommitAsync(CancellationToken);
91+
return null;
92+
}
93+
catch (DbUpdateException exception)
94+
{
95+
await outerTransaction.RollbackAsync(CancellationToken);
96+
return exception;
97+
}
98+
}
99+
100+
private static async Task InsertConflictingMarkerOnTheSameTransactionAsync(
101+
TestDbContext outerContext, IDbContextTransaction outerTransaction, CancellationToken cancellationToken)
102+
{
103+
await using var conflictContext = new TestDbContext(
104+
new DbContextOptionsBuilder<TestDbContext>().UseSqlite(outerContext.Database.GetDbConnection()).Options);
105+
await conflictContext.Database.UseTransactionAsync(outerTransaction.GetDbTransaction(), cancellationToken);
106+
conflictContext.InboxMessages.Add(new InboxMessage { MessageId = "race-key", ProcessedOnUtc = TimeProvider.System.GetUtcNow() });
107+
await conflictContext.SaveChangesAsync(cancellationToken);
108+
}
109+
110+
private static IMessageContext MessageContext => Mock.Of<IMessageContext>();
111+
112+
private TestDbContext NewContext() =>
113+
new(new DbContextOptionsBuilder<TestDbContext>()
114+
.UseSqlite(new SqliteConnectionStringBuilder { DataSource = _databasePath, DefaultTimeout = 5 }.ToString())
115+
.Options);
116+
117+
private static RelationalIdempotencyStore<TestDbContext> CreateStore(TestDbContext context) =>
118+
new(context, TimeProvider.System);
119+
120+
public sealed class TestDbContext(DbContextOptions<TestDbContext> options) : DbContext(options), ISaveInboxMessages
121+
{
122+
public DbSet<InboxMessage> InboxMessages => Set<InboxMessage>();
123+
124+
public DbSet<Thing> Things => Set<Thing>();
125+
126+
protected override void OnModelCreating(ModelBuilder modelBuilder)
127+
{
128+
ArgumentNullException.ThrowIfNull(modelBuilder);
129+
modelBuilder.ApplyRelationalInbox();
130+
modelBuilder.Entity<InboxMessage>().Property(message => message.ProcessedOnUtc)
131+
.HasConversion(value => value.UtcDateTime, value => new DateTimeOffset(value, TimeSpan.Zero));
132+
modelBuilder.Entity<Thing>().HasKey(thing => thing.Id);
133+
}
134+
}
135+
136+
public sealed class Thing
137+
{
138+
public int Id { get; set; }
139+
140+
public required string Name { get; set; }
141+
}
142+
}

tests/Vulthil.Messaging.Inbox.Tests/IdempotentConsumeFilterTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ public async Task MissingKeyWithRejectDisabledInvokesConsumerWithoutStore()
8080
_store.Verify(store => store.ProcessAsync(It.IsAny<string>(), It.IsAny<IMessageContext>(), It.IsAny<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()), Times.Never);
8181
}
8282

83+
[Fact]
84+
public async Task EmptyStringKeySelectorResultFallsBackToMessageId()
85+
{
86+
// Arrange
87+
_context.SetupGet(context => context.MessageId).Returns("message-1");
88+
GetMock<IInboxKeySelector<TestMessage>>()
89+
.Setup(selector => selector.GetKey(It.IsAny<IMessageContext<TestMessage>>()))
90+
.Returns(string.Empty);
91+
SetupStore("message-1", runConsumer: true, processed: true);
92+
93+
// Act
94+
await Target.ConsumeAsync(_context.Object, Next);
95+
96+
// Assert
97+
_consumerInvoked.ShouldBeTrue();
98+
_store.Verify(store => store.ProcessAsync("message-1", It.IsAny<IMessageContext>(), It.IsAny<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()), Times.Once);
99+
}
100+
83101
[Fact]
84102
public async Task CustomKeySelectorIsUsedForDeduplication()
85103
{

0 commit comments

Comments
 (0)