Files
scadaproj/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/MaintenanceTests.cs
T

213 lines
8.2 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb.Internal;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.LocalDb.Replication.Internal;
namespace ZB.MOM.WW.LocalDb.Tests;
public sealed class MaintenanceTests : IDisposable
{
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(50);
private readonly List<string> _paths = [];
private readonly List<IDisposable> _disposables = [];
public void Dispose()
{
foreach (var d in _disposables)
d.Dispose();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var p in _paths)
{
if (File.Exists(p)) File.Delete(p);
if (File.Exists(p + "-wal")) File.Delete(p + "-wal");
if (File.Exists(p + "-shm")) File.Delete(p + "-shm");
}
}
private async Task<(SqliteLocalDb Db, OplogStore Store)> NewDbAsync(ReplicationOptions options)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
_paths.Add(path);
var db = new SqliteLocalDb(new LocalDbOptions { Path = path });
_disposables.Add(db);
await db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)");
db.RegisterReplicated("orders");
return (db, new OplogStore(db, options));
}
private static MaintenanceBackgroundService NewService(
OplogStore store, ReplicationOptions options, ILoggerFactory? loggerFactory = null) =>
new(store, Options.Create(options), loggerFactory ?? NullLoggerFactory.Instance);
private static async Task WaitForAsync(Func<Task<bool>> predicate, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await predicate()) return;
await Task.Delay(20);
}
throw new TimeoutException("Condition not reached within " + timeout);
}
[Fact]
public async Task Maintenance_EnforcesCaps_SetsNeedsSnapshot()
{
var options = new ReplicationOptions { MaxOplogRows = 5, MaintenanceInterval = Tick };
var (db, store) = await NewDbAsync(options);
for (var i = 1; i <= 20; i++)
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i });
var service = NewService(store, options);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForAsync(async () =>
{
var snap = await db.QueryAsync("SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", x => x.GetInt64(0));
var depth = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0));
return snap[0] == 1 && depth[0] <= 5;
}, WaitTimeout);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task Maintenance_PrunesExpiredTombstones()
{
var options = new ReplicationOptions { MaintenanceInterval = Tick };
var (db, store) = await NewDbAsync(options);
await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'DOOMED', 1)");
await db.ExecuteAsync("DELETE FROM orders WHERE id = 1");
// Backdate the delete-trigger's tombstone far past any retention window.
var updated = await db.ExecuteAsync(
"UPDATE __localdb_row_version SET tombstone_utc = '2000-01-01T00:00:00.000Z' WHERE is_tombstone = 1");
Assert.Equal(1, updated);
var service = NewService(store, options);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForAsync(async () =>
{
var rows = await db.QueryAsync(
"SELECT COUNT(*) FROM __localdb_row_version WHERE is_tombstone = 1", x => x.GetInt64(0));
return rows[0] == 0;
}, WaitTimeout);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task Maintenance_SurvivesTickFailure()
{
var options = new ReplicationOptions { MaintenanceInterval = Tick };
var (db, store) = await NewDbAsync(options);
// Deterministic per-tick failure: every EnforceCapsAsync now hits "no such table".
await db.ExecuteAsync("DROP TABLE __localdb_oplog");
var logger = new ListLoggerFactory();
var service = NewService(store, options, logger);
await service.StartAsync(CancellationToken.None);
try
{
// At least ~5 failing ticks must have elapsed without killing the loop.
await WaitForAsync(
() => Task.FromResult(logger.Count(LogLevel.Warning) >= 5), WaitTimeout);
Assert.NotNull(service.ExecuteTask);
Assert.False(service.ExecuteTask!.IsCompleted, "maintenance loop died on a tick failure");
}
finally
{
await service.StopAsync(CancellationToken.None);
}
Assert.True(service.ExecuteTask!.IsCompletedSuccessfully);
}
[Fact]
public void Validator_NonPositiveMaintenanceInterval_Fails()
{
var result = new ReplicationOptionsValidator().Validate(
null, new ReplicationOptions { MaintenanceInterval = TimeSpan.Zero });
Assert.True(result.Failed);
Assert.Contains(result.Failures, f => f.Contains("MaintenanceInterval", StringComparison.Ordinal));
}
[Fact]
public void AddZbLocalDbReplication_RegistersMaintenance_BothRoles()
{
foreach (var peerAddress in new string?[] { null, "http://localhost" })
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
_paths.Add(path);
var dict = new Dictionary<string, string?> { ["LocalDb:Path"] = path };
if (peerAddress is not null) dict["LocalDb:Replication:PeerAddress"] = peerAddress;
var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddZbLocalDb(config, db =>
{
using var conn = db.CreateConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE orders (id INTEGER PRIMARY KEY)";
cmd.ExecuteNonQuery();
db.RegisterReplicated("orders");
});
services.AddZbLocalDbReplication(config);
var provider = services.BuildServiceProvider();
_disposables.Add(provider);
Assert.Contains(provider.GetServices<IHostedService>(), s => s is MaintenanceBackgroundService);
}
}
private sealed class ListLoggerFactory : ILoggerFactory
{
private readonly List<LogLevel> _levels = [];
public int Count(LogLevel level)
{
lock (_levels)
return _levels.Count(l => l == level);
}
public ILogger CreateLogger(string categoryName) => new ListLogger(_levels);
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
private sealed class ListLogger(List<LogLevel> levels) : ILogger
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (levels)
levels.Add(logLevel);
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
}