Files
CBDDC/tests/ZB.MOM.WW.CBDDC.Sample.Console.Tests/SurrealStoreContractTests.cs
Joseph Doherty 9c2a77dc3c
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m21s
Replace BLite with Surreal embedded persistence
2026-02-22 05:21:53 -05:00

435 lines
16 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Network;
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.Surreal;
namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests;
public class SurrealOplogStoreContractTests
{
[Fact]
public async Task OplogStore_AppendQueryMergeDrop_AndLastHash_Works()
{
await using var harness = new SurrealTestHarness();
var store = harness.CreateOplogStore();
var entry1 = CreateOplogEntry("Users", "u1", "node-a", 100, 0, "");
var entry2 = CreateOplogEntry("Users", "u2", "node-a", 110, 0, entry1.Hash);
var entry3 = CreateOplogEntry("Users", "u3", "node-a", 120, 1, entry2.Hash);
var otherNode = CreateOplogEntry("Users", "u4", "node-b", 115, 0, "");
await store.AppendOplogEntryAsync(entry1);
await store.AppendOplogEntryAsync(entry2);
await store.AppendOplogEntryAsync(entry3);
await store.AppendOplogEntryAsync(otherNode);
var chainRange = (await store.GetChainRangeAsync(entry1.Hash, entry3.Hash)).ToList();
chainRange.Select(x => x.Hash).ToList().ShouldBe(new[] { entry2.Hash, entry3.Hash });
var after = (await store.GetOplogAfterAsync(new HlcTimestamp(100, 0, "node-a"))).ToList();
after.Select(x => x.Hash).ToList().ShouldBe(new[] { entry2.Hash, otherNode.Hash, entry3.Hash });
var mergedEntry = CreateOplogEntry("Users", "u5", "node-a", 130, 0, entry3.Hash);
await store.MergeAsync(new[] { entry2, mergedEntry });
var exported = (await store.ExportAsync()).ToList();
exported.Count.ShouldBe(5);
exported.Count(x => x.Hash == entry2.Hash).ShouldBe(1);
var cachedLastNodeAHash = await store.GetLastEntryHashAsync("node-a");
cachedLastNodeAHash.ShouldBe(entry3.Hash);
var rehydratedStore = harness.CreateOplogStore();
var persistedLastNodeAHash = await rehydratedStore.GetLastEntryHashAsync("node-a");
persistedLastNodeAHash.ShouldBe(mergedEntry.Hash);
await store.DropAsync();
(await store.ExportAsync()).ShouldBeEmpty();
}
private static OplogEntry CreateOplogEntry(
string collection,
string key,
string nodeId,
long wall,
int logic,
string previousHash)
{
return new OplogEntry(
collection,
key,
OperationType.Put,
JsonSerializer.SerializeToElement(new { key }),
new HlcTimestamp(wall, logic, nodeId),
previousHash);
}
}
public class SurrealDocumentMetadataStoreContractTests
{
[Fact]
public async Task DocumentMetadataStore_UpsertMarkDeletedGetAfterAndMergeNewer_Works()
{
await using var harness = new SurrealTestHarness();
var store = harness.CreateDocumentMetadataStore();
await store.UpsertMetadataAsync(new DocumentMetadata("Users", "doc-1", new HlcTimestamp(100, 0, "node-a")));
await store.UpsertMetadataAsync(new DocumentMetadata("Users", "doc-2", new HlcTimestamp(105, 0, "node-a")));
await store.MarkDeletedAsync("Users", "doc-1", new HlcTimestamp(110, 1, "node-a"));
var doc1 = await store.GetMetadataAsync("Users", "doc-1");
doc1.ShouldNotBeNull();
doc1.IsDeleted.ShouldBeTrue();
doc1.UpdatedAt.ShouldBe(new HlcTimestamp(110, 1, "node-a"));
var after = (await store.GetMetadataAfterAsync(new HlcTimestamp(100, 0, "node-a"), new[] { "Users" })).ToList();
after.Select(x => x.Key).ToList().ShouldBe(new[] { "doc-2", "doc-1" });
await store.MergeAsync(new[]
{
new DocumentMetadata("Users", "doc-1", new HlcTimestamp(109, 0, "node-a"), true),
new DocumentMetadata("Users", "doc-1", new HlcTimestamp(120, 0, "node-a"), false),
new DocumentMetadata("Users", "doc-3", new HlcTimestamp(130, 0, "node-b"), false)
});
var mergedDoc1 = await store.GetMetadataAsync("Users", "doc-1");
mergedDoc1.ShouldNotBeNull();
mergedDoc1.UpdatedAt.ShouldBe(new HlcTimestamp(120, 0, "node-a"));
mergedDoc1.IsDeleted.ShouldBeFalse();
var exported = (await store.ExportAsync()).ToList();
exported.Count.ShouldBe(3);
}
}
public class SurrealPeerConfigurationStoreContractTests
{
[Fact]
public async Task PeerConfigurationStore_SaveGetRemoveAndMerge_Works()
{
await using var harness = new SurrealTestHarness();
var store = harness.CreatePeerConfigurationStore();
await store.SaveRemotePeerAsync(CreatePeer("peer-1", "10.0.0.1:5000", true));
var peer1 = await store.GetRemotePeerAsync("peer-1", CancellationToken.None);
peer1.ShouldNotBeNull();
peer1.Address.ShouldBe("10.0.0.1:5000");
await store.SaveRemotePeerAsync(CreatePeer("peer-1", "10.0.0.1:6000", false));
await store.MergeAsync(new[]
{
CreatePeer("peer-1", "10.0.0.1:7000", true),
CreatePeer("peer-2", "10.0.0.2:5000", true)
});
var afterMergePeer1 = await store.GetRemotePeerAsync("peer-1", CancellationToken.None);
var afterMergePeer2 = await store.GetRemotePeerAsync("peer-2", CancellationToken.None);
afterMergePeer1.ShouldNotBeNull();
afterMergePeer1.Address.ShouldBe("10.0.0.1:6000");
afterMergePeer1.IsEnabled.ShouldBeFalse();
afterMergePeer2.ShouldNotBeNull();
afterMergePeer2.Address.ShouldBe("10.0.0.2:5000");
await store.RemoveRemotePeerAsync("peer-1");
var removedPeer = await store.GetRemotePeerAsync("peer-1", CancellationToken.None);
removedPeer.ShouldBeNull();
var peers = (await store.GetRemotePeersAsync()).ToList();
peers.Count.ShouldBe(1);
peers[0].NodeId.ShouldBe("peer-2");
}
private static RemotePeerConfiguration CreatePeer(string nodeId, string address, bool enabled)
{
return new RemotePeerConfiguration
{
NodeId = nodeId,
Address = address,
Type = PeerType.StaticRemote,
IsEnabled = enabled,
InterestingCollections = new List<string> { "Users" }
};
}
}
public class SurrealPeerOplogConfirmationStoreContractTests
{
[Fact]
public async Task PeerOplogConfirmationStore_EnsureUpdateAndDeactivate_Works()
{
await using var harness = new SurrealTestHarness();
var store = harness.CreatePeerOplogConfirmationStore();
await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote);
await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote);
await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(100, 1, "source-1"), "hash-1");
await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(90, 0, "source-1"), "hash-old");
await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(100, 1, "source-1"), "hash-2");
var peerConfirmations = (await store.GetConfirmationsForPeerAsync("peer-a")).ToList();
peerConfirmations.Count.ShouldBe(1);
peerConfirmations[0].ConfirmedWall.ShouldBe(100);
peerConfirmations[0].ConfirmedLogic.ShouldBe(1);
peerConfirmations[0].ConfirmedHash.ShouldBe("hash-2");
var all = (await store.ExportAsync()).Where(x => x.PeerNodeId == "peer-a").ToList();
all.Count(x => x.SourceNodeId == "__peer_registration__").ShouldBe(1);
await store.RemovePeerTrackingAsync("peer-a");
var activePeers = (await store.GetActiveTrackedPeersAsync()).ToList();
activePeers.ShouldNotContain("peer-a");
var afterDeactivate = (await store.ExportAsync()).Where(x => x.PeerNodeId == "peer-a").ToList();
afterDeactivate.All(x => x.IsActive == false).ShouldBeTrue();
}
[Fact]
public async Task PeerOplogConfirmationStore_Merge_UsesNewerAndActiveStateSemantics()
{
await using var harness = new SurrealTestHarness();
var store = harness.CreatePeerOplogConfirmationStore();
await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote);
await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(100, 1, "source-1"), "hash-1");
var existing = (await store.ExportAsync())
.Single(x => x.PeerNodeId == "peer-a" && x.SourceNodeId == "source-1");
await store.MergeAsync(new[]
{
new PeerOplogConfirmation
{
PeerNodeId = "peer-a",
SourceNodeId = "source-1",
ConfirmedWall = 90,
ConfirmedLogic = 0,
ConfirmedHash = "hash-old",
LastConfirmedUtc = existing.LastConfirmedUtc.AddMinutes(-5),
IsActive = true
},
new PeerOplogConfirmation
{
PeerNodeId = "peer-a",
SourceNodeId = "source-1",
ConfirmedWall = 130,
ConfirmedLogic = 0,
ConfirmedHash = "hash-2",
LastConfirmedUtc = existing.LastConfirmedUtc.AddMinutes(5),
IsActive = false
},
new PeerOplogConfirmation
{
PeerNodeId = "peer-a",
SourceNodeId = "source-2",
ConfirmedWall = 50,
ConfirmedLogic = 0,
ConfirmedHash = "hash-3",
LastConfirmedUtc = existing.LastConfirmedUtc.AddMinutes(5),
IsActive = true
}
});
var all = (await store.ExportAsync())
.Where(x => x.PeerNodeId == "peer-a" && x.SourceNodeId != "__peer_registration__")
.OrderBy(x => x.SourceNodeId)
.ToList();
all.Count.ShouldBe(2);
var source1 = all.Single(x => x.SourceNodeId == "source-1");
source1.ConfirmedWall.ShouldBe(130);
source1.ConfirmedLogic.ShouldBe(0);
source1.ConfirmedHash.ShouldBe("hash-2");
source1.IsActive.ShouldBeFalse();
var source2 = all.Single(x => x.SourceNodeId == "source-2");
source2.ConfirmedWall.ShouldBe(50);
source2.ConfirmedHash.ShouldBe("hash-3");
source2.IsActive.ShouldBeTrue();
}
}
public class SurrealSnapshotMetadataStoreContractTests
{
[Fact]
public async Task SnapshotMetadataStore_InsertUpdateMergeAndHashLookup_Works()
{
await using var harness = new SurrealTestHarness();
var store = harness.CreateSnapshotMetadataStore();
await store.InsertSnapshotMetadataAsync(new SnapshotMetadata
{
NodeId = "node-a",
TimestampPhysicalTime = 100,
TimestampLogicalCounter = 0,
Hash = "hash-1"
});
var initialHash = await store.GetSnapshotHashAsync("node-a");
initialHash.ShouldBe("hash-1");
await store.UpdateSnapshotMetadataAsync(new SnapshotMetadata
{
NodeId = "node-a",
TimestampPhysicalTime = 120,
TimestampLogicalCounter = 1,
Hash = "hash-2"
}, CancellationToken.None);
var updatedHash = await store.GetSnapshotHashAsync("node-a");
updatedHash.ShouldBe("hash-2");
await store.MergeAsync(new[]
{
new SnapshotMetadata
{
NodeId = "node-a",
TimestampPhysicalTime = 119,
TimestampLogicalCounter = 9,
Hash = "hash-old"
},
new SnapshotMetadata
{
NodeId = "node-a",
TimestampPhysicalTime = 130,
TimestampLogicalCounter = 0,
Hash = "hash-3"
},
new SnapshotMetadata
{
NodeId = "node-b",
TimestampPhysicalTime = 140,
TimestampLogicalCounter = 0,
Hash = "hash-b"
}
});
var finalNodeA = await store.GetSnapshotMetadataAsync("node-a");
finalNodeA.ShouldNotBeNull();
finalNodeA.Hash.ShouldBe("hash-3");
finalNodeA.TimestampPhysicalTime.ShouldBe(130);
var all = (await store.GetAllSnapshotMetadataAsync()).OrderBy(x => x.NodeId).ToList();
all.Count.ShouldBe(2);
all[0].NodeId.ShouldBe("node-a");
all[1].NodeId.ShouldBe("node-b");
}
}
internal sealed class SurrealTestHarness : IAsyncDisposable
{
private readonly CBDDCSurrealEmbeddedClient _client;
private readonly string _rootPath;
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
public SurrealTestHarness()
{
string suffix = Guid.NewGuid().ToString("N");
_rootPath = Path.Combine(Path.GetTempPath(), "cbddc-surreal-tests", suffix);
string databasePath = Path.Combine(_rootPath, "rocksdb");
var options = new CBDDCSurrealEmbeddedOptions
{
Endpoint = "rocksdb://local",
DatabasePath = databasePath,
Namespace = $"cbddc_tests_{suffix}",
Database = $"main_{suffix}"
};
_client = new CBDDCSurrealEmbeddedClient(options, NullLogger<CBDDCSurrealEmbeddedClient>.Instance);
_schemaInitializer = new TestSurrealSchemaInitializer(_client);
}
public SurrealDocumentMetadataStore CreateDocumentMetadataStore()
{
return new SurrealDocumentMetadataStore(
_client,
_schemaInitializer,
NullLogger<SurrealDocumentMetadataStore>.Instance);
}
public SurrealOplogStore CreateOplogStore()
{
return new SurrealOplogStore(
_client,
_schemaInitializer,
Substitute.For<IDocumentStore>(),
new LastWriteWinsConflictResolver(),
new VectorClockService(),
null,
NullLogger<SurrealOplogStore>.Instance);
}
public SurrealPeerConfigurationStore CreatePeerConfigurationStore()
{
return new SurrealPeerConfigurationStore(
_client,
_schemaInitializer,
NullLogger<SurrealPeerConfigurationStore>.Instance);
}
public SurrealPeerOplogConfirmationStore CreatePeerOplogConfirmationStore()
{
return new SurrealPeerOplogConfirmationStore(
_client,
_schemaInitializer,
NullLogger<SurrealPeerOplogConfirmationStore>.Instance);
}
public SurrealSnapshotMetadataStore CreateSnapshotMetadataStore()
{
return new SurrealSnapshotMetadataStore(
_client,
_schemaInitializer,
NullLogger<SurrealSnapshotMetadataStore>.Instance);
}
public async ValueTask DisposeAsync()
{
await _client.DisposeAsync();
await DeleteDirectoryWithRetriesAsync(_rootPath);
}
private static async Task DeleteDirectoryWithRetriesAsync(string path)
{
for (var attempt = 0; attempt < 5; attempt++)
try
{
if (Directory.Exists(path)) Directory.Delete(path, true);
return;
}
catch when (attempt < 4)
{
await Task.Delay(50);
}
}
}
internal sealed class TestSurrealSchemaInitializer : ICBDDCSurrealSchemaInitializer
{
private readonly ICBDDCSurrealEmbeddedClient _client;
private int _initialized;
public TestSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client)
{
_client = client;
}
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _initialized, 1) == 1) return;
await _client.InitializeAsync(cancellationToken);
}
}