Skip to content

Commit 8c72721

Browse files
authored
refactor: dedupe inbox registration, drop dead options validation, and cover retention paths (#349)
1 parent f340a3b commit 8c72721

19 files changed

Lines changed: 517 additions & 47 deletions

src/Vulthil.Messaging.Inbox.Cosmos/CosmosIdempotencyStore.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,34 @@ public async Task<int> DeleteProcessedAsync(DateTimeOffset olderThanUtc, int bat
3434
}
3535

3636
dbContext.InboxMessages.RemoveRange(markers);
37-
await dbContext.SaveChangesAsync(cancellationToken);
37+
await SaveRemovedMarkersAsync(cancellationToken);
3838
return markers.Count;
3939
}
4040

41+
/// <summary>
42+
/// Saves the pending removals, treating a concurrency conflict as progress rather than a failure: it means a
43+
/// concurrent sweeper already deleted the same marker, so the goal (the row being gone) is already achieved.
44+
/// </summary>
45+
private async Task SaveRemovedMarkersAsync(CancellationToken cancellationToken)
46+
{
47+
try
48+
{
49+
await dbContext.SaveChangesAsync(cancellationToken);
50+
}
51+
catch (DbUpdateConcurrencyException exception)
52+
{
53+
DetachConflictingEntries(exception);
54+
}
55+
}
56+
57+
private static void DetachConflictingEntries(DbUpdateConcurrencyException exception)
58+
{
59+
foreach (var entry in exception.Entries)
60+
{
61+
entry.State = EntityState.Detached;
62+
}
63+
}
64+
4165
public async Task<bool> ProcessAsync(string idempotencyKey, IMessageContext context, Func<CancellationToken, Task> process, CancellationToken cancellationToken)
4266
{
4367
ArgumentException.ThrowIfNullOrEmpty(idempotencyKey);

src/Vulthil.Messaging.Inbox.Cosmos/CosmosInboxExtensions.cs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,8 @@ public static IServiceCollection AddCosmosInbox<TContext>(
3030
{
3131
ArgumentNullException.ThrowIfNull(services);
3232

33-
services.TryAddSingleton(TimeProvider.System);
3433
services.TryAddScoped<IIdempotencyStore, CosmosIdempotencyStore<TContext>>();
35-
services.AddInboxRetention(configure);
36-
37-
var options = new InboxOptions();
38-
configure?.Invoke(options);
39-
if (options.EnableMetrics)
40-
{
41-
services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddVulthilInboxInstrumentation());
42-
}
34+
services.AddInboxCore(configure);
4335

4436
return services;
4537
}

src/Vulthil.Messaging.Inbox.Cosmos/Vulthil.Messaging.Inbox.Cosmos.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
<ItemGroup>
1414
<PackageReference Include="Microsoft.EntityFrameworkCore.Cosmos" />
15-
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
1615
</ItemGroup>
1716

1817
</Project>

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,8 @@ public static IServiceCollection AddRelationalInbox<TContext>(
3030
{
3131
ArgumentNullException.ThrowIfNull(services);
3232

33-
services.TryAddSingleton(TimeProvider.System);
3433
services.TryAddScoped<IIdempotencyStore, RelationalIdempotencyStore<TContext>>();
35-
services.AddInboxRetention(configure);
36-
37-
var options = new InboxOptions();
38-
configure?.Invoke(options);
39-
if (options.EnableMetrics)
40-
{
41-
services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddVulthilInboxInstrumentation());
42-
}
34+
services.AddInboxCore(configure);
4335

4436
return services;
4537
}

src/Vulthil.Messaging.Inbox.Relational/Vulthil.Messaging.Inbox.Relational.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
<ItemGroup>
1414
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
15-
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
1615
</ItemGroup>
1716

1817
</Project>

src/Vulthil.Messaging.Inbox/IdempotentConsumeFilter.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ internal sealed class IdempotentConsumeFilter<TMessage>(
2424
ILogger<IdempotentConsumeFilter<TMessage>> logger) : IConsumeFilter<TMessage>
2525
where TMessage : notnull
2626
{
27+
private static readonly string _messageType = typeof(TMessage).FullName ?? typeof(TMessage).Name;
28+
2729
private readonly IIdempotencyStore _store = store;
2830
private readonly IInboxKeySelector<TMessage> _keySelector = keySelector;
2931
private readonly InboxOptions _options = options.Value;
3032
private readonly ILogger<IdempotentConsumeFilter<TMessage>> _logger = logger;
3133

3234
public async Task ConsumeAsync(IMessageContext<TMessage> context, ConsumeDelegate<TMessage> next)
3335
{
34-
var messageType = typeof(TMessage).FullName ?? typeof(TMessage).Name;
3536
var key = _keySelector.GetKey(context);
3637
key = string.IsNullOrEmpty(key) ? context.MessageId : key;
3738

@@ -42,7 +43,7 @@ public async Task ConsumeAsync(IMessageContext<TMessage> context, ConsumeDelegat
4243
throw new MissingIdempotencyKeyException(typeof(TMessage));
4344
}
4445

45-
InboxLog.MissingKeyAllowed(_logger, messageType);
46+
InboxLog.MissingKeyAllowed(_logger, _messageType);
4647
InboxTelemetry.MissingKey.Add(1);
4748
await next(context);
4849
return;
@@ -56,7 +57,7 @@ public async Task ConsumeAsync(IMessageContext<TMessage> context, ConsumeDelegat
5657
}
5758
else
5859
{
59-
InboxLog.DuplicateSkipped(_logger, messageType, key);
60+
InboxLog.DuplicateSkipped(_logger, _messageType, key);
6061
InboxTelemetry.DuplicateSkipped.Add(1);
6162
}
6263
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
4+
namespace Vulthil.Messaging.Inbox;
5+
6+
/// <summary>
7+
/// Registration shared by every inbox store package (<c>AddRelationalInbox</c>, <c>AddCosmosInbox</c>). The store
8+
/// extensions differ only in which <see cref="IIdempotencyStore"/> implementation they register; the retention
9+
/// sweep, OpenTelemetry metrics, and <see cref="TimeProvider"/> registration are identical regardless of the store,
10+
/// so they live here once instead of being duplicated per store package. Internal, and visible to the store
11+
/// packages via <c>InternalsVisibleTo</c>.
12+
/// </summary>
13+
internal static class InboxCoreServiceCollectionExtensions
14+
{
15+
/// <summary>
16+
/// Registers everything an <c>AddXyzInbox</c> store extension needs beyond its own store type: the shared
17+
/// <see cref="TimeProvider"/>, the retention sweep (gated by <see cref="InboxRetentionOptions.Enabled"/>), and
18+
/// OpenTelemetry metrics (gated by <see cref="InboxOptions.EnableMetrics"/>). <paramref name="configure"/> is
19+
/// invoked exactly once here to evaluate both gates from a single materialized <see cref="InboxOptions"/>
20+
/// instance, plus once more when the options system resolves <see cref="Microsoft.Extensions.Options.IOptions{TOptions}"/>
21+
/// for injected consumers.
22+
/// </summary>
23+
internal static IServiceCollection AddInboxCore(this IServiceCollection services, Action<InboxOptions>? configure)
24+
{
25+
ArgumentNullException.ThrowIfNull(services);
26+
27+
var options = new InboxOptions();
28+
configure?.Invoke(options);
29+
30+
services.TryAddSingleton(TimeProvider.System);
31+
services.RegisterInboxRetention(configure, options);
32+
33+
if (options.EnableMetrics)
34+
{
35+
services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddVulthilInboxInstrumentation());
36+
}
37+
38+
return services;
39+
}
40+
}

src/Vulthil.Messaging.Inbox/InboxRetentionOptions.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using System.ComponentModel.DataAnnotations;
2-
31
namespace Vulthil.Messaging.Inbox;
42

53
/// <summary>
@@ -29,6 +27,5 @@ public sealed class InboxRetentionOptions
2927
/// Gets or sets the maximum number of markers deleted per batch within a sweep; a sweep keeps deleting batches
3028
/// until fewer than this many markers remain. Default is 1000.
3129
/// </summary>
32-
[Range(1, int.MaxValue)]
3330
public int BatchSize { get; set; } = 1000;
3431
}

src/Vulthil.Messaging.Inbox/InboxRetentionServiceCollectionExtensions.cs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
23

34
namespace Vulthil.Messaging.Inbox;
45

@@ -14,9 +15,31 @@ public static class InboxRetentionServiceCollectionExtensions
1415
/// do).
1516
/// </summary>
1617
/// <param name="services">The service collection.</param>
17-
/// <param name="configure">An optional action to configure <see cref="InboxOptions"/>.</param>
18+
/// <param name="configure">
19+
/// An optional action to configure <see cref="InboxOptions"/>. Invoked once eagerly (to evaluate
20+
/// <see cref="InboxRetentionOptions.Enabled"/>) and once more when the options system materializes
21+
/// <see cref="Microsoft.Extensions.Options.IOptions{TOptions}"/> for injected consumers.
22+
/// </param>
1823
/// <returns>The same service collection, for chaining.</returns>
19-
public static IServiceCollection AddInboxRetention(this IServiceCollection services, Action<InboxOptions>? configure)
24+
public static IServiceCollection AddInboxRetention(this IServiceCollection services, Action<InboxOptions>? configure = null)
25+
{
26+
ArgumentNullException.ThrowIfNull(services);
27+
28+
services.TryAddSingleton(TimeProvider.System);
29+
30+
var options = new InboxOptions();
31+
configure?.Invoke(options);
32+
33+
return services.RegisterInboxRetention(configure, options);
34+
}
35+
36+
/// <summary>
37+
/// Registers the options validation and the conditional hosted-service registration for the inbox retention
38+
/// sweep, using an already-materialized <paramref name="options"/> instance so a shared caller (e.g.
39+
/// <c>AddInboxCore</c>) does not need to invoke <paramref name="configure"/> a second time just to re-evaluate
40+
/// the <see cref="InboxRetentionOptions.Enabled"/> gate.
41+
/// </summary>
42+
internal static IServiceCollection RegisterInboxRetention(this IServiceCollection services, Action<InboxOptions>? configure, InboxOptions options)
2043
{
2144
services.AddOptions<InboxOptions>()
2245
.Configure(configure ?? (static _ => { }))
@@ -26,11 +49,8 @@ public static IServiceCollection AddInboxRetention(this IServiceCollection servi
2649
&& o.Retention.SweepInterval > TimeSpan.Zero
2750
&& o.Retention.BatchSize >= 1,
2851
"Inbox retention requires RetentionPeriod and SweepInterval greater than zero and BatchSize of at least 1 when enabled.")
29-
.ValidateDataAnnotations()
3052
.ValidateOnStart();
3153

32-
var options = new InboxOptions();
33-
configure?.Invoke(options);
3454
if (options.Retention.Enabled)
3555
{
3656
services.AddHostedService<InboxRetentionBackgroundService>();

src/Vulthil.Messaging.Inbox/PublicAPI.Shipped.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Vulthil.Messaging.Inbox.InboxTelemetry
55
Vulthil.Messaging.Inbox.MeterProviderBuilderExtensions
66
static readonly Vulthil.Messaging.Inbox.InboxTelemetry.MeterName -> string!
77
static Vulthil.Messaging.Inbox.MeterProviderBuilderExtensions.AddVulthilInboxInstrumentation(this OpenTelemetry.Metrics.MeterProviderBuilder! builder) -> OpenTelemetry.Metrics.MeterProviderBuilder!
8-
static Vulthil.Messaging.Inbox.InboxRetentionServiceCollectionExtensions.AddInboxRetention(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Vulthil.Messaging.Inbox.InboxOptions!>? configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
8+
static Vulthil.Messaging.Inbox.InboxRetentionServiceCollectionExtensions.AddInboxRetention(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Vulthil.Messaging.Inbox.InboxOptions!>? configure = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
99
Vulthil.Messaging.Inbox.IIdempotencyStore
1010
Vulthil.Messaging.Inbox.IIdempotencyStore.ProcessAsync(string! idempotencyKey, Vulthil.Messaging.Abstractions.Consumers.IMessageContext! context, System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task!>! process, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<bool>!
1111
Vulthil.Messaging.Inbox.IInboxKeySelector<TMessage>

0 commit comments

Comments
 (0)