435 lines
16 KiB
C#
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);
|
|
}
|
|
}
|