Initial import of the CBDDC codebase with docs and tests. Add a .NET-focused gitignore to keep generated artifacts out of source control.
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Joseph Doherty
2026-02-20 13:03:21 -05:00
commit 08bfc17218
218 changed files with 33910 additions and 0 deletions

View File

@@ -0,0 +1,431 @@
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.BLite;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit;
using ZB.MOM.WW.CBDDC.Persistence;
namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests;
public class SnapshotStoreTests : IDisposable
{
private readonly string _testDbPath;
private readonly SampleDbContext _context;
private readonly SampleDocumentStore _documentStore;
private readonly BLiteOplogStore<SampleDbContext> _oplogStore;
private readonly BLitePeerConfigurationStore<SampleDbContext> _peerConfigStore;
private readonly BLitePeerOplogConfirmationStore<SampleDbContext> _peerConfirmationStore;
private readonly SnapshotStore _snapshotStore;
private readonly IPeerNodeConfigurationProvider _configProvider;
/// <summary>
/// Initializes a new instance of the <see cref="SnapshotStoreTests"/> class.
/// </summary>
public SnapshotStoreTests()
{
_testDbPath = Path.Combine(Path.GetTempPath(), $"test-snapshot-{Guid.NewGuid()}.blite");
_context = new SampleDbContext(_testDbPath);
_configProvider = CreateConfigProvider("test-node");
var vectorClock = new VectorClockService();
_documentStore = new SampleDocumentStore(_context, _configProvider, vectorClock, NullLogger<SampleDocumentStore>.Instance);
var snapshotMetadataStore = new BLiteSnapshotMetadataStore<SampleDbContext>(
_context,
NullLogger<BLiteSnapshotMetadataStore<SampleDbContext>>.Instance);
_oplogStore = new BLiteOplogStore<SampleDbContext>(
_context,
_documentStore,
new LastWriteWinsConflictResolver(),
vectorClock,
snapshotMetadataStore,
NullLogger<BLiteOplogStore<SampleDbContext>>.Instance);
_peerConfigStore = new BLitePeerConfigurationStore<SampleDbContext>(
_context,
NullLogger<BLitePeerConfigurationStore<SampleDbContext>>.Instance);
_peerConfirmationStore = new BLitePeerOplogConfirmationStore<SampleDbContext>(
_context,
NullLogger<BLitePeerOplogConfirmationStore<SampleDbContext>>.Instance);
_snapshotStore = new SnapshotStore(
_documentStore,
_peerConfigStore,
_oplogStore,
new LastWriteWinsConflictResolver(),
NullLogger<SnapshotStore>.Instance,
_peerConfirmationStore);
}
/// <summary>
/// Verifies that creating a snapshot writes valid JSON to the output stream.
/// </summary>
[Fact]
public async Task CreateSnapshotAsync_WritesValidJsonToStream()
{
// Arrange - Add some data
var user = new User { Id = "user-1", Name = "Alice", Age = 30 };
await _context.Users.InsertAsync(user);
await _context.SaveChangesAsync();
// Act - Create snapshot
using var stream = new MemoryStream();
await _snapshotStore.CreateSnapshotAsync(stream);
// Assert - Stream should contain valid JSON
(stream.Length > 0).ShouldBeTrue("Snapshot stream should not be empty");
// Reset stream position and verify JSON is valid
stream.Position = 0;
var json = await new StreamReader(stream).ReadToEndAsync();
string.IsNullOrWhiteSpace(json).ShouldBeFalse("Snapshot JSON should not be empty");
json.Trim().ShouldStartWith("{");
// Verify it's valid JSON by parsing
var doc = JsonDocument.Parse(json);
doc.ShouldNotBeNull();
// Verify structure
doc.RootElement.TryGetProperty("Version", out _).ShouldBeTrue("Should have Version property");
doc.RootElement.TryGetProperty("Documents", out _).ShouldBeTrue("Should have Documents property");
doc.RootElement.TryGetProperty("Oplog", out _).ShouldBeTrue("Should have Oplog property");
doc.RootElement.TryGetProperty("PeerConfirmations", out _).ShouldBeTrue("Should have PeerConfirmations property");
}
/// <summary>
/// Verifies that snapshot creation includes all persisted documents.
/// </summary>
[Fact]
public async Task CreateSnapshotAsync_IncludesAllDocuments()
{
// Arrange - Add multiple documents
await _context.Users.InsertAsync(new User { Id = "u1", Name = "User 1", Age = 20 });
await _context.Users.InsertAsync(new User { Id = "u2", Name = "User 2", Age = 25 });
await _context.TodoLists.InsertAsync(new TodoList
{
Id = "t1",
Name = "My List",
Items = [new TodoItem { Task = "Task 1", Completed = false }]
});
await _context.SaveChangesAsync();
// Act
using var stream = new MemoryStream();
await _snapshotStore.CreateSnapshotAsync(stream);
// Assert
stream.Position = 0;
var json = await new StreamReader(stream).ReadToEndAsync();
var doc = JsonDocument.Parse(json);
var documents = doc.RootElement.GetProperty("Documents");
documents.GetArrayLength().ShouldBe(3);
}
/// <summary>
/// Verifies that creating and replacing a snapshot preserves document data.
/// </summary>
[Fact]
public async Task RoundTrip_CreateAndReplace_PreservesData()
{
// Arrange - Add data to source
var originalUser = new User { Id = "user-rt", Name = "RoundTrip User", Age = 42 };
await _context.Users.InsertAsync(originalUser);
await _peerConfirmationStore.UpdateConfirmationAsync(
"peer-rt",
"source-rt",
new HlcTimestamp(500, 2, "source-rt"),
"hash-rt");
await _context.SaveChangesAsync();
// Create snapshot
using var snapshotStream = new MemoryStream();
await _snapshotStore.CreateSnapshotAsync(snapshotStream);
snapshotStream.Position = 0;
var snapshotJson = await new StreamReader(snapshotStream).ReadToEndAsync();
var snapshotDoc = JsonDocument.Parse(snapshotJson);
snapshotDoc.RootElement.GetProperty("PeerConfirmations").GetArrayLength().ShouldBe(1);
snapshotStream.Position = 0;
// Create a new context/stores (simulating a different node)
var newDbPath = Path.Combine(Path.GetTempPath(), $"test-snapshot-target-{Guid.NewGuid()}.blite");
try
{
using var newContext = new SampleDbContext(newDbPath);
var newConfigProvider = CreateConfigProvider("test-new-node");
var newVectorClock = new VectorClockService();
var newDocStore = new SampleDocumentStore(newContext, newConfigProvider, newVectorClock, NullLogger<SampleDocumentStore>.Instance);
var newSnapshotMetaStore = new BLiteSnapshotMetadataStore<SampleDbContext>(
newContext, NullLogger<BLiteSnapshotMetadataStore<SampleDbContext>>.Instance);
var newOplogStore = new BLiteOplogStore<SampleDbContext>(
newContext, newDocStore, new LastWriteWinsConflictResolver(),
newVectorClock,
newSnapshotMetaStore,
NullLogger<BLiteOplogStore<SampleDbContext>>.Instance);
var newPeerStore = new BLitePeerConfigurationStore<SampleDbContext>(
newContext, NullLogger<BLitePeerConfigurationStore<SampleDbContext>>.Instance);
var newPeerConfirmationStore = new BLitePeerOplogConfirmationStore<SampleDbContext>(
newContext,
NullLogger<BLitePeerOplogConfirmationStore<SampleDbContext>>.Instance);
var newSnapshotStore = new SnapshotStore(
newDocStore,
newPeerStore,
newOplogStore,
new LastWriteWinsConflictResolver(),
NullLogger<SnapshotStore>.Instance,
newPeerConfirmationStore);
// Act - Replace database with snapshot
await newSnapshotStore.ReplaceDatabaseAsync(snapshotStream);
// Assert - Data should be restored
var restoredUser = newContext.Users.FindById("user-rt");
restoredUser.ShouldNotBeNull();
restoredUser.Name.ShouldBe("RoundTrip User");
restoredUser.Age.ShouldBe(42);
var restoredConfirmations = (await newPeerConfirmationStore.GetConfirmationsAsync()).ToList();
restoredConfirmations.Count.ShouldBe(1);
restoredConfirmations[0].PeerNodeId.ShouldBe("peer-rt");
restoredConfirmations[0].SourceNodeId.ShouldBe("source-rt");
restoredConfirmations[0].ConfirmedWall.ShouldBe(500);
restoredConfirmations[0].ConfirmedLogic.ShouldBe(2);
restoredConfirmations[0].ConfirmedHash.ShouldBe("hash-rt");
}
finally
{
if (File.Exists(newDbPath))
try { File.Delete(newDbPath); } catch { }
}
}
/// <summary>
/// Verifies that merging a snapshot preserves existing data and adds new data.
/// </summary>
[Fact]
public async Task MergeSnapshotAsync_MergesWithExistingData()
{
// Arrange - Add initial data
await _context.Users.InsertAsync(new User { Id = "existing", Name = "Existing User", Age = 30 });
await _peerConfirmationStore.UpdateConfirmationAsync(
"peer-merge",
"source-a",
new HlcTimestamp(100, 0, "source-a"),
"target-hash-old");
await _peerConfirmationStore.UpdateConfirmationAsync(
"peer-local-only",
"source-local",
new HlcTimestamp(50, 0, "source-local"),
"target-local-hash");
await _context.SaveChangesAsync();
// Create snapshot with different data
var sourceDbPath = Path.Combine(Path.GetTempPath(), $"test-snapshot-source-{Guid.NewGuid()}.blite");
MemoryStream snapshotStream;
try
{
using var sourceContext = new SampleDbContext(sourceDbPath);
await sourceContext.Users.InsertAsync(new User { Id = "new-user", Name = "New User", Age = 25 });
await sourceContext.SaveChangesAsync();
var sourceConfigProvider = CreateConfigProvider("test-source-node");
var sourceVectorClock = new VectorClockService();
var sourceDocStore = new SampleDocumentStore(sourceContext, sourceConfigProvider, sourceVectorClock, NullLogger<SampleDocumentStore>.Instance);
var sourceSnapshotMetaStore = new BLiteSnapshotMetadataStore<SampleDbContext>(
sourceContext, NullLogger<BLiteSnapshotMetadataStore<SampleDbContext>>.Instance);
var sourceOplogStore = new BLiteOplogStore<SampleDbContext>(
sourceContext, sourceDocStore, new LastWriteWinsConflictResolver(),
sourceVectorClock,
sourceSnapshotMetaStore,
NullLogger<BLiteOplogStore<SampleDbContext>>.Instance);
var sourcePeerStore = new BLitePeerConfigurationStore<SampleDbContext>(
sourceContext, NullLogger<BLitePeerConfigurationStore<SampleDbContext>>.Instance);
var sourcePeerConfirmationStore = new BLitePeerOplogConfirmationStore<SampleDbContext>(
sourceContext,
NullLogger<BLitePeerOplogConfirmationStore<SampleDbContext>>.Instance);
await sourcePeerConfirmationStore.UpdateConfirmationAsync(
"peer-merge",
"source-a",
new HlcTimestamp(200, 1, "source-a"),
"source-hash-new");
await sourcePeerConfirmationStore.UpdateConfirmationAsync(
"peer-merge",
"source-b",
new HlcTimestamp(300, 0, "source-b"),
"source-hash-b");
var sourceSnapshotStore = new SnapshotStore(
sourceDocStore,
sourcePeerStore,
sourceOplogStore,
new LastWriteWinsConflictResolver(),
NullLogger<SnapshotStore>.Instance,
sourcePeerConfirmationStore);
snapshotStream = new MemoryStream();
await sourceSnapshotStore.CreateSnapshotAsync(snapshotStream);
snapshotStream.Position = 0;
}
finally
{
if (File.Exists(sourceDbPath))
try { File.Delete(sourceDbPath); } catch { }
}
// Act - Merge snapshot into existing data
await _snapshotStore.MergeSnapshotAsync(snapshotStream);
// Assert - Both users should exist
var existingUser = _context.Users.FindById("existing");
var newUser = _context.Users.FindById("new-user");
existingUser.ShouldNotBeNull();
newUser.ShouldNotBeNull();
existingUser.Name.ShouldBe("Existing User");
newUser.Name.ShouldBe("New User");
var confirmations = (await _peerConfirmationStore.GetConfirmationsAsync())
.OrderBy(c => c.PeerNodeId)
.ThenBy(c => c.SourceNodeId)
.ToList();
confirmations.Count.ShouldBe(3);
confirmations[0].PeerNodeId.ShouldBe("peer-local-only");
confirmations[0].SourceNodeId.ShouldBe("source-local");
confirmations[0].ConfirmedWall.ShouldBe(50);
confirmations[0].ConfirmedHash.ShouldBe("target-local-hash");
confirmations[1].PeerNodeId.ShouldBe("peer-merge");
confirmations[1].SourceNodeId.ShouldBe("source-a");
confirmations[1].ConfirmedWall.ShouldBe(200);
confirmations[1].ConfirmedLogic.ShouldBe(1);
confirmations[1].ConfirmedHash.ShouldBe("source-hash-new");
confirmations[2].PeerNodeId.ShouldBe("peer-merge");
confirmations[2].SourceNodeId.ShouldBe("source-b");
confirmations[2].ConfirmedWall.ShouldBe(300);
confirmations[2].ConfirmedHash.ShouldBe("source-hash-b");
}
/// <summary>
/// Verifies that replace can consume legacy snapshots that do not include peer confirmations.
/// </summary>
[Fact]
public async Task ReplaceDatabaseAsync_LegacySnapshotWithoutPeerConfirmations_IsSupported()
{
// Arrange
await _context.Users.InsertAsync(new User { Id = "legacy-user", Name = "Legacy User", Age = 33 });
await _context.SaveChangesAsync();
using var snapshotStream = new MemoryStream();
await _snapshotStore.CreateSnapshotAsync(snapshotStream);
snapshotStream.Position = 0;
var snapshotJson = await new StreamReader(snapshotStream).ReadToEndAsync();
var legacySnapshot = JsonNode.Parse(snapshotJson)!.AsObject();
legacySnapshot.Remove("PeerConfirmations");
using var legacyStream = new MemoryStream();
await using (var writer = new Utf8JsonWriter(legacyStream))
{
legacySnapshot.WriteTo(writer);
}
legacyStream.Position = 0;
// Act
await _snapshotStore.ReplaceDatabaseAsync(legacyStream);
// Assert
_context.Users.FindById("legacy-user").ShouldNotBeNull();
(await _peerConfirmationStore.GetConfirmationsAsync()).Count().ShouldBe(0);
}
/// <summary>
/// Verifies that snapshot creation succeeds for an empty database.
/// </summary>
[Fact]
public async Task CreateSnapshotAsync_HandlesEmptyDatabase()
{
// Act - Create snapshot from empty database
using var stream = new MemoryStream();
await _snapshotStore.CreateSnapshotAsync(stream);
// Assert - Should still produce valid JSON
(stream.Length > 0).ShouldBeTrue();
stream.Position = 0;
var json = await new StreamReader(stream).ReadToEndAsync();
var doc = JsonDocument.Parse(json);
var documents = doc.RootElement.GetProperty("Documents");
documents.GetArrayLength().ShouldBe(0);
}
/// <summary>
/// Verifies that snapshot creation includes oplog entries.
/// </summary>
[Fact]
public async Task CreateSnapshotAsync_IncludesOplogEntries()
{
// Arrange - Create some oplog entries via document changes
await _context.Users.InsertAsync(new User { Id = "op-user", Name = "Oplog User", Age = 20 });
await _context.SaveChangesAsync();
// Manually add an oplog entry to ensure it's captured
var oplogEntry = new OplogEntry(
"Users",
"manual-key",
OperationType.Put,
JsonDocument.Parse("{\"test\": true}").RootElement,
new HlcTimestamp(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), 0, "test-node"),
""
);
await _oplogStore.AppendOplogEntryAsync(oplogEntry);
// Act
using var stream = new MemoryStream();
await _snapshotStore.CreateSnapshotAsync(stream);
// Assert
stream.Position = 0;
var json = await new StreamReader(stream).ReadToEndAsync();
var doc = JsonDocument.Parse(json);
var oplog = doc.RootElement.GetProperty("Oplog");
(oplog.GetArrayLength() >= 1).ShouldBeTrue("Should have at least one oplog entry");
}
/// <summary>
/// Releases resources created for test execution.
/// </summary>
public void Dispose()
{
_documentStore?.Dispose();
_context?.Dispose();
if (File.Exists(_testDbPath))
{
try { File.Delete(_testDbPath); } catch { }
}
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(string nodeId)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
NodeId = nodeId,
TcpPort = 5000,
AuthToken = "test-token",
OplogRetentionHours = 24,
MaintenanceIntervalMinutes = 60
});
return configProvider;
}
}