c5352f3ed8
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
213 lines
8.2 KiB
C#
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() { }
|
|
}
|
|
}
|
|
}
|
|
}
|