647 lines
24 KiB
C#
647 lines
24 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
|
|
{
|
|
/// <summary>
|
|
/// Verifies append, range query, merge, drop, and last-hash behavior for the oplog store.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies oplog reads and writes are isolated by dataset identifier.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OplogStore_DatasetIsolation_Works()
|
|
{
|
|
await using var harness = new SurrealTestHarness();
|
|
var store = harness.CreateOplogStore();
|
|
|
|
var primaryEntry = CreateOplogEntry("Users", "p1", "node-a", 100, 0, "");
|
|
var logsEntry = CreateOplogEntry("Users", "l1", "node-a", 100, 1, "");
|
|
|
|
await store.AppendOplogEntryAsync(primaryEntry, DatasetId.Primary);
|
|
await store.AppendOplogEntryAsync(logsEntry, DatasetId.Logs);
|
|
|
|
var primary = (await store.ExportAsync(DatasetId.Primary)).ToList();
|
|
var logs = (await store.ExportAsync(DatasetId.Logs)).ToList();
|
|
|
|
primary.Count.ShouldBe(1);
|
|
primary[0].DatasetId.ShouldBe(DatasetId.Primary);
|
|
|
|
logs.Count.ShouldBe(1);
|
|
logs[0].DatasetId.ShouldBe(DatasetId.Logs);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies legacy oplog rows without dataset id are treated as primary dataset.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OplogStore_LegacyRowsWithoutDatasetId_MapToPrimaryOnly()
|
|
{
|
|
await using var harness = new SurrealTestHarness();
|
|
var store = harness.CreateOplogStore();
|
|
|
|
await harness.SurrealEmbeddedClient.InitializeAsync();
|
|
await harness.SurrealEmbeddedClient.RawQueryAsync(
|
|
"""
|
|
UPSERT type::thing($table, $id) CONTENT {
|
|
collection: "Users",
|
|
key: "legacy",
|
|
operation: 0,
|
|
payloadJson: "{}",
|
|
timestampPhysicalTime: 10,
|
|
timestampLogicalCounter: 0,
|
|
timestampNodeId: "node-legacy",
|
|
hash: "legacy-hash",
|
|
previousHash: ""
|
|
};
|
|
""",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["table"] = CBDDCSurrealSchemaNames.OplogEntriesTable,
|
|
["id"] = "legacy-hash"
|
|
});
|
|
|
|
var primary = (await store.GetOplogAfterAsync(new HlcTimestamp(0, 0, ""), DatasetId.Primary)).ToList();
|
|
var logs = (await store.GetOplogAfterAsync(new HlcTimestamp(0, 0, ""), DatasetId.Logs)).ToList();
|
|
|
|
primary.Any(entry => entry.Hash == "legacy-hash").ShouldBeTrue();
|
|
logs.Any(entry => entry.Hash == "legacy-hash").ShouldBeFalse();
|
|
}
|
|
|
|
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
|
|
{
|
|
/// <summary>
|
|
/// Verifies upsert, deletion marking, incremental reads, and merge precedence for document metadata.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies document metadata records do not leak across datasets.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DocumentMetadataStore_DatasetIsolation_Works()
|
|
{
|
|
await using var harness = new SurrealTestHarness();
|
|
var store = harness.CreateDocumentMetadataStore();
|
|
|
|
await store.UpsertMetadataAsync(
|
|
new DocumentMetadata("Users", "doc-shared", new HlcTimestamp(100, 0, "node-a"), false, DatasetId.Primary),
|
|
DatasetId.Primary);
|
|
await store.UpsertMetadataAsync(
|
|
new DocumentMetadata("Users", "doc-shared", new HlcTimestamp(101, 0, "node-a"), false, DatasetId.Logs),
|
|
DatasetId.Logs);
|
|
|
|
var primary = await store.GetMetadataAsync("Users", "doc-shared", DatasetId.Primary);
|
|
var logs = await store.GetMetadataAsync("Users", "doc-shared", DatasetId.Logs);
|
|
|
|
primary.ShouldNotBeNull();
|
|
primary.DatasetId.ShouldBe(DatasetId.Primary);
|
|
primary.UpdatedAt.ShouldBe(new HlcTimestamp(100, 0, "node-a"));
|
|
|
|
logs.ShouldNotBeNull();
|
|
logs.DatasetId.ShouldBe(DatasetId.Logs);
|
|
logs.UpdatedAt.ShouldBe(new HlcTimestamp(101, 0, "node-a"));
|
|
}
|
|
}
|
|
|
|
public class SurrealPeerConfigurationStoreContractTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies save, read, remove, and merge behavior for remote peer configuration records.
|
|
/// </summary>
|
|
[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
|
|
{
|
|
/// <summary>
|
|
/// Verifies peer registration, confirmation updates, and peer deactivation semantics.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies peer confirmations are isolated by dataset.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PeerOplogConfirmationStore_DatasetIsolation_Works()
|
|
{
|
|
await using var harness = new SurrealTestHarness();
|
|
var store = harness.CreatePeerOplogConfirmationStore();
|
|
|
|
await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote, DatasetId.Primary);
|
|
await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote, DatasetId.Logs);
|
|
await store.UpdateConfirmationAsync(
|
|
"peer-a",
|
|
"source-1",
|
|
new HlcTimestamp(100, 1, "source-1"),
|
|
"hash-primary",
|
|
DatasetId.Primary);
|
|
await store.UpdateConfirmationAsync(
|
|
"peer-a",
|
|
"source-1",
|
|
new HlcTimestamp(200, 1, "source-1"),
|
|
"hash-logs",
|
|
DatasetId.Logs);
|
|
|
|
var primary = (await store.GetConfirmationsForPeerAsync("peer-a", DatasetId.Primary)).ToList();
|
|
var logs = (await store.GetConfirmationsForPeerAsync("peer-a", DatasetId.Logs)).ToList();
|
|
|
|
primary.Count.ShouldBe(1);
|
|
primary[0].ConfirmedHash.ShouldBe("hash-primary");
|
|
primary[0].DatasetId.ShouldBe(DatasetId.Primary);
|
|
|
|
logs.Count.ShouldBe(1);
|
|
logs[0].ConfirmedHash.ShouldBe("hash-logs");
|
|
logs[0].DatasetId.ShouldBe(DatasetId.Logs);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies merge semantics prefer newer confirmations and preserve active-state transitions.
|
|
/// </summary>
|
|
[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
|
|
{
|
|
/// <summary>
|
|
/// Verifies insert, update, merge, and hash lookup behavior for snapshot metadata records.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies snapshot metadata rows are isolated by dataset.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SnapshotMetadataStore_DatasetIsolation_Works()
|
|
{
|
|
await using var harness = new SurrealTestHarness();
|
|
var store = harness.CreateSnapshotMetadataStore();
|
|
|
|
await store.InsertSnapshotMetadataAsync(new SnapshotMetadata
|
|
{
|
|
DatasetId = DatasetId.Primary,
|
|
NodeId = "node-a",
|
|
TimestampPhysicalTime = 100,
|
|
TimestampLogicalCounter = 0,
|
|
Hash = "hash-primary"
|
|
}, DatasetId.Primary);
|
|
|
|
await store.InsertSnapshotMetadataAsync(new SnapshotMetadata
|
|
{
|
|
DatasetId = DatasetId.Logs,
|
|
NodeId = "node-a",
|
|
TimestampPhysicalTime = 200,
|
|
TimestampLogicalCounter = 0,
|
|
Hash = "hash-logs"
|
|
}, DatasetId.Logs);
|
|
|
|
var primary = await store.GetSnapshotMetadataAsync("node-a", DatasetId.Primary);
|
|
var logs = await store.GetSnapshotMetadataAsync("node-a", DatasetId.Logs);
|
|
|
|
primary.ShouldNotBeNull();
|
|
primary.Hash.ShouldBe("hash-primary");
|
|
primary.DatasetId.ShouldBe(DatasetId.Primary);
|
|
|
|
logs.ShouldNotBeNull();
|
|
logs.Hash.ShouldBe("hash-logs");
|
|
logs.DatasetId.ShouldBe(DatasetId.Logs);
|
|
}
|
|
}
|
|
|
|
internal sealed class SurrealTestHarness : IAsyncDisposable
|
|
{
|
|
private readonly CBDDCSurrealEmbeddedClient _client;
|
|
private readonly string _rootPath;
|
|
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
|
|
|
/// <summary>
|
|
/// Initializes a temporary embedded Surreal environment for contract tests.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a document metadata store instance bound to the test harness database.
|
|
/// </summary>
|
|
public SurrealDocumentMetadataStore CreateDocumentMetadataStore()
|
|
{
|
|
return new SurrealDocumentMetadataStore(
|
|
_client,
|
|
_schemaInitializer,
|
|
NullLogger<SurrealDocumentMetadataStore>.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an oplog store instance bound to the test harness database.
|
|
/// </summary>
|
|
public SurrealOplogStore CreateOplogStore()
|
|
{
|
|
return new SurrealOplogStore(
|
|
_client,
|
|
_schemaInitializer,
|
|
Substitute.For<IDocumentStore>(),
|
|
new LastWriteWinsConflictResolver(),
|
|
new VectorClockService(),
|
|
null,
|
|
NullLogger<SurrealOplogStore>.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a peer configuration store instance bound to the test harness database.
|
|
/// </summary>
|
|
public SurrealPeerConfigurationStore CreatePeerConfigurationStore()
|
|
{
|
|
return new SurrealPeerConfigurationStore(
|
|
_client,
|
|
_schemaInitializer,
|
|
NullLogger<SurrealPeerConfigurationStore>.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a peer oplog confirmation store instance bound to the test harness database.
|
|
/// </summary>
|
|
public SurrealPeerOplogConfirmationStore CreatePeerOplogConfirmationStore()
|
|
{
|
|
return new SurrealPeerOplogConfirmationStore(
|
|
_client,
|
|
_schemaInitializer,
|
|
NullLogger<SurrealPeerOplogConfirmationStore>.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a snapshot metadata store instance bound to the test harness database.
|
|
/// </summary>
|
|
public SurrealSnapshotMetadataStore CreateSnapshotMetadataStore()
|
|
{
|
|
return new SurrealSnapshotMetadataStore(
|
|
_client,
|
|
_schemaInitializer,
|
|
NullLogger<SurrealSnapshotMetadataStore>.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the embedded Surreal client used by this harness.
|
|
/// </summary>
|
|
public ICBDDCSurrealEmbeddedClient SurrealEmbeddedClient => _client;
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="TestSurrealSchemaInitializer" /> class.
|
|
/// </summary>
|
|
/// <param name="client">The embedded client to initialize.</param>
|
|
public TestSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client)
|
|
{
|
|
_client = client;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (Interlocked.Exchange(ref _initialized, 1) == 1) return;
|
|
await _client.InitializeAsync(cancellationToken);
|
|
}
|
|
}
|