From bb433a80a8fe08a13c9b750d2697d1e1322fe1a1 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Mon, 4 May 2026 23:30:00 +0200 Subject: [PATCH 1/2] fix(efcore): drop unsupported provider fallback Remove the unsafe EF Core fallback path so the storage backend has a single explicit provider contract: SQL Server, PostgreSQL, and MySQL. Unsupported providers now fail during storage construction instead of partially working through LINQ fallback paths. Constraint: Public API breakage was explicitly approved in the PRD Constraint: SQLite is retained only as a narrow fail-fast regression test dependency Rejected: Keep internal SQLite fallback for tests | would preserve unsupported provider behavior Rejected: Method-level unsupported checks | provider-neutral methods could remain accidentally usable Confidence: high Scope-risk: moderate Directive: Do not reintroduce unsupported-provider fallback paths without adding a supported SQL dialect and contract tests Tested: csharpier check . Tested: dotnet build Tested: dotnet test tests/Atomizer.EntityFrameworkCore.Tests/Atomizer.EntityFrameworkCore.Tests.csproj -f net8.0 Tested: dotnet test tests/Atomizer.EntityFrameworkCore.Tests/Atomizer.EntityFrameworkCore.Tests.csproj -f net10.0 Not-tested: net6.0 test execution; Microsoft.NETCore.App 6.0 runtime is not installed locally --- .../Providers/DatabaseProvider.cs | 9 - .../Providers/EntityMap.cs | 2 - .../Providers/RelationalProviderCache.cs | 60 ++++--- .../EntityFrameworkCoreJobStorageOptions.cs | 7 - .../Storage/EntityFrameworkCoreStorage.cs | 156 ++---------------- .../Fixtures/SqliteDatabaseFixture.cs | 40 ----- ...EntityFrameworkCoreStorageProviderTests.cs | 37 +++++ .../EntityFrameworkCoreStorageTests.cs | 19 --- .../Sqlite/SqliteStorageContractTests.cs | 49 ------ .../TestSetup/Sqlite/SqliteDbContext.cs | 16 -- 10 files changed, 87 insertions(+), 308 deletions(-) delete mode 100644 tests/Atomizer.EntityFrameworkCore.Tests/Fixtures/SqliteDatabaseFixture.cs create mode 100644 tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageProviderTests.cs delete mode 100644 tests/Atomizer.EntityFrameworkCore.Tests/Storage/Sqlite/SqliteStorageContractTests.cs delete mode 100644 tests/Atomizer.EntityFrameworkCore.Tests/TestSetup/Sqlite/SqliteDbContext.cs diff --git a/src/Atomizer.EntityFrameworkCore/Providers/DatabaseProvider.cs b/src/Atomizer.EntityFrameworkCore/Providers/DatabaseProvider.cs index 7803186..6e57d51 100644 --- a/src/Atomizer.EntityFrameworkCore/Providers/DatabaseProvider.cs +++ b/src/Atomizer.EntityFrameworkCore/Providers/DatabaseProvider.cs @@ -13,13 +13,4 @@ public enum DatabaseProvider /// Microsoft SQL Server. SqlServer, - - /// Oracle Database. - Oracle, - - /// SQLite (unsafe fallback; not recommended for production). - Sqlite, - - /// An unrecognized or unsupported provider. - Unknown, } diff --git a/src/Atomizer.EntityFrameworkCore/Providers/EntityMap.cs b/src/Atomizer.EntityFrameworkCore/Providers/EntityMap.cs index cc90317..2825ceb 100644 --- a/src/Atomizer.EntityFrameworkCore/Providers/EntityMap.cs +++ b/src/Atomizer.EntityFrameworkCore/Providers/EntityMap.cs @@ -48,8 +48,6 @@ public static EntityMap Build(IModel model, Type clrType, DatabaseProvider provi DatabaseProvider.SqlServer => name => $"[{name}]", DatabaseProvider.PostgreSql => name => $"\"{name}\"", DatabaseProvider.MySql => name => $"`{name}`", - DatabaseProvider.Oracle => name => $"\"{name}\"", - DatabaseProvider.Unknown => name => name, _ => throw new NotSupportedException($"Database provider {provider} is not supported."), }; diff --git a/src/Atomizer.EntityFrameworkCore/Providers/RelationalProviderCache.cs b/src/Atomizer.EntityFrameworkCore/Providers/RelationalProviderCache.cs index 862824f..625a4dd 100644 --- a/src/Atomizer.EntityFrameworkCore/Providers/RelationalProviderCache.cs +++ b/src/Atomizer.EntityFrameworkCore/Providers/RelationalProviderCache.cs @@ -7,16 +7,23 @@ namespace Atomizer.EntityFrameworkCore.Providers; internal sealed class RelationalProviderCache { - public bool IsSupportedProvider => DetermineSupportedProvider(DatabaseProvider); + public bool IsSupportedProvider => _databaseProvider is not null; + public string ProviderName { get; } public ISqlDialect? Dialect { get; } - private DatabaseProvider DatabaseProvider { get; } + private readonly DatabaseProvider? _databaseProvider; private readonly EntityMap? _jobs; private readonly EntityMap? _schedules; - private RelationalProviderCache(DatabaseProvider databaseProvider, EntityMap? jobs, EntityMap? schedules) + private RelationalProviderCache( + string providerName, + DatabaseProvider? databaseProvider, + EntityMap? jobs, + EntityMap? schedules + ) { - DatabaseProvider = databaseProvider; + ProviderName = providerName; + _databaseProvider = databaseProvider; _jobs = jobs; _schedules = schedules; @@ -26,30 +33,29 @@ private RelationalProviderCache(DatabaseProvider databaseProvider, EntityMap? jo } } - private static readonly ConcurrentDictionary Instances = new(); + private static readonly ConcurrentDictionary Instances = new(); public static RelationalProviderCache Create(TDbContext dbContext) where TDbContext : DbContext { - // Determine provider for this DbContext - var provider = DetectProvider(dbContext.Database.ProviderName ?? string.Empty); + var providerName = dbContext.Database.ProviderName ?? string.Empty; + var provider = DetectProvider(providerName); - // Freeze Unknown per-provider as well (same semantics as before, but keyed). return Instances.GetOrAdd( - provider, + providerName, _ => { EntityMap? jobs = null, schedules = null; - if (DetermineSupportedProvider(provider)) + if (provider is not null) { var model = dbContext.Model; // capture once - jobs = EntityMap.Build(model, typeof(AtomizerJobEntity), provider); - schedules = EntityMap.Build(model, typeof(AtomizerScheduleEntity), provider); + jobs = EntityMap.Build(model, typeof(AtomizerJobEntity), provider.Value); + schedules = EntityMap.Build(model, typeof(AtomizerScheduleEntity), provider.Value); } - return new RelationalProviderCache(provider, jobs, schedules); + return new RelationalProviderCache(providerName, provider, jobs, schedules); } ); } @@ -61,34 +67,27 @@ private ISqlDialect CreateDialect() throw new InvalidOperationException("Database provider is not supported or entity mappings are missing."); } - return DatabaseProvider switch + return _databaseProvider switch { DatabaseProvider.PostgreSql => new PostgreSqlDialect(_jobs, _schedules), DatabaseProvider.MySql => new MySqlDialect(_jobs, _schedules), DatabaseProvider.SqlServer => new SqlServerDialect(_jobs, _schedules), - _ => throw new NotSupportedException($"Database provider {DatabaseProvider} is not supported."), + _ => throw new NotSupportedException($"Database provider {_databaseProvider} is not supported."), }; } - private static DatabaseProvider DetectProvider(string name) => + private static DatabaseProvider? DetectProvider(string name) => name switch { "Microsoft.EntityFrameworkCore.SqlServer" => DatabaseProvider.SqlServer, "Npgsql.EntityFrameworkCore.PostgreSQL" => DatabaseProvider.PostgreSql, "Pomelo.EntityFrameworkCore.MySql" or "MySql.EntityFrameworkCore" => DatabaseProvider.MySql, - "Oracle.EntityFrameworkCore" => DatabaseProvider.Oracle, - "Microsoft.EntityFrameworkCore.Sqlite" => DatabaseProvider.Sqlite, - _ => DatabaseProvider.Unknown, + _ => null, }; - private static bool DetermineSupportedProvider(DatabaseProvider provider) - { - return provider is DatabaseProvider.PostgreSql or DatabaseProvider.MySql or DatabaseProvider.SqlServer; - } - // Testing helpers internal static bool TryGet(DatabaseProvider provider, out RelationalProviderCache? cache) => - Instances.TryGetValue(provider, out cache); + Instances.TryGetValue(GetProviderName(provider), out cache); internal static void ResetInstanceForTests(DatabaseProvider? provider = null) { @@ -98,6 +97,15 @@ internal static void ResetInstanceForTests(DatabaseProvider? provider = null) return; } - Instances.TryRemove(provider.Value, out _); + Instances.TryRemove(GetProviderName(provider.Value), out _); } + + private static string GetProviderName(DatabaseProvider provider) => + provider switch + { + DatabaseProvider.SqlServer => "Microsoft.EntityFrameworkCore.SqlServer", + DatabaseProvider.PostgreSql => "Npgsql.EntityFrameworkCore.PostgreSQL", + DatabaseProvider.MySql => "Pomelo.EntityFrameworkCore.MySql", + _ => throw new NotSupportedException($"Database provider {provider} is not supported."), + }; } diff --git a/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreJobStorageOptions.cs b/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreJobStorageOptions.cs index d6abad5..f22c9ca 100644 --- a/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreJobStorageOptions.cs +++ b/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreJobStorageOptions.cs @@ -5,13 +5,6 @@ /// public class EntityFrameworkCoreJobStorageOptions { - /// - /// If true, allows falling back to providers that may not be - /// fully supported, tested or work in distributed environments (e.g. SQLite). - /// - /// Default is false. See documentation for details and implications. - public bool AllowUnsafeProviderFallback { get; set; } = false; - /// /// Maximum time to wait when acquiring a database transaction lock before giving up. /// diff --git a/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreStorage.cs b/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreStorage.cs index 4423838..5edbf49 100644 --- a/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreStorage.cs +++ b/src/Atomizer.EntityFrameworkCore/Storage/EntityFrameworkCoreStorage.cs @@ -33,6 +33,10 @@ IAtomizerClock clock _logger = logger; _clock = clock; _providerCache = RelationalProviderCache.Create(dbContext); + if (!_providerCache.IsSupportedProvider) + { + throw UnsupportedProviderException(_providerCache.ProviderName); + } } public async Task UpsertHeartbeatAsync(AtomizerActiveServer server, CancellationToken cancellationToken) @@ -163,7 +167,7 @@ public async Task InsertAsync(AtomizerJob job, CancellationToken cancellat } } - if (job.PartitionKey != null && _providerCache is { IsSupportedProvider: true, Dialect: not null }) + if (job.PartitionKey != null && _providerCache.Dialect is not null) { var sql = _providerCache.Dialect.InsertJobWithSequence(job); await _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, cancellationToken); @@ -176,19 +180,6 @@ public async Task InsertAsync(AtomizerJob job, CancellationToken cancellat return job.Id; } - if (job.PartitionKey != null && !_providerCache.IsSupportedProvider && _options.AllowUnsafeProviderFallback) - { - // LINQ fallback sequence assignment: not atomic under concurrency but safe for single-process use. - var partitionKeyStr = job.PartitionKey.ToString(); - var queueKeyStr = job.QueueKey.Key; - var maxSeq = await JobEntities - .AsNoTracking() - .Where(j => j.QueueKey == queueKeyStr && j.PartitionKey == partitionKeyStr) - .MaxAsync(j => j.SequenceNumber, cancellationToken); - entity.SequenceNumber = (maxSeq ?? 0L) + 1L; - job.SequenceNumber = entity.SequenceNumber; - } - JobEntities.Add(entity); await _dbContext.SaveChangesAsync(cancellationToken); return entity.Id; @@ -221,7 +212,7 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); - if (_providerCache is { IsSupportedProvider: true, Dialect: not null }) + if (_providerCache.Dialect is not null) { var sql = _providerCache.Dialect.GetDueJobs(queueKey, now, batchSize); @@ -230,62 +221,7 @@ CancellationToken cancellationToken return entities.Select(job => job.ToAtomizerJob()).ToList(); } - if (!_providerCache.IsSupportedProvider && _options.AllowUnsafeProviderFallback) - { - // WARNING: AsNoTracking() with no row lock means two concurrent QueuePumps - // on the same process (or any second node) will both receive the same jobs. - // AllowUnsafeProviderFallback is only safe with DegreeOfParallelism=1 and - // a single process instance. It is not safe for production use. - var allForQueue = await JobEntities - .AsNoTracking() - .Where(j => j.QueueKey == queueKey.Key) - .ToListAsync(cancellationToken); - - // 1) Collect blocked partitions: any partition key with a Processing job - // or a Pending job with prior attempts (retrying). - var blockedPartitions = allForQueue - .Where(j => - j.PartitionKey != null - && ( - j.Status == AtomizerEntityJobStatus.Processing - || (j.Status == AtomizerEntityJobStatus.Pending && j.Attempts > 0) - ) - ) - .Select(j => j.PartitionKey!) - .ToHashSet(); - - // 2) Find the lowest sequence number per unblocked partition (partition heads). - // Only consider Pending jobs that are due — Completed and Failed jobs must not - // block the next job from becoming the partition head. - var partitionHeads = allForQueue - .Where(j => - j.PartitionKey != null - && !blockedPartitions.Contains(j.PartitionKey) - && j.Status == AtomizerEntityJobStatus.Pending - && (j.VisibleAt == null || j.VisibleAt <= now) - && j.ScheduledAt <= now - ) - .GroupBy(j => j.PartitionKey!) - .Select(g => g.OrderBy(j => j.SequenceNumber).First().Id) - .ToHashSet(); - - // 3) Apply eligibility filter, FIFO partition-head filter, and batch size limit. - return allForQueue - .Where(j => - ( - j.Status == AtomizerEntityJobStatus.Pending - && (j.VisibleAt == null || j.VisibleAt <= now) - && j.ScheduledAt <= now - || (j.Status == AtomizerEntityJobStatus.Processing && j.VisibleAt <= now) // lease expired - ) && (j.PartitionKey == null || partitionHeads.Contains(j.Id)) - ) - .OrderBy(j => j.ScheduledAt) - .Take(batchSize) - .Select(j => j.ToAtomizerJob()) - .ToList(); - } - - throw UnsupportedProviderException(); + throw UnsupportedProviderException(_providerCache.ProviderName); } public async Task ReleaseLeasedAsync( @@ -294,41 +230,21 @@ public async Task ReleaseLeasedAsync( CancellationToken cancellationToken ) { - if (_providerCache is { IsSupportedProvider: true, Dialect: not null }) + if (_providerCache.Dialect is not null) { var sql = _providerCache.Dialect.ReleaseLeasedJobs(leaseToken, now); var result = await _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, cancellationToken); return result; } - var entities = await JobEntities - .Where(j => j.LeaseToken == leaseToken.Token && j.Status == AtomizerEntityJobStatus.Processing) - .ToListAsync(cancellationToken); - - foreach (var entity in entities) - { - entity.Status = AtomizerEntityJobStatus.Pending; - entity.VisibleAt = null; - entity.LeaseToken = null; - entity.UpdatedAt = now; - } - - try - { - return await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Failed to release leased jobs for lease token {LeaseToken}", leaseToken.Token); - return 0; - } + throw UnsupportedProviderException(_providerCache.ProviderName); } public async Task UpsertScheduleAsync(AtomizerSchedule schedule, CancellationToken cancellationToken) { var entity = schedule.ToEntity(); - if (_providerCache is { IsSupportedProvider: true, Dialect: not null }) + if (_providerCache.Dialect is not null) { var now = _clock.UtcNow; var sql = _providerCache.Dialect.UpsertSchedule(schedule, now); @@ -339,36 +255,7 @@ public async Task UpsertScheduleAsync(AtomizerSchedule schedule, Cancellat .FirstAsync(cancellationToken); } - if (!_providerCache.IsSupportedProvider && _options.AllowUnsafeProviderFallback) - { - var existing = await ScheduleEntities - .AsNoTracking() - .FirstOrDefaultAsync(s => s.JobKey == entity.JobKey, cancellationToken); - - if (existing is not null) - { - entity.Id = existing.Id; - ScheduleEntities.Update(entity); - } - else - { - ScheduleEntities.Add(entity); - } - - try - { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Failed to upsert schedule for job {JobKey}", schedule.JobKey); - throw; - } - - return entity.Id; - } - - throw UnsupportedProviderException(); + throw UnsupportedProviderException(_providerCache.ProviderName); } public async Task UpdateSchedulesAsync(IEnumerable schedules, CancellationToken cancellationToken) @@ -396,7 +283,7 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); - if (_providerCache is { IsSupportedProvider: true, Dialect: not null }) + if (_providerCache.Dialect is not null) { var sql = _providerCache.Dialect.GetDueSchedules(now); @@ -408,17 +295,7 @@ CancellationToken cancellationToken return entities.Select(s => s.ToAtomizerSchedule()).ToList(); } - if (!_providerCache.IsSupportedProvider && _options.AllowUnsafeProviderFallback) - { - return await ScheduleEntities - .AsNoTracking() - .Where(s => s.Enabled && s.NextRunAt <= now) - .OrderBy(s => s.NextRunAt) - .Select(s => s.ToAtomizerSchedule()) - .ToListAsync(cancellationToken); - } - - throw UnsupportedProviderException(); + throw UnsupportedProviderException(_providerCache.ProviderName); } public async Task ExecuteInLeaseAsync( @@ -476,10 +353,9 @@ CancellationToken cancellationToken } } - private static NotSupportedException UnsupportedProviderException() => + private static NotSupportedException UnsupportedProviderException(string providerName) => new( - "The current database provider is not supported. " - + "To bypass this check, set AllowUnsafeProviderFallback to true in EntityFrameworkCoreJobStorageOptions. " - + "Note that this may lead to unexpected behavior." + $"The current database provider '{providerName}' is not supported. " + + "Atomizer.EntityFrameworkCore supports SQL Server, PostgreSQL and MySQL." ); } diff --git a/tests/Atomizer.EntityFrameworkCore.Tests/Fixtures/SqliteDatabaseFixture.cs b/tests/Atomizer.EntityFrameworkCore.Tests/Fixtures/SqliteDatabaseFixture.cs deleted file mode 100644 index 9a1e801..0000000 --- a/tests/Atomizer.EntityFrameworkCore.Tests/Fixtures/SqliteDatabaseFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Atomizer.EntityFrameworkCore.Providers; -using Atomizer.EntityFrameworkCore.Tests.TestSetup.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace Atomizer.EntityFrameworkCore.Tests.Fixtures; - -[CollectionDefinition(nameof(SqliteDatabaseFixture))] -public class SqliteDatabaseFixture : ICollectionFixture, IAsyncLifetime -{ - public SqliteDbContext DbContext { get; private set; } = null!; - private string _databaseName = string.Empty; - - public async ValueTask InitializeAsync() - { - _databaseName = $"atomizer_db_{Guid.NewGuid():N}.db"; - RelationalProviderCache.ResetInstanceForTests(); - - DbContext = ConfigureDbContext(); - await DbContext.Database.EnsureCreatedAsync(); - } - - private SqliteDbContext ConfigureDbContext() - { - var options = new DbContextOptionsBuilder(); - options.UseSqlite($"Data Source={_databaseName}"); - - return new SqliteDbContext(options.Options); - } - - public SqliteDbContext CreateNewDbContext() - { - return ConfigureDbContext(); - } - - public async ValueTask DisposeAsync() - { - await DbContext.Database.EnsureDeletedAsync(); - await DbContext.DisposeAsync(); - } -} diff --git a/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageProviderTests.cs b/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageProviderTests.cs new file mode 100644 index 0000000..fbb985c --- /dev/null +++ b/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageProviderTests.cs @@ -0,0 +1,37 @@ +using Atomizer.Core; +using Atomizer.EntityFrameworkCore.Storage; +using Atomizer.EntityFrameworkCore.Tests.TestSetup; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Atomizer.EntityFrameworkCore.Tests.Storage; + +public sealed class EntityFrameworkCoreStorageProviderTests +{ + [Fact] + public void Constructor_WhenProviderIsUnsupported_ShouldFailFast() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + + using var dbContext = new UnsupportedProviderDbContext(options); + + var act = () => + new EntityFrameworkCoreStorage( + dbContext, + new EntityFrameworkCoreJobStorageOptions(), + Substitute.For>>(), + Substitute.For() + ); + + act.Should() + .Throw() + .WithMessage("*Microsoft.EntityFrameworkCore.Sqlite*SQL Server*PostgreSQL*MySQL*"); + } + + public sealed class UnsupportedProviderDbContext(DbContextOptions options) + : TestDbContext(options); +} diff --git a/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageTests.cs b/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageTests.cs index 149ef5c..399d02d 100644 --- a/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageTests.cs +++ b/tests/Atomizer.EntityFrameworkCore.Tests/Storage/EntityFrameworkCoreStorageTests.cs @@ -225,12 +225,6 @@ public async Task GetDueJobsAsync_WhenForUpdate_ShouldNotReturnSameJobs() await using var dbContext1 = _dbContextFactory(); - if (dbContext1.Database.IsSqlite()) - { - // SQLite does not support "FOR UPDATE SKIP LOCKED" behavior, so we skip this test for SQLite. - return; - } - var storage1 = _storageFactory(dbContext1); await using var dbContext2 = _dbContextFactory(); @@ -461,12 +455,6 @@ public async Task GetDueSchedules_WhenForUpdate_ShouldNotReturnSameSchedules() await using var dbContext1 = _dbContextFactory(); - if (dbContext1.Database.IsSqlite()) - { - // SQLite does not support "FOR UPDATE SKIP LOCKED" behavior, so we skip this test for SQLite. - return; - } - var storage1 = _storageFactory(dbContext1); await using var dbContext2 = _dbContextFactory(); @@ -814,10 +802,3 @@ public class MySqlStorageTestsExecutor(MySqlDatabaseFixture fixture) [Collection(nameof(SqlServerDatabaseFixture))] public class SqlServerStorageTestsExecutor(SqlServerDatabaseFixture fixture) : EntityFrameworkCoreStorageTests(fixture.CreateNewDbContext); - -[Collection(nameof(SqliteDatabaseFixture))] -public class SqliteStorageTestsExecutor(SqliteDatabaseFixture fixture) - : EntityFrameworkCoreStorageTests( - fixture.CreateNewDbContext, - new EntityFrameworkCoreJobStorageOptions { AllowUnsafeProviderFallback = true } - ); diff --git a/tests/Atomizer.EntityFrameworkCore.Tests/Storage/Sqlite/SqliteStorageContractTests.cs b/tests/Atomizer.EntityFrameworkCore.Tests/Storage/Sqlite/SqliteStorageContractTests.cs deleted file mode 100644 index c54944e..0000000 --- a/tests/Atomizer.EntityFrameworkCore.Tests/Storage/Sqlite/SqliteStorageContractTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Atomizer.Abstractions; -using Atomizer.Core; -using Atomizer.EntityFrameworkCore.Storage; -using Atomizer.EntityFrameworkCore.Tests.Fixtures; -using Atomizer.EntityFrameworkCore.Tests.TestSetup.Sqlite; -using Atomizer.Tests.Utilities.StorageContract; -using Microsoft.Extensions.Logging; -using NSubstitute; - -namespace Atomizer.EntityFrameworkCore.Tests.Storage.Sqlite; - -/// -/// Contract tests for backed by SQLite. -/// SQLite is not a supported production provider; it exercises the LINQ fallback path -/// (AllowUnsafeProviderFallback = true), not the CTE dialect SQL. -/// The CTE dialect SQL is verified by the PostgreSQL, SQL Server, and MySQL subclasses. -/// -/// -/// The fallback path enforces FIFO semantics in-process, but it is not safe for concurrent -/// multi-node production use because it does not take provider-level row locks. -/// -[Collection(nameof(SqliteDatabaseFixture))] -public sealed class SqliteStorageContractTests(SqliteDatabaseFixture fixture) : AtomizerStorageContractTests -{ - private SqliteDbContext? _dbContext; - - protected override IAtomizerStorage CreateStorage(IAtomizerClock clock) - { - _dbContext = fixture.CreateNewDbContext(); - return new EntityFrameworkCoreStorage( - _dbContext, - new EntityFrameworkCoreJobStorageOptions { AllowUnsafeProviderFallback = true }, - Substitute.For>>(), - clock - ); - } - - public override async ValueTask DisposeAsync() - { - if (_dbContext is not null) - await _dbContext.DisposeAsync(); - - // Delete errors before jobs to satisfy the FK constraint, then schedules. - // Use a bounded cancellation token so teardown does not hang indefinitely. - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await using var cleanupContext = fixture.CreateNewDbContext(); - await StorageTestCleanup.ClearAsync(cleanupContext, cts.Token); - } -} diff --git a/tests/Atomizer.EntityFrameworkCore.Tests/TestSetup/Sqlite/SqliteDbContext.cs b/tests/Atomizer.EntityFrameworkCore.Tests/TestSetup/Sqlite/SqliteDbContext.cs deleted file mode 100644 index 7e253ee..0000000 --- a/tests/Atomizer.EntityFrameworkCore.Tests/TestSetup/Sqlite/SqliteDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -namespace Atomizer.EntityFrameworkCore.Tests.TestSetup.Sqlite; - -public class SqliteDbContext : TestDbContext -{ - public SqliteDbContext(DbContextOptions options, string? schema = null) - : base(options, schema) { } - - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - configurationBuilder.Properties().HaveConversion(); - base.ConfigureConventions(configurationBuilder); - } -} From d8c16139a13b05548c6b344be1daf7396962c245 Mon Sep 17 00:00:00 2001 From: mnbuhl Date: Mon, 4 May 2026 23:37:40 +0200 Subject: [PATCH 2/2] test(queue): improve StopAsync timing assertion --- tests/Atomizer.Tests/Processing/QueuePumpTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Atomizer.Tests/Processing/QueuePumpTests.cs b/tests/Atomizer.Tests/Processing/QueuePumpTests.cs index 50a0653..bfc2280 100644 --- a/tests/Atomizer.Tests/Processing/QueuePumpTests.cs +++ b/tests/Atomizer.Tests/Processing/QueuePumpTests.cs @@ -140,10 +140,7 @@ public async Task StopAsync_WhenWorkerIsLongRunning_ShouldRespectGracePeriod() // Assert: Should return after about 1 second, not 2 sw.Elapsed.TotalSeconds.Should().BeGreaterThanOrEqualTo(1); sw.Elapsed.TotalSeconds.Should() - .BeLessThanOrEqualTo( - 1.5, - $"StopAsync should return after about 1 second, elapsed: {sw.Elapsed.TotalSeconds}" - ); + .BeLessThan(2, $"StopAsync should return after about 1 second, elapsed: {sw.Elapsed.TotalSeconds}"); logger.Received(1).LogInformation($"Stopping queue '{queueOptions.QueueKey}'..."); logger.Received(1).LogInformation($"Queue '{queueOptions.QueueKey}' stopped");