using System.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Sync; using ZB.MOM.WW.CBDDC.Persistence; using ZB.MOM.WW.CBDDC.Persistence.Lmdb; using ZB.MOM.WW.CBDDC.Persistence.Surreal; namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests; public class SurrealOplogStoreContractParityTests : OplogStoreContractTestBase { protected override Task CreateHarnessAsync() { return Task.FromResult(new SurrealOplogStoreContractHarness()); } } public class LmdbOplogStoreContractTests : OplogStoreContractTestBase { protected override Task CreateHarnessAsync() { return Task.FromResult(new LmdbOplogStoreContractHarness()); } [Fact] public async Task Lmdb_IndexConsistency_InsertPopulatesAndPruneRemovesIndexes() { await using var harness = new LmdbOplogStoreContractHarness(); var store = (LmdbOplogStore)harness.Store; var entry1 = CreateOplogEntry("Users", "i1", "node-a", 100, 0, ""); var entry2 = CreateOplogEntry("Users", "i2", "node-a", 101, 0, entry1.Hash); await store.AppendOplogEntryAsync(entry1); await store.AppendOplogEntryAsync(entry2); LmdbOplogIndexDiagnostics before = await store.GetIndexDiagnosticsAsync(DatasetId.Primary); before.OplogByHashCount.ShouldBe(2); before.OplogByHlcCount.ShouldBe(2); before.OplogByNodeHlcCount.ShouldBe(2); before.OplogPrevToHashCount.ShouldBe(1); before.OplogNodeHeadCount.ShouldBe(1); await store.PruneOplogAsync(new HlcTimestamp(101, 0, "node-a")); LmdbOplogIndexDiagnostics after = await store.GetIndexDiagnosticsAsync(DatasetId.Primary); after.OplogByHashCount.ShouldBe(0); after.OplogByHlcCount.ShouldBe(0); after.OplogByNodeHlcCount.ShouldBe(0); after.OplogPrevToHashCount.ShouldBe(0); after.OplogNodeHeadCount.ShouldBe(0); } [Fact] public async Task Lmdb_Prune_RemovesAtOrBeforeCutoff_AndKeepsNewerInterleavedEntries() { await using var harness = new LmdbOplogStoreContractHarness(); IOplogStore store = harness.Store; var nodeAOld = CreateOplogEntry("Users", "a-old", "node-a", 100, 0, ""); var nodeBKeep = CreateOplogEntry("Users", "b-keep", "node-b", 105, 0, ""); var nodeANew = CreateOplogEntry("Users", "a-new", "node-a", 110, 0, nodeAOld.Hash); var lateOld = CreateOplogEntry("Users", "late-old", "node-c", 90, 0, ""); await store.AppendOplogEntryAsync(nodeAOld); await store.AppendOplogEntryAsync(nodeBKeep); await store.AppendOplogEntryAsync(nodeANew); await store.AppendOplogEntryAsync(lateOld); await store.PruneOplogAsync(new HlcTimestamp(100, 0, "node-a")); var remaining = (await store.ExportAsync()).Select(e => e.Hash).ToHashSet(StringComparer.Ordinal); remaining.Contains(nodeAOld.Hash).ShouldBeFalse(); remaining.Contains(lateOld.Hash).ShouldBeFalse(); remaining.Contains(nodeBKeep.Hash).ShouldBeTrue(); remaining.Contains(nodeANew.Hash).ShouldBeTrue(); } [Fact] public async Task Lmdb_NodeHead_AdvancesAndRecomputesAcrossPrune() { await using var harness = new LmdbOplogStoreContractHarness(); IOplogStore store = harness.Store; var older = CreateOplogEntry("Users", "n1", "node-a", 100, 0, ""); var newer = CreateOplogEntry("Users", "n2", "node-a", 120, 0, older.Hash); await store.AppendOplogEntryAsync(older); await store.AppendOplogEntryAsync(newer); (await store.GetLastEntryHashAsync("node-a")).ShouldBe(newer.Hash); await store.PruneOplogAsync(new HlcTimestamp(110, 0, "node-a")); (await store.GetLastEntryHashAsync("node-a")).ShouldBe(newer.Hash); await store.PruneOplogAsync(new HlcTimestamp(130, 0, "node-a")); (await store.GetLastEntryHashAsync("node-a")).ShouldBeNull(); } [Fact] public async Task Lmdb_RestartDurability_PreservesHeadAndScans() { await using var harness = new LmdbOplogStoreContractHarness(); IOplogStore store = harness.Store; var entry1 = CreateOplogEntry("Users", "r1", "node-a", 100, 0, ""); var entry2 = CreateOplogEntry("Users", "r2", "node-a", 101, 0, entry1.Hash); await store.AppendOplogEntryAsync(entry1); await store.AppendOplogEntryAsync(entry2); IOplogStore reopened = harness.ReopenStore(); (await reopened.GetLastEntryHashAsync("node-a")).ShouldBe(entry2.Hash); var after = (await reopened.GetOplogAfterAsync(new HlcTimestamp(0, 0, string.Empty))).ToList(); after.Count.ShouldBe(2); after[0].Hash.ShouldBe(entry1.Hash); after[1].Hash.ShouldBe(entry2.Hash); } [Fact] public async Task Lmdb_Dedupe_DuplicateHashAppendIsIdempotent() { await using var harness = new LmdbOplogStoreContractHarness(); IOplogStore store = harness.Store; var entry = CreateOplogEntry("Users", "d1", "node-a", 100, 0, ""); await store.AppendOplogEntryAsync(entry); await store.AppendOplogEntryAsync(entry); var exported = (await store.ExportAsync()).ToList(); exported.Count.ShouldBe(1); exported[0].Hash.ShouldBe(entry.Hash); } [Fact] public async Task Lmdb_PrunePerformanceSmoke_LargeSyntheticWindow_CompletesWithinGenerousBudget() { await using var harness = new LmdbOplogStoreContractHarness(); IOplogStore store = harness.Store; string previousHash = string.Empty; for (var i = 0; i < 5000; i++) { var entry = CreateOplogEntry("Users", $"p-{i:D5}", "node-a", 1_000 + i, 0, previousHash); await store.AppendOplogEntryAsync(entry); previousHash = entry.Hash; } var sw = Stopwatch.StartNew(); await store.PruneOplogAsync(new HlcTimestamp(6_000, 0, "node-a")); sw.Stop(); sw.ElapsedMilliseconds.ShouldBeLessThan(30_000L); (await store.ExportAsync()).ShouldBeEmpty(); } } internal sealed class SurrealOplogStoreContractHarness : IOplogStoreContractHarness { private readonly SurrealTestHarness _harness; public SurrealOplogStoreContractHarness() { _harness = new SurrealTestHarness(); Store = _harness.CreateOplogStore(); } public IOplogStore Store { get; private set; } public IOplogStore ReopenStore() { Store = _harness.CreateOplogStore(); return Store; } public Task AppendOplogEntryAsync(OplogEntry entry, string datasetId, CancellationToken cancellationToken = default) { return ((SurrealOplogStore)Store).AppendOplogEntryAsync(entry, datasetId, cancellationToken); } public Task> ExportAsync(string datasetId, CancellationToken cancellationToken = default) { return ((SurrealOplogStore)Store).ExportAsync(datasetId, cancellationToken); } public ValueTask DisposeAsync() { return _harness.DisposeAsync(); } } internal sealed class LmdbOplogStoreContractHarness : IOplogStoreContractHarness { private readonly string _rootPath; private LmdbOplogStore? _store; public LmdbOplogStoreContractHarness() { _rootPath = Path.Combine(Path.GetTempPath(), "cbddc-lmdb-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_rootPath); _store = CreateStore(); } public IOplogStore Store => _store ?? throw new ObjectDisposedException(nameof(LmdbOplogStoreContractHarness)); public IOplogStore ReopenStore() { _store?.Dispose(); _store = CreateStore(); return _store; } public Task AppendOplogEntryAsync(OplogEntry entry, string datasetId, CancellationToken cancellationToken = default) { return (_store ?? throw new ObjectDisposedException(nameof(LmdbOplogStoreContractHarness))) .AppendOplogEntryAsync(entry, datasetId, cancellationToken); } public Task> ExportAsync(string datasetId, CancellationToken cancellationToken = default) { return (_store ?? throw new ObjectDisposedException(nameof(LmdbOplogStoreContractHarness))) .ExportAsync(datasetId, cancellationToken); } public async ValueTask DisposeAsync() { _store?.Dispose(); _store = null; for (var attempt = 0; attempt < 5; attempt++) try { if (Directory.Exists(_rootPath)) Directory.Delete(_rootPath, true); break; } catch when (attempt < 4) { await Task.Delay(50); } } private LmdbOplogStore CreateStore() { string lmdbPath = Path.Combine(_rootPath, "lmdb-oplog"); Directory.CreateDirectory(lmdbPath); return new LmdbOplogStore( Substitute.For(), new LastWriteWinsConflictResolver(), new VectorClockService(), new LmdbOplogOptions { EnvironmentPath = lmdbPath, MapSizeBytes = 64L * 1024 * 1024, MaxDatabases = 16, PruneBatchSize = 128 }, null, NullLogger.Instance); } }