Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
79d0291
test: Added Integration Tests for Outbox
samtrion Apr 10, 2026
675af0c
refactor: outbox transport, EF tests, and SQLite storage
samtrion Apr 10, 2026
f4c73e8
fix: Remove extraneous '-' from Directory.Packages.props
samtrion Apr 10, 2026
e338f96
fix: Remove redundant assertions from NullMessageTransportTests
samtrion Apr 10, 2026
f0a80cc
fix: Update src/NetEvolve.Pulse/Outbox/NullMessageTransport.cs
samtrion Apr 10, 2026
985518b
fix: Update tests/NetEvolve.Pulse.Tests.Integration/Internals/InMemor…
samtrion Apr 10, 2026
1969b11
test: Add comprehensive EF Outbox integration tests
samtrion Apr 10, 2026
7d2db6a
test: Support EF Core InMemory provider for Outbox repository
samtrion Apr 10, 2026
3418b20
refactor(test): DB setup for isolation and reliability
samtrion Apr 12, 2026
c4e13a0
fix: Reformatted testhelper
samtrion Apr 12, 2026
ab7756b
fix(test): Use correct TestGroup
samtrion Apr 12, 2026
b5cd16f
test: Add PostgreSQL integration tests and index name fixes
samtrion Apr 12, 2026
b4828e9
test: Add CancellationToken to all async test methods
samtrion Apr 12, 2026
1de2405
fix(test): Improve SQL Server test DB setup and update outbox tests
samtrion Apr 12, 2026
d6f94d7
refactor(test): DB container management and sharing
samtrion Apr 12, 2026
ae53067
fix: Skip processing loop when disabled
samtrion Apr 12, 2026
4cd6e8b
refactor: clarify tracking vs bulk ops in EF outbox repo
samtrion Apr 12, 2026
8964d6a
refactor: Disable EF Core service provider caching in tests
samtrion Apr 12, 2026
6a9da0d
chore: Use custom model cache key for per-test table names
samtrion Apr 12, 2026
f72600d
fix: Move disable processing check inside try block
samtrion Apr 12, 2026
c1b1bc7
feat: Add Oracle MySQL EF Core provider support for Outbox
samtrion Apr 13, 2026
e517887
fix: Centralize EF Core provider names and improve executor config
samtrion Apr 13, 2026
53c3b65
chore(deps): Upgraded `NetEvolve.Defaults` to `2.3.0`
samtrion Apr 13, 2026
b60a312
refactor: test method signatures to remove unused CancellationToken p…
samtrion Apr 13, 2026
64b06ae
chore: Improve test infra: MySQL options, cancellation, timeouts
samtrion Apr 13, 2026
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
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ dotnet_diagnostic.IDE0046.severity = sugges
csharp_style_prefer_primary_constructors = false
dotnet_diagnostic.IDE0290.severity = suggestion

# IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = warning
dotnet_code_quality_unused_parameters = all

# [CSharpier] Incompatible rules deactivated
# https://csharpier.com/docs/IntegratingWithLinters#code-analysis-rules
dotnet_diagnostic.IDE0055.severity = none
Expand Down
21 changes: 17 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,44 +30,57 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.4.0" />
<PackageVersion Include="MySql.EntityFrameworkCore" Version="10.0.1" />
<PackageVersion Include="NetEvolve.CodeBuilder" Version="1.1.28" />
<PackageVersion Include="NetEvolve.Extensions.TUnit" Version="3.5.238" />
<PackageVersion Include="NetEvolve.Http.Correlation.Abstractions" Version="3.1.23" />
<PackageVersion Include="NetEvolve.Http.Correlation.AspNetCore" Version="3.1.23" />
<PackageVersion Include="NetEvolve.Http.Correlation.TestGenerator" Version="3.1.23" />
<PackageVersion Include="Npgsql" Version="10.0.2" />
<PackageVersion Include="Polly.Core" Version="8.6.6" />
<PackageVersion Include="RabbitMQ.Client" Version="7.2.1" />
<PackageVersion Include="Testcontainers" Version="4.11.0" />
<PackageVersion Include="Testcontainers.Kafka" Version="4.11.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.11.0" />
<PackageVersion Include="Testcontainers.MySql" Version="4.11.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.11.0" />
<PackageVersion Include="TUnit" Version="1.33.0" />
<PackageVersion Include="TUnit.Mocks" Version="1.30.8-beta" />
<PackageVersion Include="TUnit.Mocks.Http" Version="1.30.8-beta" />
<PackageVersion Include="TUnit.Mocks.Logging" Version="1.30.8-beta" />
<PackageVersion Include="TUnit.AspNetCore" Version="1.33.0" />
<PackageVersion Include="TUnit.Mocks" Version="1.33.0-beta" />
<PackageVersion Include="TUnit.Mocks.Http" Version="1.33.0-beta" />
<PackageVersion Include="TUnit.Mocks.Logging" Version="1.33.0-beta" />
<PackageVersion Include="Verify.ParametersHashing" Version="1.0.0" />
<PackageVersion Include="Verify.TUnit" Version="31.15.0" />
</ItemGroup>
<ItemGroup Label="TargetFramework .NET 8" Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.25" />
<PackageVersion Include="Npgsql" Version="8.0.9" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
</ItemGroup>
<ItemGroup Label="TargetFramework .NET 9" Condition="'$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.14" />
<PackageVersion Include="Npgsql" Version="9.0.5" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup Label="TargetFramework .NET 10" Condition="'$(TargetFramework)' == 'net10.0'">
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.5" />
<PackageVersion Include="Npgsql" Version="10.0.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,26 @@
using NetEvolve.Pulse.Outbox;

/// <summary>
/// Entity Framework Core configuration for <see cref="OutboxMessage"/> targeting MySQL.
/// Supports both Pomelo (<c>Pomelo.EntityFrameworkCore.MySql</c>) and the Oracle
/// (<c>MySql.EntityFrameworkCore</c>) providers.
/// Entity Framework Core configuration for <see cref="OutboxMessage"/> targeting MySQL
/// via the Oracle provider (<c>MySql.EntityFrameworkCore</c>).
/// </summary>
/// <remarks>
/// <para><strong>Column Types:</strong></para>
/// <list type="bullet">
/// <item><description><c>char(36)</c> for <see cref="Guid"/> (UUID string representation)</description></item>
/// <item><description><c>binary(16)</c> for <see cref="Guid"/> — raw 16-byte UUID with a <c>byte[]</c> value converter</description></item>
/// <item><description><c>varchar(n)</c> for bounded strings</description></item>
/// <item><description><c>longtext</c> for unbounded strings (Payload, Error)</description></item>
/// <item><description><c>datetime(6)</c> for <see cref="DateTimeOffset"/> — Pomelo stores values as UTC</description></item>
/// <item><description><c>bigint</c> for <see cref="DateTimeOffset"/> — stored as UTC ticks via a <see langword="long"/> value converter</description></item>
/// </list>
/// <para><strong>Why binary(16) and bigint:</strong></para>
/// The Oracle MySQL provider does not produce a valid type mapping for
/// <see cref="Guid"/>→<c>char(36)</c> SQL parameter binding (returns <see langword="null"/>,
/// causing a <see cref="System.NullReferenceException"/> in
/// <c>TypeMappedRelationalParameter.AddDbParameter</c>).
/// Using <c>binary(16)</c> with an explicit <c>byte[]</c> converter provides a working
/// binding. Similarly, the provider lacks a proper <see cref="DateTimeOffset"/> SQL type
/// mapping; converting to <see langword="long"/> (UTC ticks) eliminates the broken
/// provider-specific type resolution and ensures correct ordering and comparison semantics.
/// <para><strong>Filtered Indexes:</strong></para>
/// MySQL does not support partial/filtered indexes with a <c>WHERE</c> clause.
/// All filter properties inherit <see langword="null"/> from the base class,
Expand Down Expand Up @@ -60,17 +68,48 @@ public MySqlOutboxMessageConfiguration(IOptions<OutboxOptions> options)
/// <inheritdoc />
protected override void ApplyColumnTypes(EntityTypeBuilder<OutboxMessage> builder)
{
// char(36) is the canonical UUID string format used by Pomelo and Oracle MySQL provider
_ = builder.Property(m => m.Id).HasColumnType("char(36)");
// binary(16) stores the raw 16-byte UUID — half the storage of char(36), faster binary
// comparisons, and better index locality. Critically, the Oracle MySQL provider
// (MySql.EntityFrameworkCore) has a working byte[]→binary(16) type mapping for SQL
// parameter binding, whereas Guid→char(36) returns a null TypeMapping and throws
// NullReferenceException inside TypeMappedRelationalParameter.AddDbParameter.
_ = builder
.Property(m => m.Id)
.HasColumnType("binary(16)")
.HasConversion(v => v.ToByteArray(), v => new Guid(v));
_ = builder.Property(m => m.EventType).HasColumnType("varchar(500)");
// longtext covers MySQL's maximum row size for arbitrarily large JSON payloads
_ = builder.Property(m => m.Payload).HasColumnType("longtext");
_ = builder.Property(m => m.CorrelationId).HasColumnType("varchar(100)");
// datetime(6) stores microsecond precision; Pomelo converts DateTimeOffset to UTC
_ = builder.Property(m => m.CreatedAt).HasColumnType("datetime(6)");
_ = builder.Property(m => m.UpdatedAt).HasColumnType("datetime(6)");
_ = builder.Property(m => m.ProcessedAt).HasColumnType("datetime(6)");
_ = builder.Property(m => m.NextRetryAt).HasColumnType("datetime(6)");

// DateTimeOffset columns are stored as BIGINT (UTC ticks), matching the SQLite
// approach (INTEGER / UTC ticks). The Oracle MySQL provider lacks a proper
// DateTimeOffset type mapping for parameterised operations (ExecuteUpdateAsync,
// IN clauses, etc.). Converting to long eliminates the broken provider-specific
// type resolution and ensures correct ordering and comparison semantics.
_ = builder
.Property(m => m.CreatedAt)
.HasColumnType("bigint")
.HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
_ = builder
.Property(m => m.UpdatedAt)
.HasColumnType("bigint")
.HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
_ = builder
.Property(m => m.ProcessedAt)
.HasColumnType("bigint")
.HasConversion(
v => v.HasValue ? (long?)v.Value.UtcTicks : null,
v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
);
_ = builder
.Property(m => m.NextRetryAt)
.HasColumnType("bigint")
.HasConversion(
v => v.HasValue ? (long?)v.Value.UtcTicks : null,
v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
);

_ = builder.Property(m => m.RetryCount).HasColumnType("int");
_ = builder.Property(m => m.Error).HasColumnType("longtext");
_ = builder.Property(m => m.Status).HasColumnType("int");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
/// </remarks>
internal abstract class OutboxMessageConfigurationBase : IEntityTypeConfiguration<OutboxMessage>
{
private const uint FnvPrime = 16777619;
private readonly OutboxOptions _options;

/// <summary>
Expand Down Expand Up @@ -93,10 +94,12 @@ public void Configure(EntityTypeBuilder<OutboxMessage> builder)
var schema = string.IsNullOrWhiteSpace(_options.Schema)
? OutboxMessageSchema.DefaultSchema
: _options.Schema.Trim();
_ = builder.ToTable(_options.TableName, schema);
var tableName = _options.TableName;

_ = builder.ToTable(tableName, schema);

// Primary key
_ = builder.HasKey(m => m.Id);
_ = builder.HasKey(m => m.Id).HasName(TruncateIdentifier($"PK_{schema}_{tableName}"));

// Id column
_ = builder.Property(m => m.Id).HasColumnName(OutboxMessageSchema.Columns.Id).ValueGeneratedNever();
Expand Down Expand Up @@ -155,18 +158,62 @@ public void Configure(EntityTypeBuilder<OutboxMessage> builder)
_ = builder
.HasIndex(m => new { m.Status, m.CreatedAt })
.HasFilter(PendingMessagesFilter)
.HasDatabaseName("IX_OutboxMessage_Status_CreatedAt");
.HasDatabaseName(TruncateIdentifier($"IX_{schema}_{tableName}_Status_CreatedAt"));

// Index for retry-scheduled message polling (with exponential backoff)
_ = builder
.HasIndex(m => new { m.Status, m.NextRetryAt })
.HasFilter(RetryScheduledMessagesFilter)
.HasDatabaseName("IX_OutboxMessage_Status_NextRetryAt");
.HasDatabaseName(TruncateIdentifier($"IX_{schema}_{tableName}_Status_NextRetryAt"));

// Index for completed message cleanup
_ = builder
.HasIndex(m => new { m.Status, m.ProcessedAt })
.HasFilter(CompletedMessagesFilter)
.HasDatabaseName("IX_OutboxMessage_Status_ProcessedAt");
.HasDatabaseName(TruncateIdentifier($"IX_{schema}_{tableName}_Status_ProcessedAt"));
}

/// <summary>
/// Truncates a database identifier to the specified maximum length while maintaining uniqueness
/// by appending a stable hash suffix when the identifier exceeds the limit.
/// This is required for databases such as PostgreSQL that enforce a 63-character identifier limit.
/// </summary>
/// <param name="name">The full identifier name to potentially truncate.</param>
/// <param name="maxLength">The maximum allowed identifier length. Defaults to 63 (PostgreSQL limit).</param>
/// <returns>
/// The original <paramref name="name"/> if it fits within <paramref name="maxLength"/>;
/// otherwise, a truncated prefix combined with an 8-character hexadecimal hash suffix
/// that uniquely identifies the original name.
/// </returns>
private static string TruncateIdentifier(string name, int maxLength = 63)
{
if (name.Length <= maxLength)
{
return name;
}

// Append a stable hash suffix to distinguish otherwise-identical truncated prefixes.
// The hash is computed over the full name, so two names that share a long common prefix
// but differ only in their suffix will produce different hashes.
var hash = ComputeFnv1aHash(name);
var hashSuffix = $"_{hash:x8}"; // "_" + 8 hex chars = 9 chars
var prefixLength = maxLength - hashSuffix.Length;
return name[..prefixLength] + hashSuffix;
}

/// <summary>
/// Computes a stable 32-bit FNV-1a hash of the given string.
/// FNV-1a is chosen for its simplicity, speed, and good distribution,
/// making it suitable for generating short disambiguation suffixes in identifier names.
/// </summary>
private static uint ComputeFnv1aHash(string value)
{
var hash = 2166136261;
foreach (var c in value)
{
hash ^= c;
hash *= FnvPrime;
}
return hash;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,35 @@ protected override void ApplyColumnTypes(EntityTypeBuilder<OutboxMessage> builde
_ = builder.Property(m => m.EventType).HasColumnType("TEXT");
_ = builder.Property(m => m.Payload).HasColumnType("TEXT");
_ = builder.Property(m => m.CorrelationId).HasColumnType("TEXT");
// DateTimeOffset is stored as ISO-8601 TEXT by EF Core SQLite.
_ = builder.Property(m => m.CreatedAt).HasColumnType("TEXT");
_ = builder.Property(m => m.UpdatedAt).HasColumnType("TEXT");
_ = builder.Property(m => m.ProcessedAt).HasColumnType("TEXT");
_ = builder.Property(m => m.NextRetryAt).HasColumnType("TEXT");

// DateTimeOffset columns are stored as INTEGER (UTC ticks) in SQLite.
// EF Core SQLite refuses to translate DateTimeOffset comparisons and ORDER BY
// when the column is TEXT because ISO-8601 string ordering is incorrect for
// values with non-UTC offsets. Storing as long (UTC ticks) allows EF Core to
// generate correct INTEGER comparisons and orderings in SQL.
_ = builder
.Property(m => m.CreatedAt)
.HasColumnType("INTEGER")
.HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
_ = builder
.Property(m => m.UpdatedAt)
.HasColumnType("INTEGER")
.HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
_ = builder
.Property(m => m.ProcessedAt)
.HasColumnType("INTEGER")
.HasConversion(
v => v.HasValue ? (long?)v.Value.UtcTicks : null,
v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
);
_ = builder
.Property(m => m.NextRetryAt)
.HasColumnType("INTEGER")
.HasConversion(
v => v.HasValue ? (long?)v.Value.UtcTicks : null,
v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
);

_ = builder.Property(m => m.RetryCount).HasColumnType("INTEGER");
_ = builder.Property(m => m.Error).HasColumnType("TEXT");
_ = builder.Property(m => m.Status).HasColumnType("INTEGER");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,22 @@ public static IMediatorBuilder AddEntityFrameworkOutbox<TContext>(
{
ArgumentNullException.ThrowIfNull(configurator);

var services = configurator.Services;

_ = services.AddOptions<OutboxOptions>();

// Register options if configureOptions is provided
if (configureOptions is not null)
{
_ = services.Configure(configureOptions);
}

// Ensure TimeProvider is registered
services.TryAddSingleton(TimeProvider.System);
var services = configurator.AddOutbox(configureOptions).Services;

// Register the repository
_ = services.RemoveAll<IOutboxRepository>();
_ = services.AddScoped<IOutboxRepository, EntityFrameworkOutboxRepository<TContext>>();

// Register the event outbox (overrides the default OutboxEventStore)
_ = services.RemoveAll<IEventOutbox>();
_ = services.AddScoped<IEventOutbox, EntityFrameworkOutbox<TContext>>();

// Register the transaction scope
_ = services.RemoveAll<IOutboxTransactionScope>();
_ = services.AddScoped<IOutboxTransactionScope, EntityFrameworkOutboxTransactionScope<TContext>>();

// Register the management API
_ = services.RemoveAll<IOutboxManagement>();
_ = services.AddScoped<IOutboxManagement, EntityFrameworkOutboxManagement<TContext>>();

return configurator;
Expand Down
Loading
Loading