Skip to content

Commit e45b529

Browse files
committed
Improve InsertOnlyTableReconciler testability
Refactor `InsertOnlyTableReconciler` to be non-generic, simplifying its declaration. Integrate `TimeProvider` for precise control over delays during testing. Add a `ReconcileNow` method to immediately trigger reconciliation cycles, bypassing the background service's periodic timers. This enables tests to drain insert-only tables without waiting for real time. Update persistence test contexts to leverage `FakeTimeProvider` and the new `ReconcileNow` method, reducing test setup complexity and coupling to specific reconciler implementations.
1 parent f7947f3 commit e45b529

7 files changed

Lines changed: 42 additions & 56 deletions

File tree

‎src/ServiceControl.Persistence.EFCore.PostgreSql/Infrastructure/KnownEndpointsReconciler.cs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql.Infrastructure;
44
using Microsoft.Extensions.DependencyInjection;
55
using Microsoft.Extensions.Logging;
66
using ServiceControl.Persistence.EFCore.DbContexts;
7-
using ServiceControl.Persistence.EFCore.Entities;
87
using ServiceControl.Persistence.EFCore.Infrastructure;
98

109
class KnownEndpointsReconciler(
1110
ILogger<KnownEndpointsReconciler> logger,
1211
TimeProvider timeProvider,
1312
IServiceScopeFactory serviceScopeFactory)
14-
: InsertOnlyTableReconciler<KnownEndpointInsertOnlyEntity, KnownEndpointEntity>(
13+
: InsertOnlyTableReconciler(
1514
logger, timeProvider, serviceScopeFactory, nameof(KnownEndpointsReconciler))
1615
{
1716
protected override Task<int> ReconcileBatch(ServiceControlDbContext dbContext, CancellationToken stoppingToken) =>

‎src/ServiceControl.Persistence.EFCore.SqlServer/Infrastructure/KnownEndpointsReconciler.cs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ namespace ServiceControl.Persistence.EFCore.SqlServer.Infrastructure;
44
using Microsoft.Extensions.DependencyInjection;
55
using Microsoft.Extensions.Logging;
66
using ServiceControl.Persistence.EFCore.DbContexts;
7-
using ServiceControl.Persistence.EFCore.Entities;
87
using ServiceControl.Persistence.EFCore.Infrastructure;
98

109
class KnownEndpointsReconciler(
1110
ILogger<KnownEndpointsReconciler> logger,
1211
TimeProvider timeProvider,
1312
IServiceScopeFactory serviceScopeFactory)
14-
: InsertOnlyTableReconciler<KnownEndpointInsertOnlyEntity, KnownEndpointEntity>(
13+
: InsertOnlyTableReconciler(
1514
logger, timeProvider, serviceScopeFactory, nameof(KnownEndpointsReconciler))
1615
{
1716
protected override Task<int> ReconcileBatch(ServiceControlDbContext dbContext, CancellationToken stoppingToken) =>

‎src/ServiceControl.Persistence.EFCore/Infrastructure/InsertOnlyTableReconciler.cs‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure;
66
using Microsoft.Extensions.Logging;
77
using ServiceControl.Persistence.EFCore.DbContexts;
88

9-
public abstract class InsertOnlyTableReconciler<TInsertOnly, TTarget>(
9+
public abstract class InsertOnlyTableReconciler(
1010
ILogger logger,
1111
TimeProvider timeProvider,
1212
IServiceScopeFactory serviceScopeFactory,
1313
string serviceName) : BackgroundService
14-
where TInsertOnly : class
15-
where TTarget : class
1614
{
1715
protected const int BatchSize = 1000;
1816

@@ -22,15 +20,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
2220

2321
try
2422
{
25-
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
23+
await Task.Delay(TimeSpan.FromSeconds(30), timeProvider, stoppingToken);
2624

2725
using PeriodicTimer timer = new(TimeSpan.FromSeconds(30), timeProvider);
2826

2927
do
3028
{
3129
try
3230
{
33-
await Reconcile(stoppingToken);
31+
await Reconcile(pace: true, stoppingToken);
3432
}
3533
catch (Exception ex) when (ex is not OperationCanceledException)
3634
{
@@ -44,9 +42,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
4442
}
4543
}
4644

47-
async Task Reconcile(CancellationToken stoppingToken)
45+
// Drains all pending rows immediately, bypassing the background timer and the inter-batch pacing.
46+
// Intended for tests that need reconciled data visible without waiting for the timer loop.
47+
public Task ReconcileNow(CancellationToken cancellationToken = default) => Reconcile(pace: false, cancellationToken);
48+
49+
async Task Reconcile(bool pace, CancellationToken cancellationToken)
4850
{
49-
while (!stoppingToken.IsCancellationRequested)
51+
while (!cancellationToken.IsCancellationRequested)
5052
{
5153
using var scope = serviceScopeFactory.CreateScope();
5254
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();
@@ -60,14 +62,17 @@ async Task Reconcile(CancellationToken stoppingToken)
6062
var rows = await ReconcileBatch(context, ct);
6163
await transaction.CommitAsync(ct);
6264
return rows;
63-
}, stoppingToken);
65+
}, cancellationToken);
6466

6567
if (rowsAffected < BatchSize)
6668
{
6769
break;
6870
}
6971

70-
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
72+
if (pace)
73+
{
74+
await Task.Delay(TimeSpan.FromSeconds(2), timeProvider, cancellationToken);
75+
}
7176
}
7277
}
7378

‎src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs‎

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@
22
namespace ServiceControl.Persistence.Tests;
33

44
using System;
5-
using System.Threading;
5+
using System.Linq;
66
using System.Threading.Tasks;
77
using EFCore.PostgreSql;
8-
using EFCore.PostgreSql.Infrastructure;
98
using Microsoft.EntityFrameworkCore;
109
using Microsoft.Extensions.DependencyInjection;
1110
using Microsoft.Extensions.Hosting;
11+
using Microsoft.Extensions.Time.Testing;
1212
using Npgsql;
13-
using ServiceControl.Persistence.EFCore.DbContexts;
13+
using ServiceControl.Persistence.EFCore.Infrastructure;
1414

1515
public class PersistenceTestsContext : IPersistenceTestsContext
1616
{
1717
IHost host;
1818
string databaseName;
1919

20+
public FakeTimeProvider FakeTime { get; } = new();
21+
2022
public async Task Setup(IHostApplicationBuilder hostBuilder)
2123
{
2224
databaseName = $"sc_test_{Guid.NewGuid():n}";
@@ -35,6 +37,8 @@ public async Task Setup(IHostApplicationBuilder hostBuilder)
3537

3638
persistence.AddPersistence(hostBuilder.Services);
3739
persistence.AddInstaller(hostBuilder.Services);
40+
41+
hostBuilder.Services.AddSingleton<TimeProvider>(FakeTime);
3842
}
3943

4044
public async Task PostSetup(IHost host)
@@ -46,8 +50,6 @@ public async Task PostSetup(IHost host)
4650
await db.Database.MigrateAsync();
4751
}
4852

49-
// Dropped via a separate admin connection: EnsureDeletedAsync would run DROP DATABASE on the
50-
// same connection that's using it, which Postgres always rejects (error 55006).
5153
public async Task TearDown()
5254
{
5355
await using var connection = new NpgsqlConnection(await PostgreSqlSharedContainer.GetConnectionStringAsync());
@@ -57,24 +59,13 @@ public async Task TearDown()
5759
await command.ExecuteNonQueryAsync();
5860
}
5961

60-
// Reconcile the insert-only tables so that ingested data is visible to the data stores,
61-
// without waiting for the reconciler background services' timers
62-
public Task CompleteDatabaseOperation() => DrainInsertOnlyTables();
63-
64-
async Task DrainInsertOnlyTables()
62+
// Drain every insert-only reconciler so that ingested data is visible to the data stores,
63+
// without waiting for the reconciler background services' timers.
64+
public async Task CompleteDatabaseOperation()
6565
{
66-
using var scope = host.Services.CreateScope();
67-
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();
68-
69-
while (await dbContext.KnownEndpointsInsertOnly.AnyAsync())
66+
foreach (var reconciler in host.Services.GetServices<IHostedService>().OfType<InsertOnlyTableReconciler>())
7067
{
71-
var strategy = dbContext.Database.CreateExecutionStrategy();
72-
await strategy.ExecuteAsync(async () =>
73-
{
74-
await using var transaction = await dbContext.Database.BeginTransactionAsync();
75-
await KnownEndpointsReconciler.ReconcileBatch(dbContext, batchSize: 1000, CancellationToken.None);
76-
await transaction.CommitAsync();
77-
});
68+
await reconciler.ReconcileNow();
7869
}
7970
}
8071

‎src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
<ItemGroup>
1616
<PackageReference Include="GitHubActionsTestLogger" />
17+
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
1718
<PackageReference Include="Microsoft.NET.Test.Sdk" />
1819
<PackageReference Include="NServiceBus.Testing" />
1920
<PackageReference Include="NUnit" />

‎src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs‎

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@
22
namespace ServiceControl.Persistence.Tests;
33

44
using System;
5-
using System.Threading;
5+
using System.Linq;
66
using System.Threading.Tasks;
77
using EFCore.SqlServer;
8-
using EFCore.SqlServer.Infrastructure;
98
using Microsoft.Data.SqlClient;
109
using Microsoft.EntityFrameworkCore;
1110
using Microsoft.Extensions.DependencyInjection;
1211
using Microsoft.Extensions.Hosting;
13-
using ServiceControl.Persistence.EFCore.DbContexts;
12+
using Microsoft.Extensions.Time.Testing;
13+
using ServiceControl.Persistence.EFCore.Infrastructure;
1414

1515
public class PersistenceTestsContext : IPersistenceTestsContext
1616
{
1717
IHost host;
1818
string databaseName;
1919

20+
public FakeTimeProvider FakeTime { get; } = new();
21+
2022
public async Task Setup(IHostApplicationBuilder hostBuilder)
2123
{
2224
databaseName = $"sc_test_{Guid.NewGuid():n}";
@@ -35,6 +37,8 @@ public async Task Setup(IHostApplicationBuilder hostBuilder)
3537

3638
persistence.AddPersistence(hostBuilder.Services);
3739
persistence.AddInstaller(hostBuilder.Services);
40+
41+
hostBuilder.Services.AddSingleton<TimeProvider>(FakeTime);
3842
}
3943

4044
public async Task PostSetup(IHost host)
@@ -46,9 +50,6 @@ public async Task PostSetup(IHost host)
4650
await db.Database.EnsureCreatedAsync();
4751
}
4852

49-
// Dropped via a separate admin connection to master: SQL Server refuses to drop a database
50-
// that the issuing connection (or any other session) is still attached to, so we force
51-
// everyone off with SINGLE_USER ROLLBACK IMMEDIATE first.
5253
public async Task TearDown()
5354
{
5455
await using var connection = new SqlConnection(await SqlServerSharedContainer.GetConnectionStringAsync());
@@ -64,24 +65,13 @@ IF DB_ID('{databaseName}') IS NOT NULL
6465
await command.ExecuteNonQueryAsync();
6566
}
6667

67-
// Reconcile the insert-only tables so that ingested data is visible to the data stores,
68-
// without waiting for the reconciler background services' timers
69-
public Task CompleteDatabaseOperation() => DrainInsertOnlyTables();
70-
71-
async Task DrainInsertOnlyTables()
68+
// Drain every insert-only reconciler so that ingested data is visible to the data stores,
69+
// without waiting for the reconciler background services' timers.
70+
public async Task CompleteDatabaseOperation()
7271
{
73-
using var scope = host.Services.CreateScope();
74-
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();
75-
76-
while (await dbContext.KnownEndpointsInsertOnly.AnyAsync())
72+
foreach (var reconciler in host.Services.GetServices<IHostedService>().OfType<InsertOnlyTableReconciler>())
7773
{
78-
var strategy = dbContext.Database.CreateExecutionStrategy();
79-
await strategy.ExecuteAsync(async () =>
80-
{
81-
await using var transaction = await dbContext.Database.BeginTransactionAsync();
82-
await KnownEndpointsReconciler.ReconcileBatch(dbContext, batchSize: 1000, CancellationToken.None);
83-
await transaction.CommitAsync();
84-
});
74+
await reconciler.ReconcileNow();
8575
}
8676
}
8777

‎src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
<ItemGroup>
1616
<PackageReference Include="GitHubActionsTestLogger" />
17+
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
1718
<PackageReference Include="Microsoft.NET.Test.Sdk" />
1819
<PackageReference Include="NServiceBus.Testing" />
1920
<PackageReference Include="NUnit" />

0 commit comments

Comments
 (0)