Reformat/cleanup
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m10s

This commit is contained in:
Joseph Doherty
2026-02-21 07:53:53 -05:00
parent c6f6d9329a
commit 7ebc2cb567
160 changed files with 7258 additions and 7262 deletions

View File

@@ -1,23 +1,23 @@
<Solution> <Solution>
<Configurations> <Configurations>
<Platform Name="Any CPU" /> <Platform Name="Any CPU"/>
<Platform Name="x64" /> <Platform Name="x64"/>
<Platform Name="x86" /> <Platform Name="x86"/>
</Configurations> </Configurations>
<Folder Name="/samples/"> <Folder Name="/samples/">
<Project Path="samples/ZB.MOM.WW.CBDDC.Sample.Console/ZB.MOM.WW.CBDDC.Sample.Console.csproj" /> <Project Path="samples/ZB.MOM.WW.CBDDC.Sample.Console/ZB.MOM.WW.CBDDC.Sample.Console.csproj"/>
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.CBDDC.Hosting/ZB.MOM.WW.CBDDC.Hosting.csproj" /> <Project Path="src/ZB.MOM.WW.CBDDC.Hosting/ZB.MOM.WW.CBDDC.Hosting.csproj"/>
<Project Path="src/ZB.MOM.WW.CBDDC.Core/ZB.MOM.WW.CBDDC.Core.csproj" /> <Project Path="src/ZB.MOM.WW.CBDDC.Core/ZB.MOM.WW.CBDDC.Core.csproj"/>
<Project Path="src/ZB.MOM.WW.CBDDC.Network/ZB.MOM.WW.CBDDC.Network.csproj" /> <Project Path="src/ZB.MOM.WW.CBDDC.Network/ZB.MOM.WW.CBDDC.Network.csproj"/>
<Project Path="src/ZB.MOM.WW.CBDDC.Persistence/ZB.MOM.WW.CBDDC.Persistence.csproj" /> <Project Path="src/ZB.MOM.WW.CBDDC.Persistence/ZB.MOM.WW.CBDDC.Persistence.csproj"/>
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.CBDDC.Core.Tests/ZB.MOM.WW.CBDDC.Core.Tests.csproj" /> <Project Path="tests/ZB.MOM.WW.CBDDC.Core.Tests/ZB.MOM.WW.CBDDC.Core.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.CBDDC.E2E.Tests/ZB.MOM.WW.CBDDC.E2E.Tests.csproj" /> <Project Path="tests/ZB.MOM.WW.CBDDC.E2E.Tests/ZB.MOM.WW.CBDDC.E2E.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.CBDDC.Hosting.Tests/ZB.MOM.WW.CBDDC.Hosting.Tests.csproj" /> <Project Path="tests/ZB.MOM.WW.CBDDC.Hosting.Tests/ZB.MOM.WW.CBDDC.Hosting.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.CBDDC.Network.Tests/ZB.MOM.WW.CBDDC.Network.Tests.csproj" /> <Project Path="tests/ZB.MOM.WW.CBDDC.Network.Tests/ZB.MOM.WW.CBDDC.Network.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.CBDDC.Sample.Console.Tests/ZB.MOM.WW.CBDDC.Sample.Console.Tests.csproj" /> <Project Path="tests/ZB.MOM.WW.CBDDC.Sample.Console.Tests/ZB.MOM.WW.CBDDC.Sample.Console.Tests.csproj"/>
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers> <EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -1,37 +1,33 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Core; using Serilog.Context;
using ZB.MOM.WW.CBDDC.Core.Cache; using ZB.MOM.WW.CBDDC.Core.Cache;
using ZB.MOM.WW.CBDDC.Core.Diagnostics; using ZB.MOM.WW.CBDDC.Core.Diagnostics;
using ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Network;
using ZB.MOM.WW.CBDDC.Persistence.BLite;
using Microsoft.Extensions.DependencyInjection; // For IServiceProvider if needed
using Serilog.Context;
using ZB.MOM.WW.CBDDC.Sample.Console;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Network;
using ZB.MOM.WW.CBDDC.Network.Security;
// For IServiceProvider if needed
namespace ZB.MOM.WW.CBDDC.Sample.Console; namespace ZB.MOM.WW.CBDDC.Sample.Console;
public class ConsoleInteractiveService : BackgroundService public class ConsoleInteractiveService : BackgroundService
{ {
private readonly ILogger<ConsoleInteractiveService> _logger;
private readonly SampleDbContext _db;
private readonly ICBDDCNode _node;
private readonly IHostApplicationLifetime _lifetime;
// Auxiliary services for status/commands // Auxiliary services for status/commands
private readonly IDocumentCache _cache; private readonly IDocumentCache _cache;
private readonly IOfflineQueue _queue;
private readonly ICBDDCHealthCheck _healthCheck;
private readonly ISyncStatusTracker _syncTracker;
private readonly IServiceProvider _serviceProvider;
private readonly IPeerNodeConfigurationProvider _configProvider; private readonly IPeerNodeConfigurationProvider _configProvider;
private readonly SampleDbContext _db;
private readonly ICBDDCHealthCheck _healthCheck;
private readonly IHostApplicationLifetime _lifetime;
private readonly ILogger<ConsoleInteractiveService> _logger;
private readonly ICBDDCNode _node;
private readonly IOfflineQueue _queue;
private readonly IServiceProvider _serviceProvider;
private readonly ISyncStatusTracker _syncTracker;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ConsoleInteractiveService"/> class. /// Initializes a new instance of the <see cref="ConsoleInteractiveService" /> class.
/// </summary> /// </summary>
/// <param name="logger">The logger used by the interactive service.</param> /// <param name="logger">The logger used by the interactive service.</param>
/// <param name="db">The sample database context.</param> /// <param name="db">The sample database context.</param>
@@ -72,7 +68,7 @@ public class ConsoleInteractiveService : BackgroundService
{ {
var config = await _configProvider.GetConfiguration(); var config = await _configProvider.GetConfiguration();
System.Console.WriteLine($"--- Interactive Console ---"); System.Console.WriteLine("--- Interactive Console ---");
System.Console.WriteLine($"Node ID: {config.NodeId}"); System.Console.WriteLine($"Node ID: {config.NodeId}");
PrintHelp(); PrintHelp();
@@ -85,7 +81,7 @@ public class ConsoleInteractiveService : BackgroundService
continue; continue;
} }
var input = System.Console.ReadLine(); string? input = System.Console.ReadLine();
if (string.IsNullOrEmpty(input)) continue; if (string.IsNullOrEmpty(input)) continue;
try try
@@ -118,42 +114,53 @@ public class ConsoleInteractiveService : BackgroundService
System.Console.WriteLine(" [n]ew (auto), [s]pam (5x), [c]ount, [t]odos"); System.Console.WriteLine(" [n]ew (auto), [s]pam (5x), [c]ount, [t]odos");
System.Console.WriteLine(" [h]ealth, cac[h]e"); System.Console.WriteLine(" [h]ealth, cac[h]e");
System.Console.WriteLine(" [r]esolver [lww|merge], [demo] conflict"); System.Console.WriteLine(" [r]esolver [lww|merge], [demo] conflict");
} }
private async Task HandleInput(string input) private async Task HandleInput(string input)
{ {
var config = await _configProvider.GetConfiguration(); var config = await _configProvider.GetConfiguration();
if (input.StartsWith("n")) if (input.StartsWith("n"))
{ {
var ts = DateTime.Now.ToString("HH:mm:ss.fff"); var ts = DateTime.Now.ToString("HH:mm:ss.fff");
var user = new User { Id = Guid.NewGuid().ToString(), Name = $"User-{ts}", Age = new Random().Next(18, 90), Address = new Address { City = "AutoCity" } }; var user = new User
{
Id = Guid.NewGuid().ToString(), Name = $"User-{ts}", Age = new Random().Next(18, 90),
Address = new Address { City = "AutoCity" }
};
await _db.Users.InsertAsync(user); await _db.Users.InsertAsync(user);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
System.Console.WriteLine($"[+] Created {user.Name} with Id: {user.Id}..."); System.Console.WriteLine($"[+] Created {user.Name} with Id: {user.Id}...");
} }
else if (input.StartsWith("s")) else if (input.StartsWith("s"))
{ {
for (int i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
{ {
var ts = DateTime.Now.ToString("HH:mm:ss.fff"); var ts = DateTime.Now.ToString("HH:mm:ss.fff");
var user = new User { Id = Guid.NewGuid().ToString(), Name = $"User-{ts}", Age = new Random().Next(18, 90), Address = new Address { City = "SpamCity" } }; var user = new User
{
Id = Guid.NewGuid().ToString(), Name = $"User-{ts}", Age = new Random().Next(18, 90),
Address = new Address { City = "SpamCity" }
};
await _db.Users.InsertAsync(user); await _db.Users.InsertAsync(user);
System.Console.WriteLine($"[+] Created {user.Name} with Id: {user.Id}..."); System.Console.WriteLine($"[+] Created {user.Name} with Id: {user.Id}...");
await Task.Delay(100); await Task.Delay(100);
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
else if (input.StartsWith("c")) else if (input.StartsWith("c"))
{ {
var userCount = _db.Users.FindAll().Count(); int userCount = _db.Users.FindAll().Count();
var todoCount = _db.TodoLists.FindAll().Count(); int todoCount = _db.TodoLists.FindAll().Count();
System.Console.WriteLine($"Collection 'Users': {userCount} documents"); System.Console.WriteLine($"Collection 'Users': {userCount} documents");
System.Console.WriteLine($"Collection 'TodoLists': {todoCount} documents"); System.Console.WriteLine($"Collection 'TodoLists': {todoCount} documents");
} }
else if (input.StartsWith("p")) else if (input.StartsWith("p"))
{ {
var alice = new User { Id = Guid.NewGuid().ToString(), Name = "Alice", Age = 30, Address = new Address { City = "Paris" } }; var alice = new User
var bob = new User { Id = Guid.NewGuid().ToString(), Name = "Bob", Age = 25, Address = new Address { City = "Rome" } }; { Id = Guid.NewGuid().ToString(), Name = "Alice", Age = 30, Address = new Address { City = "Paris" } };
var bob = new User
{ Id = Guid.NewGuid().ToString(), Name = "Bob", Age = 25, Address = new Address { City = "Rome" } };
await _db.Users.InsertAsync(alice); await _db.Users.InsertAsync(alice);
await _db.Users.InsertAsync(bob); await _db.Users.InsertAsync(bob);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -162,17 +169,19 @@ public class ConsoleInteractiveService : BackgroundService
else if (input.StartsWith("g")) else if (input.StartsWith("g"))
{ {
System.Console.Write("Enter user Id: "); System.Console.Write("Enter user Id: ");
var id = System.Console.ReadLine(); string? id = System.Console.ReadLine();
if (!string.IsNullOrEmpty(id)) if (!string.IsNullOrEmpty(id))
{ {
var u = _db.Users.FindById(id); var u = _db.Users.FindById(id);
System.Console.WriteLine(u != null ? $"Got: {u.Name}, Age {u.Age}, City: {u.Address?.City}" : "Not found"); System.Console.WriteLine(u != null
? $"Got: {u.Name}, Age {u.Age}, City: {u.Address?.City}"
: "Not found");
} }
} }
else if (input.StartsWith("d")) else if (input.StartsWith("d"))
{ {
System.Console.Write("Enter user Id to delete: "); System.Console.Write("Enter user Id to delete: ");
var id = System.Console.ReadLine(); string? id = System.Console.ReadLine();
if (!string.IsNullOrEmpty(id)) if (!string.IsNullOrEmpty(id))
{ {
await _db.Users.DeleteAsync(id); await _db.Users.DeleteAsync(id);
@@ -183,8 +192,8 @@ public class ConsoleInteractiveService : BackgroundService
else if (input.StartsWith("l")) else if (input.StartsWith("l"))
{ {
var peers = _node.Discovery.GetActivePeers(); var peers = _node.Discovery.GetActivePeers();
var handshakeSvc = _serviceProvider.GetService<ZB.MOM.WW.CBDDC.Network.Security.IPeerHandshakeService>(); var handshakeSvc = _serviceProvider.GetService<IPeerHandshakeService>();
var secureIcon = handshakeSvc != null ? "🔒" : "🔓"; string secureIcon = handshakeSvc != null ? "🔒" : "🔓";
System.Console.WriteLine($"Active Peers ({secureIcon}):"); System.Console.WriteLine($"Active Peers ({secureIcon}):");
foreach (var p in peers) foreach (var p in peers)
@@ -203,7 +212,7 @@ public class ConsoleInteractiveService : BackgroundService
{ {
var health = await _healthCheck.CheckAsync(); var health = await _healthCheck.CheckAsync();
var syncStatus = _syncTracker.GetStatus(); var syncStatus = _syncTracker.GetStatus();
var handshakeSvc = _serviceProvider.GetService<ZB.MOM.WW.CBDDC.Network.Security.IPeerHandshakeService>(); var handshakeSvc = _serviceProvider.GetService<IPeerHandshakeService>();
System.Console.WriteLine("=== Health Check ==="); System.Console.WriteLine("=== Health Check ===");
System.Console.WriteLine($"Database: {(health.DatabaseHealthy ? "" : "")}"); System.Console.WriteLine($"Database: {(health.DatabaseHealthy ? "" : "")}");
@@ -216,17 +225,18 @@ public class ConsoleInteractiveService : BackgroundService
if (health.Errors.Any()) if (health.Errors.Any())
{ {
System.Console.WriteLine("Errors:"); System.Console.WriteLine("Errors:");
foreach (var err in health.Errors.Take(3)) System.Console.WriteLine($" - {err}"); foreach (string err in health.Errors.Take(3)) System.Console.WriteLine($" - {err}");
} }
} }
else if (input.StartsWith("ch") || input == "cache") else if (input.StartsWith("ch") || input == "cache")
{ {
var stats = _cache.GetStatistics(); var stats = _cache.GetStatistics();
System.Console.WriteLine($"=== Cache Stats ===\nSize: {stats.Size}\nHits: {stats.Hits}\nMisses: {stats.Misses}\nRate: {stats.HitRate:P1}"); System.Console.WriteLine(
$"=== Cache Stats ===\nSize: {stats.Size}\nHits: {stats.Hits}\nMisses: {stats.Misses}\nRate: {stats.HitRate:P1}");
} }
else if (input.StartsWith("r") && input.Contains("resolver")) else if (input.StartsWith("r") && input.Contains("resolver"))
{ {
var parts = input.Split(' '); string[] parts = input.Split(' ');
if (parts.Length > 1) if (parts.Length > 1)
{ {
var newResolver = parts[1].ToLower() switch var newResolver = parts[1].ToLower() switch
@@ -240,7 +250,7 @@ public class ConsoleInteractiveService : BackgroundService
{ {
// Note: Requires restart to fully apply. For demo, we inform user. // Note: Requires restart to fully apply. For demo, we inform user.
System.Console.WriteLine($"⚠️ Resolver changed to {parts[1].ToUpper()}. Restart node to apply."); System.Console.WriteLine($"⚠️ Resolver changed to {parts[1].ToUpper()}. Restart node to apply.");
System.Console.WriteLine($" (Current session continues with previous resolver)"); System.Console.WriteLine(" (Current session continues with previous resolver)");
} }
else else
{ {
@@ -262,7 +272,7 @@ public class ConsoleInteractiveService : BackgroundService
System.Console.WriteLine($"📋 {list.Name} ({list.Items.Count} items)"); System.Console.WriteLine($"📋 {list.Name} ({list.Items.Count} items)");
foreach (var item in list.Items) foreach (var item in list.Items)
{ {
var status = item.Completed ? "✓" : " "; string status = item.Completed ? "✓" : " ";
System.Console.WriteLine($" [{status}] {item.Task}"); System.Console.WriteLine($" [{status}] {item.Task}");
} }
} }
@@ -281,8 +291,8 @@ public class ConsoleInteractiveService : BackgroundService
Name = "Shopping List", Name = "Shopping List",
Items = new List<TodoItem> Items = new List<TodoItem>
{ {
new TodoItem { Task = "Buy milk", Completed = false }, new() { Task = "Buy milk", Completed = false },
new TodoItem { Task = "Buy bread", Completed = false } new() { Task = "Buy bread", Completed = false }
} }
}; };
@@ -325,24 +335,20 @@ public class ConsoleInteractiveService : BackgroundService
System.Console.WriteLine($" List: {merged.Name}"); System.Console.WriteLine($" List: {merged.Name}");
foreach (var item in merged.Items) foreach (var item in merged.Items)
{ {
var status = item.Completed ? "✓" : " "; string status = item.Completed ? "✓" : " ";
System.Console.WriteLine($" [{status}] {item.Task}"); System.Console.WriteLine($" [{status}] {item.Task}");
} }
var resolver = _serviceProvider.GetRequiredService<IConflictResolver>(); var resolver = _serviceProvider.GetRequiredService<IConflictResolver>();
var resolverType = resolver.GetType().Name; string resolverType = resolver.GetType().Name;
System.Console.WriteLine($"\n Resolution Strategy: {resolverType}"); System.Console.WriteLine($"\n Resolution Strategy: {resolverType}");
if (resolverType.Contains("Recursive")) if (resolverType.Contains("Recursive"))
{
System.Console.WriteLine(" → Items merged by 'id', both edits preserved"); System.Console.WriteLine(" → Items merged by 'id', both edits preserved");
}
else else
{
System.Console.WriteLine(" → Last write wins, Node B changes override Node A"); System.Console.WriteLine(" → Last write wins, Node B changes override Node A");
}
} }
System.Console.WriteLine("\n✓ Demo complete. Run 'todos' to see all lists.\n"); System.Console.WriteLine("\n✓ Demo complete. Run 'todos' to see all lists.\n");
} }
} }

View File

@@ -1,33 +1,26 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Core.Cache;
using ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Core.Diagnostics;
using ZB.MOM.WW.CBDDC.Core.Resilience;
using ZB.MOM.WW.CBDDC.Network;
using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Persistence.BLite;
using ZB.MOM.WW.CBDDC.Sample.Console;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog; using Serilog;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Network;
using ZB.MOM.WW.CBDDC.Persistence.BLite;
namespace ZB.MOM.WW.CBDDC.Sample.Console; namespace ZB.MOM.WW.CBDDC.Sample.Console;
// Local User/Address classes removed in favor of Shared project // Local User/Address classes removed in favor of Shared project
class Program internal class Program
{ {
static async Task Main(string[] args) private static async Task Main(string[] args)
{ {
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
// Configuration // Configuration
builder.Configuration.SetBasePath(Directory.GetCurrentDirectory()) builder.Configuration.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); .AddJsonFile("appsettings.json", true, true);
// Logging // Logging
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
@@ -38,39 +31,36 @@ class Program
.Enrich.WithProperty("Application", "CBDDC.Sample.Console") .Enrich.WithProperty("Application", "CBDDC.Sample.Console")
.WriteTo.Console()); .WriteTo.Console());
var randomPort = new Random().Next(1000, 9999); int randomPort = new Random().Next(1000, 9999);
// Node ID // Node ID
string nodeId = args.Length > 0 ? args[0] : ("node-" + randomPort); string nodeId = args.Length > 0 ? args[0] : "node-" + randomPort;
int tcpPort = args.Length > 1 ? int.Parse(args[1]) : randomPort; int tcpPort = args.Length > 1 ? int.Parse(args[1]) : randomPort;
// Conflict Resolution Strategy (can be switched at runtime via service replacement) // Conflict Resolution Strategy (can be switched at runtime via service replacement)
var useRecursiveMerge = args.Contains("--merge"); bool useRecursiveMerge = args.Contains("--merge");
if (useRecursiveMerge) if (useRecursiveMerge) builder.Services.AddSingleton<IConflictResolver, RecursiveNodeMergeConflictResolver>();
{
builder.Services.AddSingleton<IConflictResolver, RecursiveNodeMergeConflictResolver>();
}
IPeerNodeConfigurationProvider peerNodeConfigurationProvider = new StaticPeerNodeConfigurationProvider( IPeerNodeConfigurationProvider peerNodeConfigurationProvider = new StaticPeerNodeConfigurationProvider(
new PeerNodeConfiguration new PeerNodeConfiguration
{ {
NodeId = nodeId, NodeId = nodeId,
TcpPort = tcpPort, TcpPort = tcpPort,
AuthToken = "Test-Cluster-Key", AuthToken = "Test-Cluster-Key"
//KnownPeers = builder.Configuration.GetSection("CBDDC:KnownPeers").Get<List<KnownPeerConfiguration>>() ?? new() //KnownPeers = builder.Configuration.GetSection("CBDDC:KnownPeers").Get<List<KnownPeerConfiguration>>() ?? new()
}); });
builder.Services.AddSingleton<IPeerNodeConfigurationProvider>(peerNodeConfigurationProvider); builder.Services.AddSingleton(peerNodeConfigurationProvider);
// Database path // Database path
var dataPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data"); string dataPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data");
Directory.CreateDirectory(dataPath); Directory.CreateDirectory(dataPath);
var databasePath = Path.Combine(dataPath, $"{nodeId}.blite"); string databasePath = Path.Combine(dataPath, $"{nodeId}.blite");
// Register CBDDC Services using Fluent Extensions with BLite, SampleDbContext, and SampleDocumentStore // Register CBDDC Services using Fluent Extensions with BLite, SampleDbContext, and SampleDocumentStore
builder.Services.AddCBDDCCore() builder.Services.AddCBDDCCore()
.AddCBDDCBLite<SampleDbContext, SampleDocumentStore>(sp => new SampleDbContext(databasePath)) .AddCBDDCBLite<SampleDbContext, SampleDocumentStore>(sp => new SampleDbContext(databasePath))
.AddCBDDCNetwork<StaticPeerNodeConfigurationProvider>(); // useHostedService = true by default .AddCBDDCNetwork<StaticPeerNodeConfigurationProvider>(); // useHostedService = true by default
builder.Services.AddHostedService<ConsoleInteractiveService>(); // Runs the Input Loop builder.Services.AddHostedService<ConsoleInteractiveService>(); // Runs the Input Loop
@@ -86,12 +76,7 @@ class Program
private class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider private class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider
{ {
/// <summary> /// <summary>
/// Gets or sets the current peer node configuration. /// Initializes a new instance of the <see cref="StaticPeerNodeConfigurationProvider" /> class.
/// </summary>
public PeerNodeConfiguration Configuration { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="StaticPeerNodeConfigurationProvider"/> class.
/// </summary> /// </summary>
/// <param name="configuration">The initial peer node configuration.</param> /// <param name="configuration">The initial peer node configuration.</param>
public StaticPeerNodeConfigurationProvider(PeerNodeConfiguration configuration) public StaticPeerNodeConfigurationProvider(PeerNodeConfiguration configuration)
@@ -100,12 +85,17 @@ class Program
} }
/// <summary> /// <summary>
/// Occurs when the peer node configuration changes. /// Gets or sets the current peer node configuration.
/// </summary>
public PeerNodeConfiguration Configuration { get; }
/// <summary>
/// Occurs when the peer node configuration changes.
/// </summary> /// </summary>
public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged; public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged;
/// <summary> /// <summary>
/// Gets the current peer node configuration. /// Gets the current peer node configuration.
/// </summary> /// </summary>
/// <returns>A task that returns the current configuration.</returns> /// <returns>A task that returns the current configuration.</returns>
public Task<PeerNodeConfiguration> GetConfiguration() public Task<PeerNodeConfiguration> GetConfiguration()
@@ -114,7 +104,7 @@ class Program
} }
/// <summary> /// <summary>
/// Raises the configuration changed event. /// Raises the configuration changed event.
/// </summary> /// </summary>
/// <param name="newConfig">The new configuration value.</param> /// <param name="newConfig">The new configuration value.</param>
protected virtual void OnConfigurationChanged(PeerNodeConfiguration newConfig) protected virtual void OnConfigurationChanged(PeerNodeConfiguration newConfig)
@@ -122,5 +112,4 @@ class Program
ConfigurationChanged?.Invoke(this, newConfig); ConfigurationChanged?.Invoke(this, newConfig);
} }
} }
}
}

View File

@@ -5,21 +5,25 @@ This sample demonstrates the core features of CBDDC, a distributed peer-to-peer
## Features Demonstrated ## Features Demonstrated
### 🔑 Primary Keys & Auto-Generation ### 🔑 Primary Keys & Auto-Generation
- Automatic GUID generation for entities - Automatic GUID generation for entities
- Convention-based key detection (`Id` property) - Convention-based key detection (`Id` property)
- `[PrimaryKey]` attribute support - `[PrimaryKey]` attribute support
### 🎯 Generic Type-Safe API ### 🎯 Generic Type-Safe API
- `Collection<T>()` for compile-time type safety - `Collection<T>()` for compile-time type safety
- Keyless `Put(entity)` with auto-key extraction - Keyless `Put(entity)` with auto-key extraction
- IntelliSense-friendly operations - IntelliSense-friendly operations
### 🔍 LINQ Query Support ### 🔍 LINQ Query Support
- Expression-based queries - Expression-based queries
- Paging and sorting - Paging and sorting
- Complex predicates (>, >=, ==, !=, nested properties) - Complex predicates (>, >=, ==, !=, nested properties)
### 🌐 Network Synchronization ### 🌐 Network Synchronization
- UDP peer discovery - UDP peer discovery
- TCP synchronization - TCP synchronization
- Automatic conflict resolution (Last-Write-Wins) - Automatic conflict resolution (Last-Write-Wins)
@@ -35,16 +39,19 @@ dotnet run
### Multi-Node (Peer-to-Peer) ### Multi-Node (Peer-to-Peer)
Terminal 1: Terminal 1:
```bash ```bash
dotnet run -- --node-id node1 --tcp-port 5001 --udp-port 6001 dotnet run -- --node-id node1 --tcp-port 5001 --udp-port 6001
``` ```
Terminal 2: Terminal 2:
```bash ```bash
dotnet run -- --node-id node2 --tcp-port 5002 --udp-port 6002 dotnet run -- --node-id node2 --tcp-port 5002 --udp-port 6002
``` ```
Terminal 3: Terminal 3:
```bash ```bash
dotnet run -- --node-id node3 --tcp-port 5003 --udp-port 6003 dotnet run -- --node-id node3 --tcp-port 5003 --udp-port 6003
``` ```
@@ -53,20 +60,20 @@ Changes made on any node will automatically sync to all peers!
## Available Commands ## Available Commands
| Command | Description | | Command | Description |
|---------|-------------| |---------|----------------------------------------|
| `p` | Put Alice and Bob (auto-generated IDs) | | `p` | Put Alice and Bob (auto-generated IDs) |
| `g` | Get user by ID (prompts for ID) | | `g` | Get user by ID (prompts for ID) |
| `d` | Delete user by ID (prompts for ID) | | `d` | Delete user by ID (prompts for ID) |
| `n` | Create new user with auto-generated ID | | `n` | Create new user with auto-generated ID |
| `s` | Spam 5 users with auto-generated IDs | | `s` | Spam 5 users with auto-generated IDs |
| `c` | Count total documents | | `c` | Count total documents |
| `f` | Demo various Find queries | | `f` | Demo various Find queries |
| `f2` | Demo Find with paging (skip/take) | | `f2` | Demo Find with paging (skip/take) |
| `a` | Demo auto-generated primary keys | | `a` | Demo auto-generated primary keys |
| `t` | Demo generic typed API | | `t` | Demo generic typed API |
| `l` | List active peers | | `l` | List active peers |
| `q` | Quit | | `q` | Quit |
## Example Session ## Example Session

View File

@@ -2,28 +2,13 @@
using BLite.Core.Metadata; using BLite.Core.Metadata;
using BLite.Core.Storage; using BLite.Core.Storage;
using ZB.MOM.WW.CBDDC.Persistence.BLite; using ZB.MOM.WW.CBDDC.Persistence.BLite;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Sample.Console; namespace ZB.MOM.WW.CBDDC.Sample.Console;
public partial class SampleDbContext : CBDDCDocumentDbContext public class SampleDbContext : CBDDCDocumentDbContext
{ {
/// <summary> /// <summary>
/// Gets the users collection. /// Initializes a new instance of the SampleDbContext class using the specified database file path.
/// </summary>
public DocumentCollection<string, User> Users { get; private set; } = null!;
/// <summary>
/// Gets the todo lists collection.
/// </summary>
public DocumentCollection<string, TodoList> TodoLists { get; private set; } = null!;
/// <summary>
/// Initializes a new instance of the SampleDbContext class using the specified database file path.
/// </summary> /// </summary>
/// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param> /// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param>
public SampleDbContext(string databasePath) : base(databasePath) public SampleDbContext(string databasePath) : base(databasePath)
@@ -31,8 +16,8 @@ public partial class SampleDbContext : CBDDCDocumentDbContext
} }
/// <summary> /// <summary>
/// Initializes a new instance of the SampleDbContext class using the specified database file path and page file /// Initializes a new instance of the SampleDbContext class using the specified database file path and page file
/// configuration. /// configuration.
/// </summary> /// </summary>
/// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param> /// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param>
/// <param name="config">The configuration settings for the page file. Cannot be null.</param> /// <param name="config">The configuration settings for the page file. Cannot be null.</param>
@@ -40,6 +25,16 @@ public partial class SampleDbContext : CBDDCDocumentDbContext
{ {
} }
/// <summary>
/// Gets the users collection.
/// </summary>
public DocumentCollection<string, User> Users { get; private set; } = null!;
/// <summary>
/// Gets the todo lists collection.
/// </summary>
public DocumentCollection<string, TodoList> TodoLists { get; private set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -52,4 +47,4 @@ public partial class SampleDbContext : CBDDCDocumentDbContext
.ToCollection("TodoLists") .ToCollection("TodoLists")
.HasKey(t => t.Id); .HasKey(t => t.Id);
} }
} }

View File

@@ -1,16 +1,15 @@
using ZB.MOM.WW.CBDDC.Core; using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Core.Sync; using ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Persistence.BLite; using ZB.MOM.WW.CBDDC.Persistence.BLite;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace ZB.MOM.WW.CBDDC.Sample.Console; namespace ZB.MOM.WW.CBDDC.Sample.Console;
/// <summary> /// <summary>
/// Document store implementation for CBDDC Sample using BLite persistence. /// Document store implementation for CBDDC Sample using BLite persistence.
/// Extends BLiteDocumentStore to automatically handle Oplog creation via CDC. /// Extends BLiteDocumentStore to automatically handle Oplog creation via CDC.
/// </summary> /// </summary>
public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext> public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
{ {
@@ -18,7 +17,7 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
private const string TodoListsCollection = "TodoLists"; private const string TodoListsCollection = "TodoLists";
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SampleDocumentStore"/> class. /// Initializes a new instance of the <see cref="SampleDocumentStore" /> class.
/// </summary> /// </summary>
/// <param name="context">The sample database context.</param> /// <param name="context">The sample database context.</param>
/// <param name="configProvider">The peer node configuration provider.</param> /// <param name="configProvider">The peer node configuration provider.</param>
@@ -37,6 +36,16 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
WatchCollection(TodoListsCollection, context.TodoLists, t => t.Id); WatchCollection(TodoListsCollection, context.TodoLists, t => t.Id);
} }
#region Helper Methods
private static JsonElement? SerializeEntity<T>(T? entity) where T : class
{
if (entity == null) return null;
return JsonSerializer.SerializeToElement(entity);
}
#endregion
#region Abstract Method Implementations #region Abstract Method Implementations
/// <inheritdoc /> /// <inheritdoc />
@@ -49,12 +58,10 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
/// <inheritdoc /> /// <inheritdoc />
protected override async Task ApplyContentToEntitiesBatchAsync( protected override async Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents, CancellationToken cancellationToken) IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken)
{ {
foreach (var (collection, key, content) in documents) foreach ((string collection, string key, var content) in documents) UpsertEntity(collection, key, content);
{
UpsertEntity(collection, key, content);
}
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
@@ -91,10 +98,10 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
protected override Task<JsonElement?> GetEntityAsJsonAsync( protected override Task<JsonElement?> GetEntityAsJsonAsync(
string collection, string key, CancellationToken cancellationToken) string collection, string key, CancellationToken cancellationToken)
{ {
return Task.FromResult<JsonElement?>(collection switch return Task.FromResult(collection switch
{ {
UsersCollection => SerializeEntity(_context.Users.Find(u => u.Id == key).FirstOrDefault()), UsersCollection => SerializeEntity(_context.Users.Find(u => u.Id == key).FirstOrDefault()),
TodoListsCollection => SerializeEntity(_context.TodoLists.Find(t => t.Id == key).FirstOrDefault()), TodoListsCollection => SerializeEntity(_context.TodoLists.Find(t => t.Id == key).FirstOrDefault()),
_ => null _ => null
}); });
} }
@@ -111,10 +118,7 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
protected override async Task RemoveEntitiesBatchAsync( protected override async Task RemoveEntitiesBatchAsync(
IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken) IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken)
{ {
foreach (var (collection, key) in documents) foreach ((string collection, string key) in documents) DeleteEntity(collection, key);
{
DeleteEntity(collection, key);
}
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
@@ -140,25 +144,15 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
{ {
return await Task.Run(() => collection switch return await Task.Run(() => collection switch
{ {
UsersCollection => _context.Users.FindAll() UsersCollection => _context.Users.FindAll()
.Select(u => (u.Id, SerializeEntity(u)!.Value)), .Select(u => (u.Id, SerializeEntity(u)!.Value)),
TodoListsCollection => _context.TodoLists.FindAll() TodoListsCollection => _context.TodoLists.FindAll()
.Select(t => (t.Id, SerializeEntity(t)!.Value)), .Select(t => (t.Id, SerializeEntity(t)!.Value)),
_ => Enumerable.Empty<(string, JsonElement)>() _ => Enumerable.Empty<(string, JsonElement)>()
}, cancellationToken); }, cancellationToken);
} }
#endregion #endregion
}
#region Helper Methods
private static JsonElement? SerializeEntity<T>(T? entity) where T : class
{
if (entity == null) return null;
return JsonSerializer.SerializeToElement(entity);
}
#endregion
}

View File

@@ -1,23 +1,22 @@
using System.Collections.Generic; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.CBDDC.Sample.Console;
namespace ZB.MOM.WW.CBDDC.Sample.Console;
public class TodoList public class TodoList
{ {
/// <summary> /// <summary>
/// Gets or sets the document identifier. /// Gets or sets the document identifier.
/// </summary> /// </summary>
[Key] [Key]
public string Id { get; set; } = Guid.NewGuid().ToString(); public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary> /// <summary>
/// Gets or sets the list name. /// Gets or sets the list name.
/// </summary> /// </summary>
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the todo items in the list. /// Gets or sets the todo items in the list.
/// </summary> /// </summary>
public List<TodoItem> Items { get; set; } = new(); public List<TodoItem> Items { get; set; } = new();
} }
@@ -25,17 +24,17 @@ public class TodoList
public class TodoItem public class TodoItem
{ {
/// <summary> /// <summary>
/// Gets or sets the task description. /// Gets or sets the task description.
/// </summary> /// </summary>
public string Task { get; set; } = string.Empty; public string Task { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the task is completed. /// Gets or sets a value indicating whether the task is completed.
/// </summary> /// </summary>
public bool Completed { get; set; } public bool Completed { get; set; }
/// <summary> /// <summary>
/// Gets or sets the UTC creation timestamp. /// Gets or sets the UTC creation timestamp.
/// </summary> /// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }

View File

@@ -1,27 +1,27 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.CBDDC.Sample.Console; namespace ZB.MOM.WW.CBDDC.Sample.Console;
public class User public class User
{ {
/// <summary> /// <summary>
/// Gets or sets the unique user identifier. /// Gets or sets the unique user identifier.
/// </summary> /// </summary>
[Key] [Key]
public string Id { get; set; } = ""; public string Id { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the user name. /// Gets or sets the user name.
/// </summary> /// </summary>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary> /// <summary>
/// Gets or sets the user age. /// Gets or sets the user age.
/// </summary> /// </summary>
public int Age { get; set; } public int Age { get; set; }
/// <summary> /// <summary>
/// Gets or sets the user address. /// Gets or sets the user address.
/// </summary> /// </summary>
public Address? Address { get; set; } public Address? Address { get; set; }
} }
@@ -29,7 +29,7 @@ public class User
public class Address public class Address
{ {
/// <summary> /// <summary>
/// Gets or sets the city value. /// Gets or sets the city value.
/// </summary> /// </summary>
public string? City { get; set; } public string? City { get; set; }
} }

View File

@@ -1,41 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="Lifter.Core" Version="1.1.0" /> <PackageReference Include="Lifter.Core" Version="1.1.0"/>
<PackageReference Include="BLite.SourceGenerators" Version="1.3.1"> <PackageReference Include="BLite.SourceGenerators" Version="1.3.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Core\ZB.MOM.WW.CBDDC.Core.csproj" /> <ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Core\ZB.MOM.WW.CBDDC.Core.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Network\ZB.MOM.WW.CBDDC.Network.csproj" /> <ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Network\ZB.MOM.WW.CBDDC.Network.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Persistence\ZB.MOM.WW.CBDDC.Persistence.csproj" /> <ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Persistence\ZB.MOM.WW.CBDDC.Persistence.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0"/>
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" /> <PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.json"> <None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<AssemblyName>ZB.MOM.WW.CBDDC.Sample.Console</AssemblyName> <AssemblyName>ZB.MOM.WW.CBDDC.Sample.Console</AssemblyName>
<RootNamespace>ZB.MOM.WW.CBDDC.Sample.Console</RootNamespace> <RootNamespace>ZB.MOM.WW.CBDDC.Sample.Console</RootNamespace>
<PackageId>ZB.MOM.WW.CBDDC.Sample.Console</PackageId> <PackageId>ZB.MOM.WW.CBDDC.Sample.Console</PackageId>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -1,51 +1,51 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft": "Warning", "Microsoft": "Warning",
"System": "Warning", "System": "Warning",
"CBDDC": "Information", "CBDDC": "Information",
"ZB.MOM.WW.CBDDC.Network.SyncOrchestrator": "Information", "ZB.MOM.WW.CBDDC.Network.SyncOrchestrator": "Information",
"ZB.MOM.WW.CBDDC.Core.Storage.OplogCoordinator": "Warning", "ZB.MOM.WW.CBDDC.Core.Storage.OplogCoordinator": "Warning",
"ZB.MOM.WW.CBDDC.Persistence": "Warning" "ZB.MOM.WW.CBDDC.Persistence": "Warning"
}
},
"CBDDC": {
"Network": {
"TcpPort": 5001,
"UdpPort": 6000,
"AuthToken": "demo-secret-key",
"ConnectionTimeoutMs": 5000,
"RetryAttempts": 3,
"RetryDelayMs": 1000,
"LocalhostOnly": false
},
"Persistence": {
"DatabasePath": "data/cbddc.db",
"EnableWalMode": true,
"CacheSizeMb": 50,
"EnableAutoBackup": true,
"BackupPath": "backups/",
"BusyTimeoutMs": 5000
},
"Sync": {
"SyncIntervalMs": 5000,
"BatchSize": 100,
"EnableOfflineQueue": true,
"MaxQueueSize": 1000
},
"Logging": {
"LogLevel": "Information",
"LogFilePath": "logs/cbddc.log",
"MaxLogFileSizeMb": 10,
"MaxLogFiles": 5
},
"KnownPeers": [
{
"NodeId": "AspNetSampleNode",
"Host": "localhost",
"Port": 6001
}
]
} }
},
"CBDDC": {
"Network": {
"TcpPort": 5001,
"UdpPort": 6000,
"AuthToken": "demo-secret-key",
"ConnectionTimeoutMs": 5000,
"RetryAttempts": 3,
"RetryDelayMs": 1000,
"LocalhostOnly": false
},
"Persistence": {
"DatabasePath": "data/cbddc.db",
"EnableWalMode": true,
"CacheSizeMb": 50,
"EnableAutoBackup": true,
"BackupPath": "backups/",
"BusyTimeoutMs": 5000
},
"Sync": {
"SyncIntervalMs": 5000,
"BatchSize": 100,
"EnableOfflineQueue": true,
"MaxQueueSize": 1000
},
"Logging": {
"LogLevel": "Information",
"LogFilePath": "logs/cbddc.log",
"MaxLogFileSizeMb": 10,
"MaxLogFiles": 5
},
"KnownPeers": [
{
"NodeId": "AspNetSampleNode",
"Host": "localhost",
"Port": 6001
}
]
}
} }

View File

@@ -1,76 +1,75 @@
using System; using System.Collections.Generic;
using System.Collections.Generic; using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Core.Network; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging; using ZB.MOM.WW.CBDDC.Core.Network;
using Microsoft.Extensions.Logging.Abstractions;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Cache; namespace ZB.MOM.WW.CBDDC.Core.Cache;
/// <summary> /// <summary>
/// LRU cache entry with linked list node. /// LRU cache entry with linked list node.
/// </summary> /// </summary>
internal class CacheEntry internal class CacheEntry
{ {
/// <summary> /// <summary>
/// Gets the cached document. /// Initializes a new instance of the <see cref="CacheEntry" /> class.
/// </summary> /// </summary>
public Document Document { get; } /// <param name="document">The cached document.</param>
/// <param name="node">The linked-list node used for LRU tracking.</param>
/// <summary> public CacheEntry(Document document, LinkedListNode<string> node)
/// Gets the linked-list node used for LRU tracking. {
/// </summary> Document = document;
public LinkedListNode<string> Node { get; } Node = node;
/// <summary>
/// Initializes a new instance of the <see cref="CacheEntry"/> class.
/// </summary>
/// <param name="document">The cached document.</param>
/// <param name="node">The linked-list node used for LRU tracking.</param>
public CacheEntry(Document document, LinkedListNode<string> node)
{
Document = document;
Node = node;
} }
/// <summary>
/// Gets the cached document.
/// </summary>
public Document Document { get; }
/// <summary>
/// Gets the linked-list node used for LRU tracking.
/// </summary>
public LinkedListNode<string> Node { get; }
} }
/// <summary> /// <summary>
/// In-memory LRU cache for documents. /// In-memory LRU cache for documents.
/// </summary> /// </summary>
public class DocumentCache : IDocumentCache public class DocumentCache : IDocumentCache
{ {
private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider; private readonly Dictionary<string, CacheEntry> _cache = new();
private readonly Dictionary<string, CacheEntry> _cache = new();
private readonly LinkedList<string> _lru = new();
private readonly ILogger<DocumentCache> _logger;
private readonly object _lock = new(); private readonly object _lock = new();
private readonly ILogger<DocumentCache> _logger;
private readonly LinkedList<string> _lru = new();
private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider;
// Statistics // Statistics
private long _hits = 0; private long _hits;
private long _misses = 0; private long _misses;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentCache"/> class.
/// </summary>
/// <param name="peerNodeConfigurationProvider">The configuration provider used for cache size limits.</param>
/// <param name="logger">The logger instance.</param>
public DocumentCache(IPeerNodeConfigurationProvider peerNodeConfigurationProvider, ILogger<DocumentCache>? logger = null)
{
_peerNodeConfigurationProvider = peerNodeConfigurationProvider;
_logger = logger ?? NullLogger<DocumentCache>.Instance;
}
/// <summary> /// <summary>
/// Gets a document from cache. /// Initializes a new instance of the <see cref="DocumentCache" /> class.
/// </summary> /// </summary>
/// <param name="collection">The document collection name.</param> /// <param name="peerNodeConfigurationProvider">The configuration provider used for cache size limits.</param>
/// <param name="key">The document key.</param> /// <param name="logger">The logger instance.</param>
/// <returns>A task whose result is the cached document, or <see langword="null"/> if not found.</returns> public DocumentCache(IPeerNodeConfigurationProvider peerNodeConfigurationProvider,
public async Task<Document?> Get(string collection, string key) ILogger<DocumentCache>? logger = null)
{ {
lock (_lock) _peerNodeConfigurationProvider = peerNodeConfigurationProvider;
{ _logger = logger ?? NullLogger<DocumentCache>.Instance;
}
/// <summary>
/// Gets a document from cache.
/// </summary>
/// <param name="collection">The document collection name.</param>
/// <param name="key">The document key.</param>
/// <returns>A task whose result is the cached document, or <see langword="null" /> if not found.</returns>
public async Task<Document?> Get(string collection, string key)
{
lock (_lock)
{
var cacheKey = $"{collection}:{key}"; var cacheKey = $"{collection}:{key}";
if (_cache.TryGetValue(cacheKey, out var entry)) if (_cache.TryGetValue(cacheKey, out var entry))
@@ -90,16 +89,16 @@ public class DocumentCache : IDocumentCache
} }
} }
/// <summary> /// <summary>
/// Sets a document in cache. /// Sets a document in cache.
/// </summary> /// </summary>
/// <param name="collection">The document collection name.</param> /// <param name="collection">The document collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <param name="document">The document to cache.</param> /// <param name="document">The document to cache.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public async Task Set(string collection, string key, Document document) public async Task Set(string collection, string key, Document document)
{ {
var peerConfig = await _peerNodeConfigurationProvider.GetConfiguration(); var peerConfig = await _peerNodeConfigurationProvider.GetConfiguration();
lock (_lock) lock (_lock)
{ {
@@ -118,7 +117,7 @@ public class DocumentCache : IDocumentCache
// Evict if full // Evict if full
if (_cache.Count >= peerConfig.MaxDocumentCacheSize) if (_cache.Count >= peerConfig.MaxDocumentCacheSize)
{ {
var oldest = _lru.Last!.Value; string oldest = _lru.Last!.Value;
_lru.RemoveLast(); _lru.RemoveLast();
_cache.Remove(oldest); _cache.Remove(oldest);
_logger.LogTrace("Evicted oldest cache entry {Key}", oldest); _logger.LogTrace("Evicted oldest cache entry {Key}", oldest);
@@ -130,15 +129,15 @@ public class DocumentCache : IDocumentCache
} }
} }
/// <summary> /// <summary>
/// Removes a document from cache. /// Removes a document from cache.
/// </summary> /// </summary>
/// <param name="collection">The document collection name.</param> /// <param name="collection">The document collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
public void Remove(string collection, string key) public void Remove(string collection, string key)
{ {
lock (_lock) lock (_lock)
{ {
var cacheKey = $"{collection}:{key}"; var cacheKey = $"{collection}:{key}";
if (_cache.TryGetValue(cacheKey, out var entry)) if (_cache.TryGetValue(cacheKey, out var entry))
@@ -151,13 +150,13 @@ public class DocumentCache : IDocumentCache
} }
/// <summary> /// <summary>
/// Clears all cached documents. /// Clears all cached documents.
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
lock (_lock) lock (_lock)
{ {
var count = _cache.Count; int count = _cache.Count;
_cache.Clear(); _cache.Clear();
_lru.Clear(); _lru.Clear();
_logger.LogInformation("Cleared cache ({Count} entries)", count); _logger.LogInformation("Cleared cache ({Count} entries)", count);
@@ -165,15 +164,15 @@ public class DocumentCache : IDocumentCache
} }
/// <summary> /// <summary>
/// Gets cache statistics. /// Gets cache statistics.
/// </summary> /// </summary>
public (long Hits, long Misses, int Size, double HitRate) GetStatistics() public (long Hits, long Misses, int Size, double HitRate) GetStatistics()
{ {
lock (_lock) lock (_lock)
{ {
var total = _hits + _misses; long total = _hits + _misses;
var hitRate = total > 0 ? (double)_hits / total : 0; double hitRate = total > 0 ? (double)_hits / total : 0;
return (_hits, _misses, _cache.Count, hitRate); return (_hits, _misses, _cache.Count, hitRate);
} }
} }
} }

View File

@@ -1,45 +1,44 @@
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Cache namespace ZB.MOM.WW.CBDDC.Core.Cache;
/// <summary>
/// Defines operations for caching documents by collection and key.
/// </summary>
public interface IDocumentCache
{ {
/// <summary> /// <summary>
/// Defines operations for caching documents by collection and key. /// Clears all cached documents.
/// </summary> /// </summary>
public interface IDocumentCache void Clear();
{
/// <summary>
/// Clears all cached documents.
/// </summary>
void Clear();
/// <summary> /// <summary>
/// Gets a cached document by collection and key. /// Gets a cached document by collection and key.
/// </summary> /// </summary>
/// <param name="collection">The collection name.</param> /// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <returns>The cached document, or <see langword="null"/> if not found.</returns> /// <returns>The cached document, or <see langword="null" /> if not found.</returns>
Task<Document?> Get(string collection, string key); Task<Document?> Get(string collection, string key);
/// <summary> /// <summary>
/// Gets cache hit/miss statistics. /// Gets cache hit/miss statistics.
/// </summary> /// </summary>
/// <returns>A tuple containing hits, misses, current size, and hit rate.</returns> /// <returns>A tuple containing hits, misses, current size, and hit rate.</returns>
(long Hits, long Misses, int Size, double HitRate) GetStatistics(); (long Hits, long Misses, int Size, double HitRate) GetStatistics();
/// <summary> /// <summary>
/// Removes a cached document by collection and key. /// Removes a cached document by collection and key.
/// </summary> /// </summary>
/// <param name="collection">The collection name.</param> /// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
void Remove(string collection, string key); void Remove(string collection, string key);
/// <summary> /// <summary>
/// Adds or updates a cached document. /// Adds or updates a cached document.
/// </summary> /// </summary>
/// <param name="collection">The collection name.</param> /// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <param name="document">The document to cache.</param> /// <param name="document">The document to cache.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Set(string collection, string key, Document document); Task Set(string collection, string key, Document document);
} }
}

View File

@@ -1,24 +1,24 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
/// <summary> /// <summary>
/// Event arguments for when changes are applied to the peer store. /// Event arguments for when changes are applied to the peer store.
/// </summary> /// </summary>
public class ChangesAppliedEventArgs : EventArgs public class ChangesAppliedEventArgs : EventArgs
{ {
/// <summary> /// <summary>
/// Gets the changes that were applied. /// Initializes a new instance of the <see cref="ChangesAppliedEventArgs" /> class.
/// </summary>
public IEnumerable<OplogEntry> Changes { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ChangesAppliedEventArgs"/> class.
/// </summary> /// </summary>
/// <param name="changes">The changes that were applied.</param> /// <param name="changes">The changes that were applied.</param>
public ChangesAppliedEventArgs(IEnumerable<OplogEntry> changes) public ChangesAppliedEventArgs(IEnumerable<OplogEntry> changes)
{ {
Changes = changes; Changes = changes;
} }
}
/// <summary>
/// Gets the changes that were applied.
/// </summary>
public IEnumerable<OplogEntry> Changes { get; }
}

View File

@@ -1,45 +1,44 @@
using System; using System;
using System.Collections.Generic; using System.Linq;
using System.Linq; using System.Threading;
using System.Threading; using System.Threading.Tasks;
using System.Threading.Tasks; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Core.Storage;
namespace ZB.MOM.WW.CBDDC.Core.Diagnostics; namespace ZB.MOM.WW.CBDDC.Core.Diagnostics;
/// <summary> /// <summary>
/// Provides health check functionality. /// Provides health check functionality.
/// </summary> /// </summary>
public class CBDDCHealthCheck : ICBDDCHealthCheck public class CBDDCHealthCheck : ICBDDCHealthCheck
{ {
private readonly IOplogStore _store; private readonly ILogger<CBDDCHealthCheck> _logger;
private readonly ISyncStatusTracker _syncTracker; private readonly IOplogStore _store;
private readonly ILogger<CBDDCHealthCheck> _logger; private readonly ISyncStatusTracker _syncTracker;
/// <summary>
/// Initializes a new instance of the <see cref="CBDDCHealthCheck"/> class.
/// </summary>
/// <param name="store">The oplog store used for database health checks.</param>
/// <param name="syncTracker">The tracker that provides synchronization status.</param>
/// <param name="logger">The logger instance.</param>
public CBDDCHealthCheck(
IOplogStore store,
ISyncStatusTracker syncTracker,
ILogger<CBDDCHealthCheck>? logger = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_syncTracker = syncTracker ?? throw new ArgumentNullException(nameof(syncTracker));
_logger = logger ?? NullLogger<CBDDCHealthCheck>.Instance;
}
/// <summary> /// <summary>
/// Performs a comprehensive health check. /// Initializes a new instance of the <see cref="CBDDCHealthCheck" /> class.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel the health check.</param> /// <param name="store">The oplog store used for database health checks.</param>
public async Task<HealthStatus> CheckAsync(CancellationToken cancellationToken = default) /// <param name="syncTracker">The tracker that provides synchronization status.</param>
{ /// <param name="logger">The logger instance.</param>
public CBDDCHealthCheck(
IOplogStore store,
ISyncStatusTracker syncTracker,
ILogger<CBDDCHealthCheck>? logger = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_syncTracker = syncTracker ?? throw new ArgumentNullException(nameof(syncTracker));
_logger = logger ?? NullLogger<CBDDCHealthCheck>.Instance;
}
/// <summary>
/// Performs a comprehensive health check.
/// </summary>
/// <param name="cancellationToken">A token used to cancel the health check.</param>
public async Task<HealthStatus> CheckAsync(CancellationToken cancellationToken = default)
{
var status = new HealthStatus(); var status = new HealthStatus();
// Check database health // Check database health
@@ -65,9 +64,7 @@ public class CBDDCHealthCheck : ICBDDCHealthCheck
// Add error messages from sync tracker // Add error messages from sync tracker
foreach (var error in syncStatus.SyncErrors.Take(5)) // Last 5 errors foreach (var error in syncStatus.SyncErrors.Take(5)) // Last 5 errors
{
status.Errors.Add($"{error.Timestamp:yyyy-MM-dd HH:mm:ss} - {error.Message}"); status.Errors.Add($"{error.Timestamp:yyyy-MM-dd HH:mm:ss} - {error.Message}");
}
// Add metadata // Add metadata
status.Metadata["TotalDocumentsSynced"] = syncStatus.TotalDocumentsSynced; status.Metadata["TotalDocumentsSynced"] = syncStatus.TotalDocumentsSynced;
@@ -79,4 +76,4 @@ public class CBDDCHealthCheck : ICBDDCHealthCheck
return status; return status;
} }
} }

View File

@@ -4,145 +4,145 @@ using System.Collections.Generic;
namespace ZB.MOM.WW.CBDDC.Core.Diagnostics; namespace ZB.MOM.WW.CBDDC.Core.Diagnostics;
/// <summary> /// <summary>
/// Represents the health status of an CBDDC instance. /// Represents the health status of an CBDDC instance.
/// </summary> /// </summary>
public class HealthStatus public class HealthStatus
{ {
/// <summary> /// <summary>
/// Indicates if the database is healthy. /// Indicates if the database is healthy.
/// </summary> /// </summary>
public bool DatabaseHealthy { get; set; } public bool DatabaseHealthy { get; set; }
/// <summary> /// <summary>
/// Indicates if network connectivity is available. /// Indicates if network connectivity is available.
/// </summary> /// </summary>
public bool NetworkHealthy { get; set; } public bool NetworkHealthy { get; set; }
/// <summary> /// <summary>
/// Number of currently connected peers. /// Number of currently connected peers.
/// </summary> /// </summary>
public int ConnectedPeers { get; set; } public int ConnectedPeers { get; set; }
/// <summary> /// <summary>
/// Timestamp of the last successful sync operation. /// Timestamp of the last successful sync operation.
/// </summary> /// </summary>
public DateTime? LastSyncTime { get; set; } public DateTime? LastSyncTime { get; set; }
/// <summary> /// <summary>
/// List of recent errors. /// List of recent errors.
/// </summary> /// </summary>
public List<string> Errors { get; set; } = new(); public List<string> Errors { get; set; } = new();
/// <summary> /// <summary>
/// Overall health status. /// Overall health status.
/// </summary> /// </summary>
public bool IsHealthy => DatabaseHealthy && NetworkHealthy && Errors.Count == 0; public bool IsHealthy => DatabaseHealthy && NetworkHealthy && Errors.Count == 0;
/// <summary> /// <summary>
/// Additional diagnostic information. /// Additional diagnostic information.
/// </summary> /// </summary>
public Dictionary<string, object> Metadata { get; set; } = new(); public Dictionary<string, object> Metadata { get; set; } = new();
} }
/// <summary> /// <summary>
/// Represents the synchronization status. /// Represents the synchronization status.
/// </summary> /// </summary>
public class SyncStatus public class SyncStatus
{ {
/// <summary> /// <summary>
/// Indicates if the node is currently online. /// Indicates if the node is currently online.
/// </summary> /// </summary>
public bool IsOnline { get; set; } public bool IsOnline { get; set; }
/// <summary> /// <summary>
/// Timestamp of the last sync operation. /// Timestamp of the last sync operation.
/// </summary> /// </summary>
public DateTime? LastSyncTime { get; set; } public DateTime? LastSyncTime { get; set; }
/// <summary> /// <summary>
/// Number of pending operations in the offline queue. /// Number of pending operations in the offline queue.
/// </summary> /// </summary>
public int PendingOperations { get; set; } public int PendingOperations { get; set; }
/// <summary> /// <summary>
/// List of active peer nodes. /// List of active peer nodes.
/// </summary> /// </summary>
public List<PeerInfo> ActivePeers { get; set; } = new(); public List<PeerInfo> ActivePeers { get; set; } = new();
/// <summary> /// <summary>
/// Recent sync errors. /// Recent sync errors.
/// </summary> /// </summary>
public List<SyncError> SyncErrors { get; set; } = new(); public List<SyncError> SyncErrors { get; set; } = new();
/// <summary> /// <summary>
/// Total number of documents synced. /// Total number of documents synced.
/// </summary> /// </summary>
public long TotalDocumentsSynced { get; set; } public long TotalDocumentsSynced { get; set; }
/// <summary> /// <summary>
/// Total bytes transferred. /// Total bytes transferred.
/// </summary> /// </summary>
public long TotalBytesTransferred { get; set; } public long TotalBytesTransferred { get; set; }
} }
/// <summary> /// <summary>
/// Information about a peer node. /// Information about a peer node.
/// </summary> /// </summary>
public class PeerInfo public class PeerInfo
{ {
/// <summary> /// <summary>
/// Unique identifier of the peer. /// Unique identifier of the peer.
/// </summary> /// </summary>
public string NodeId { get; set; } = ""; public string NodeId { get; set; } = "";
/// <summary> /// <summary>
/// Network address of the peer. /// Network address of the peer.
/// </summary> /// </summary>
public string Address { get; set; } = ""; public string Address { get; set; } = "";
/// <summary> /// <summary>
/// Last time the peer was seen. /// Last time the peer was seen.
/// </summary> /// </summary>
public DateTime LastSeen { get; set; } public DateTime LastSeen { get; set; }
/// <summary> /// <summary>
/// Indicates if the peer is currently connected. /// Indicates if the peer is currently connected.
/// </summary> /// </summary>
public bool IsConnected { get; set; } public bool IsConnected { get; set; }
/// <summary> /// <summary>
/// Number of successful syncs with this peer. /// Number of successful syncs with this peer.
/// </summary> /// </summary>
public int SuccessfulSyncs { get; set; } public int SuccessfulSyncs { get; set; }
/// <summary> /// <summary>
/// Number of failed syncs with this peer. /// Number of failed syncs with this peer.
/// </summary> /// </summary>
public int FailedSyncs { get; set; } public int FailedSyncs { get; set; }
} }
/// <summary> /// <summary>
/// Represents a synchronization error. /// Represents a synchronization error.
/// </summary> /// </summary>
public class SyncError public class SyncError
{ {
/// <summary> /// <summary>
/// Timestamp when the error occurred. /// Timestamp when the error occurred.
/// </summary> /// </summary>
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
/// <summary> /// <summary>
/// Error message. /// Error message.
/// </summary> /// </summary>
public string Message { get; set; } = ""; public string Message { get; set; } = "";
/// <summary> /// <summary>
/// Peer node ID if applicable. /// Peer node ID if applicable.
/// </summary> /// </summary>
public string? PeerNodeId { get; set; } public string? PeerNodeId { get; set; }
/// <summary> /// <summary>
/// Error code. /// Error code.
/// </summary> /// </summary>
public string? ErrorCode { get; set; } public string? ErrorCode { get; set; }
} }

View File

@@ -1,15 +1,14 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Diagnostics namespace ZB.MOM.WW.CBDDC.Core.Diagnostics;
public interface ICBDDCHealthCheck
{ {
public interface ICBDDCHealthCheck /// <summary>
{ /// Performs a health check for the implementing component.
/// <summary> /// </summary>
/// Performs a health check for the implementing component. /// <param name="cancellationToken">Cancellation token.</param>
/// </summary> /// <returns>The resulting health status.</returns>
/// <param name="cancellationToken">Cancellation token.</param> Task<HealthStatus> CheckAsync(CancellationToken cancellationToken = default);
/// <returns>The resulting health status.</returns> }
Task<HealthStatus> CheckAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -1,63 +1,62 @@
using System; using System;
namespace ZB.MOM.WW.CBDDC.Core.Diagnostics namespace ZB.MOM.WW.CBDDC.Core.Diagnostics;
/// <summary>
/// Tracks synchronization status and peer health metrics.
/// </summary>
public interface ISyncStatusTracker
{ {
/// <summary> /// <summary>
/// Tracks synchronization status and peer health metrics. /// Removes peer entries that have been inactive longer than the specified threshold.
/// </summary> /// </summary>
public interface ISyncStatusTracker /// <param name="inactiveThreshold">The inactivity threshold used to prune peers.</param>
{ void CleanupInactivePeers(TimeSpan inactiveThreshold);
/// <summary>
/// Removes peer entries that have been inactive longer than the specified threshold.
/// </summary>
/// <param name="inactiveThreshold">The inactivity threshold used to prune peers.</param>
void CleanupInactivePeers(TimeSpan inactiveThreshold);
/// <summary> /// <summary>
/// Gets the current synchronization status snapshot. /// Gets the current synchronization status snapshot.
/// </summary> /// </summary>
/// <returns>The current <see cref="SyncStatus"/>.</returns> /// <returns>The current <see cref="SyncStatus" />.</returns>
SyncStatus GetStatus(); SyncStatus GetStatus();
/// <summary> /// <summary>
/// Records an error encountered during synchronization. /// Records an error encountered during synchronization.
/// </summary> /// </summary>
/// <param name="message">The error message.</param> /// <param name="message">The error message.</param>
/// <param name="peerNodeId">The related peer node identifier, if available.</param> /// <param name="peerNodeId">The related peer node identifier, if available.</param>
/// <param name="errorCode">An optional error code.</param> /// <param name="errorCode">An optional error code.</param>
void RecordError(string message, string? peerNodeId = null, string? errorCode = null); void RecordError(string message, string? peerNodeId = null, string? errorCode = null);
/// <summary> /// <summary>
/// Records a failed operation for the specified peer. /// Records a failed operation for the specified peer.
/// </summary> /// </summary>
/// <param name="nodeId">The peer node identifier.</param> /// <param name="nodeId">The peer node identifier.</param>
void RecordPeerFailure(string nodeId); void RecordPeerFailure(string nodeId);
/// <summary> /// <summary>
/// Records a successful operation for the specified peer. /// Records a successful operation for the specified peer.
/// </summary> /// </summary>
/// <param name="nodeId">The peer node identifier.</param> /// <param name="nodeId">The peer node identifier.</param>
void RecordPeerSuccess(string nodeId); void RecordPeerSuccess(string nodeId);
/// <summary> /// <summary>
/// Records synchronization throughput metrics. /// Records synchronization throughput metrics.
/// </summary> /// </summary>
/// <param name="documentCount">The number of synchronized documents.</param> /// <param name="documentCount">The number of synchronized documents.</param>
/// <param name="bytesTransferred">The number of bytes transferred.</param> /// <param name="bytesTransferred">The number of bytes transferred.</param>
void RecordSync(int documentCount, long bytesTransferred); void RecordSync(int documentCount, long bytesTransferred);
/// <summary> /// <summary>
/// Sets whether the local node is currently online. /// Sets whether the local node is currently online.
/// </summary> /// </summary>
/// <param name="isOnline">A value indicating whether the node is online.</param> /// <param name="isOnline">A value indicating whether the node is online.</param>
void SetOnlineStatus(bool isOnline); void SetOnlineStatus(bool isOnline);
/// <summary> /// <summary>
/// Updates peer connectivity details. /// Updates peer connectivity details.
/// </summary> /// </summary>
/// <param name="nodeId">The peer node identifier.</param> /// <param name="nodeId">The peer node identifier.</param>
/// <param name="address">The peer network address.</param> /// <param name="address">The peer network address.</param>
/// <param name="isConnected">A value indicating whether the peer is connected.</param> /// <param name="isConnected">A value indicating whether the peer is connected.</param>
void UpdatePeer(string nodeId, string address, bool isConnected); void UpdatePeer(string nodeId, string address, bool isConnected);
} }
}

View File

@@ -1,44 +1,44 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.CBDDC.Core.Diagnostics; namespace ZB.MOM.WW.CBDDC.Core.Diagnostics;
/// <summary> /// <summary>
/// Tracks synchronization status and provides diagnostics. /// Tracks synchronization status and provides diagnostics.
/// </summary> /// </summary>
public class SyncStatusTracker : ISyncStatusTracker public class SyncStatusTracker : ISyncStatusTracker
{ {
private readonly ILogger<SyncStatusTracker> _logger; private const int MaxErrorHistory = 50;
private readonly object _lock = new();
private bool _isOnline = false;
private DateTime? _lastSyncTime;
private readonly List<PeerInfo> _activePeers = new(); private readonly List<PeerInfo> _activePeers = new();
private readonly object _lock = new();
private readonly ILogger<SyncStatusTracker> _logger;
private readonly Queue<SyncError> _recentErrors = new(); private readonly Queue<SyncError> _recentErrors = new();
private long _totalDocumentsSynced = 0;
private long _totalBytesTransferred = 0; private bool _isOnline;
private const int MaxErrorHistory = 50; private DateTime? _lastSyncTime;
private long _totalBytesTransferred;
/// <summary> private long _totalDocumentsSynced;
/// Initializes a new instance of the <see cref="SyncStatusTracker"/> class.
/// </summary> /// <summary>
/// <param name="logger">Optional logger instance.</param> /// Initializes a new instance of the <see cref="SyncStatusTracker" /> class.
public SyncStatusTracker(ILogger<SyncStatusTracker>? logger = null) /// </summary>
{ /// <param name="logger">Optional logger instance.</param>
_logger = logger ?? NullLogger<SyncStatusTracker>.Instance; public SyncStatusTracker(ILogger<SyncStatusTracker>? logger = null)
} {
_logger = logger ?? NullLogger<SyncStatusTracker>.Instance;
/// <summary> }
/// Updates online status.
/// </summary> /// <summary>
/// <param name="isOnline">Whether the node is currently online.</param> /// Updates online status.
public void SetOnlineStatus(bool isOnline) /// </summary>
{ /// <param name="isOnline">Whether the node is currently online.</param>
lock (_lock) public void SetOnlineStatus(bool isOnline)
{ {
lock (_lock)
{
if (_isOnline != isOnline) if (_isOnline != isOnline)
{ {
_isOnline = isOnline; _isOnline = isOnline;
@@ -47,15 +47,15 @@ public class SyncStatusTracker : ISyncStatusTracker
} }
} }
/// <summary> /// <summary>
/// Records a successful sync operation. /// Records a successful sync operation.
/// </summary> /// </summary>
/// <param name="documentCount">The number of documents synchronized.</param> /// <param name="documentCount">The number of documents synchronized.</param>
/// <param name="bytesTransferred">The number of bytes transferred.</param> /// <param name="bytesTransferred">The number of bytes transferred.</param>
public void RecordSync(int documentCount, long bytesTransferred) public void RecordSync(int documentCount, long bytesTransferred)
{ {
lock (_lock) lock (_lock)
{ {
_lastSyncTime = DateTime.UtcNow; _lastSyncTime = DateTime.UtcNow;
_totalDocumentsSynced += documentCount; _totalDocumentsSynced += documentCount;
_totalBytesTransferred += bytesTransferred; _totalBytesTransferred += bytesTransferred;
@@ -64,16 +64,16 @@ public class SyncStatusTracker : ISyncStatusTracker
} }
} }
/// <summary> /// <summary>
/// Records a sync error. /// Records a sync error.
/// </summary> /// </summary>
/// <param name="message">The error message.</param> /// <param name="message">The error message.</param>
/// <param name="peerNodeId">The related peer node identifier, if available.</param> /// <param name="peerNodeId">The related peer node identifier, if available.</param>
/// <param name="errorCode">The error code, if available.</param> /// <param name="errorCode">The error code, if available.</param>
public void RecordError(string message, string? peerNodeId = null, string? errorCode = null) public void RecordError(string message, string? peerNodeId = null, string? errorCode = null)
{ {
lock (_lock) lock (_lock)
{ {
var error = new SyncError var error = new SyncError
{ {
Timestamp = DateTime.UtcNow, Timestamp = DateTime.UtcNow,
@@ -84,25 +84,22 @@ public class SyncStatusTracker : ISyncStatusTracker
_recentErrors.Enqueue(error); _recentErrors.Enqueue(error);
while (_recentErrors.Count > MaxErrorHistory) while (_recentErrors.Count > MaxErrorHistory) _recentErrors.Dequeue();
{
_recentErrors.Dequeue();
}
_logger.LogWarning("Sync error recorded: {Message} (Peer: {Peer})", message, peerNodeId ?? "N/A"); _logger.LogWarning("Sync error recorded: {Message} (Peer: {Peer})", message, peerNodeId ?? "N/A");
} }
} }
/// <summary> /// <summary>
/// Updates peer information. /// Updates peer information.
/// </summary> /// </summary>
/// <param name="nodeId">The peer node identifier.</param> /// <param name="nodeId">The peer node identifier.</param>
/// <param name="address">The peer address.</param> /// <param name="address">The peer address.</param>
/// <param name="isConnected">Whether the peer is currently connected.</param> /// <param name="isConnected">Whether the peer is currently connected.</param>
public void UpdatePeer(string nodeId, string address, bool isConnected) public void UpdatePeer(string nodeId, string address, bool isConnected)
{ {
lock (_lock) lock (_lock)
{ {
var peer = _activePeers.FirstOrDefault(p => p.NodeId == nodeId); var peer = _activePeers.FirstOrDefault(p => p.NodeId == nodeId);
if (peer == null) if (peer == null)
@@ -126,40 +123,34 @@ public class SyncStatusTracker : ISyncStatusTracker
} }
} }
/// <summary> /// <summary>
/// Records successful sync with a peer. /// Records successful sync with a peer.
/// </summary> /// </summary>
/// <param name="nodeId">The peer node identifier.</param> /// <param name="nodeId">The peer node identifier.</param>
public void RecordPeerSuccess(string nodeId) public void RecordPeerSuccess(string nodeId)
{ {
lock (_lock) lock (_lock)
{ {
var peer = _activePeers.FirstOrDefault(p => p.NodeId == nodeId); var peer = _activePeers.FirstOrDefault(p => p.NodeId == nodeId);
if (peer != null) if (peer != null) peer.SuccessfulSyncs++;
{
peer.SuccessfulSyncs++;
}
}
}
/// <summary>
/// Records failed sync with a peer.
/// </summary>
/// <param name="nodeId">The peer node identifier.</param>
public void RecordPeerFailure(string nodeId)
{
lock (_lock)
{
var peer = _activePeers.FirstOrDefault(p => p.NodeId == nodeId);
if (peer != null)
{
peer.FailedSyncs++;
}
} }
} }
/// <summary> /// <summary>
/// Gets current sync status. /// Records failed sync with a peer.
/// </summary>
/// <param name="nodeId">The peer node identifier.</param>
public void RecordPeerFailure(string nodeId)
{
lock (_lock)
{
var peer = _activePeers.FirstOrDefault(p => p.NodeId == nodeId);
if (peer != null) peer.FailedSyncs++;
}
}
/// <summary>
/// Gets current sync status.
/// </summary> /// </summary>
public SyncStatus GetStatus() public SyncStatus GetStatus()
{ {
@@ -178,21 +169,18 @@ public class SyncStatusTracker : ISyncStatusTracker
} }
} }
/// <summary> /// <summary>
/// Cleans up inactive peers. /// Cleans up inactive peers.
/// </summary> /// </summary>
/// <param name="inactiveThreshold">The inactivity threshold used to remove peers.</param> /// <param name="inactiveThreshold">The inactivity threshold used to remove peers.</param>
public void CleanupInactivePeers(TimeSpan inactiveThreshold) public void CleanupInactivePeers(TimeSpan inactiveThreshold)
{ {
lock (_lock) lock (_lock)
{ {
var cutoff = DateTime.UtcNow - inactiveThreshold; var cutoff = DateTime.UtcNow - inactiveThreshold;
var removed = _activePeers.RemoveAll(p => p.LastSeen < cutoff); int removed = _activePeers.RemoveAll(p => p.LastSeen < cutoff);
if (removed > 0) if (removed > 0) _logger.LogInformation("Removed {Count} inactive peers", removed);
{
_logger.LogInformation("Removed {Count} inactive peers", removed);
}
} }
} }
} }

View File

@@ -1,41 +1,15 @@
using ZB.MOM.WW.CBDDC.Core.Sync; using System.Text.Json;
using System; using ZB.MOM.WW.CBDDC.Core.Sync;
using System.Text.Json;
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
/// <summary> /// <summary>
/// Represents a stored document and its synchronization metadata. /// Represents a stored document and its synchronization metadata.
/// </summary> /// </summary>
public class Document public class Document
{ {
/// <summary> /// <summary>
/// Gets the collection that contains the document. /// Initializes a new instance of the <see cref="Document" /> class.
/// </summary>
public string Collection { get; private set; }
/// <summary>
/// Gets the document key.
/// </summary>
public string Key { get; private set; }
/// <summary>
/// Gets the document content.
/// </summary>
public JsonElement Content { get; private set; }
/// <summary>
/// Gets the timestamp of the latest applied update.
/// </summary>
public HlcTimestamp UpdatedAt { get; private set; }
/// <summary>
/// Gets a value indicating whether the document is deleted.
/// </summary>
public bool IsDeleted { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="Document"/> class.
/// </summary> /// </summary>
/// <param name="collection">The collection that contains the document.</param> /// <param name="collection">The collection that contains the document.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
@@ -52,32 +26,59 @@ public class Document
} }
/// <summary> /// <summary>
/// Merges a remote operation into the current document using last-write-wins or a conflict resolver. /// Gets the collection that contains the document.
/// </summary>
public string Collection { get; }
/// <summary>
/// Gets the document key.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the document content.
/// </summary>
public JsonElement Content { get; private set; }
/// <summary>
/// Gets the timestamp of the latest applied update.
/// </summary>
public HlcTimestamp UpdatedAt { get; private set; }
/// <summary>
/// Gets a value indicating whether the document is deleted.
/// </summary>
public bool IsDeleted { get; private set; }
/// <summary>
/// Merges a remote operation into the current document using last-write-wins or a conflict resolver.
/// </summary> /// </summary>
/// <param name="oplogEntry">The remote operation to merge.</param> /// <param name="oplogEntry">The remote operation to merge.</param>
/// <param name="resolver">An optional conflict resolver for custom merge behavior.</param> /// <param name="resolver">An optional conflict resolver for custom merge behavior.</param>
public void Merge(OplogEntry oplogEntry, IConflictResolver? resolver = null) public void Merge(OplogEntry oplogEntry, IConflictResolver? resolver = null)
{ {
if (oplogEntry == null) return; if (oplogEntry == null) return;
if (Collection != oplogEntry.Collection) return; if (Collection != oplogEntry.Collection) return;
if (Key != oplogEntry.Key) return; if (Key != oplogEntry.Key) return;
if (resolver == null) if (resolver == null)
{ {
//last wins //last wins
if (UpdatedAt <= oplogEntry.Timestamp) if (UpdatedAt <= oplogEntry.Timestamp)
{ {
Content = oplogEntry.Payload ?? default; Content = oplogEntry.Payload ?? default;
UpdatedAt = oplogEntry.Timestamp; UpdatedAt = oplogEntry.Timestamp;
IsDeleted = oplogEntry.Operation == OperationType.Delete; IsDeleted = oplogEntry.Operation == OperationType.Delete;
} }
return;
} return;
var resolutionResult = resolver.Resolve(this, oplogEntry); }
if (resolutionResult.ShouldApply && resolutionResult.MergedDocument != null)
{ var resolutionResult = resolver.Resolve(this, oplogEntry);
Content = resolutionResult.MergedDocument.Content; if (resolutionResult.ShouldApply && resolutionResult.MergedDocument != null)
UpdatedAt = resolutionResult.MergedDocument.UpdatedAt; {
IsDeleted = resolutionResult.MergedDocument.IsDeleted; Content = resolutionResult.MergedDocument.Content;
} UpdatedAt = resolutionResult.MergedDocument.UpdatedAt;
} IsDeleted = resolutionResult.MergedDocument.IsDeleted;
} }
}
}

View File

@@ -1,19 +1,14 @@
using System; using System;
namespace ZB.MOM.WW.CBDDC.Core.Exceptions;
/// <summary>
/// Base exception for all CBDDC-related errors.
/// </summary>
public class CBDDCException : Exception
{
/// <summary>
/// Error code for programmatic error handling.
/// </summary>
public string ErrorCode { get; }
namespace ZB.MOM.WW.CBDDC.Core.Exceptions;
/// <summary>
/// Base exception for all CBDDC-related errors.
/// </summary>
public class CBDDCException : Exception
{
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CBDDCException"/> class. /// Initializes a new instance of the <see cref="CBDDCException" /> class.
/// </summary> /// </summary>
/// <param name="errorCode">The application-specific error code.</param> /// <param name="errorCode">The application-specific error code.</param>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
@@ -24,7 +19,7 @@ public class CBDDCException : Exception
} }
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CBDDCException"/> class. /// Initializes a new instance of the <see cref="CBDDCException" /> class.
/// </summary> /// </summary>
/// <param name="errorCode">The application-specific error code.</param> /// <param name="errorCode">The application-specific error code.</param>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
@@ -34,137 +29,151 @@ public class CBDDCException : Exception
{ {
ErrorCode = errorCode; ErrorCode = errorCode;
} }
}
/// <summary>
/// <summary> /// Error code for programmatic error handling.
/// Exception thrown when network operations fail. /// </summary>
/// </summary> public string ErrorCode { get; }
}
/// <summary>
/// Exception thrown when network operations fail.
/// </summary>
public class NetworkException : CBDDCException public class NetworkException : CBDDCException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="NetworkException"/> class. /// Initializes a new instance of the <see cref="NetworkException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
public NetworkException(string message) public NetworkException(string message)
: base("NETWORK_ERROR", message) { } : base("NETWORK_ERROR", message)
{
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="NetworkException"/> class. /// Initializes a new instance of the <see cref="NetworkException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
/// <param name="innerException">The exception that caused the current exception.</param> /// <param name="innerException">The exception that caused the current exception.</param>
public NetworkException(string message, Exception innerException) public NetworkException(string message, Exception innerException)
: base("NETWORK_ERROR", message, innerException) { } : base("NETWORK_ERROR", message, innerException)
{
}
} }
/// <summary> /// <summary>
/// Exception thrown when persistence operations fail. /// Exception thrown when persistence operations fail.
/// </summary> /// </summary>
public class PersistenceException : CBDDCException public class PersistenceException : CBDDCException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PersistenceException"/> class. /// Initializes a new instance of the <see cref="PersistenceException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
public PersistenceException(string message) public PersistenceException(string message)
: base("PERSISTENCE_ERROR", message) { } : base("PERSISTENCE_ERROR", message)
{
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PersistenceException"/> class. /// Initializes a new instance of the <see cref="PersistenceException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
/// <param name="innerException">The exception that caused the current exception.</param> /// <param name="innerException">The exception that caused the current exception.</param>
public PersistenceException(string message, Exception innerException) public PersistenceException(string message, Exception innerException)
: base("PERSISTENCE_ERROR", message, innerException) { } : base("PERSISTENCE_ERROR", message, innerException)
{
}
} }
/// <summary> /// <summary>
/// Exception thrown when synchronization operations fail. /// Exception thrown when synchronization operations fail.
/// </summary> /// </summary>
public class SyncException : CBDDCException public class SyncException : CBDDCException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SyncException"/> class. /// Initializes a new instance of the <see cref="SyncException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
public SyncException(string message) public SyncException(string message)
: base("SYNC_ERROR", message) { } : base("SYNC_ERROR", message)
{
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SyncException"/> class. /// Initializes a new instance of the <see cref="SyncException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
/// <param name="innerException">The exception that caused the current exception.</param> /// <param name="innerException">The exception that caused the current exception.</param>
public SyncException(string message, Exception innerException) public SyncException(string message, Exception innerException)
: base("SYNC_ERROR", message, innerException) { } : base("SYNC_ERROR", message, innerException)
{
}
} }
/// <summary> /// <summary>
/// Exception thrown when configuration is invalid. /// Exception thrown when configuration is invalid.
/// </summary> /// </summary>
public class ConfigurationException : CBDDCException public class ConfigurationException : CBDDCException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ConfigurationException"/> class. /// Initializes a new instance of the <see cref="ConfigurationException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
public ConfigurationException(string message) public ConfigurationException(string message)
: base("CONFIG_ERROR", message) { } : base("CONFIG_ERROR", message)
{
}
} }
/// <summary> /// <summary>
/// Exception thrown when database corruption is detected. /// Exception thrown when database corruption is detected.
/// </summary> /// </summary>
public class DatabaseCorruptionException : PersistenceException public class DatabaseCorruptionException : PersistenceException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="DatabaseCorruptionException"/> class. /// Initializes a new instance of the <see cref="DatabaseCorruptionException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
public DatabaseCorruptionException(string message) public DatabaseCorruptionException(string message)
: base(message) { } : base(message)
{
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="DatabaseCorruptionException"/> class. /// Initializes a new instance of the <see cref="DatabaseCorruptionException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
/// <param name="innerException">The exception that caused the current exception.</param> /// <param name="innerException">The exception that caused the current exception.</param>
public DatabaseCorruptionException(string message, Exception innerException) public DatabaseCorruptionException(string message, Exception innerException)
: base(message, innerException) { } : base(message, innerException)
{
}
} }
/// <summary> /// <summary>
/// Exception thrown when a timeout occurs. /// Exception thrown when a timeout occurs.
/// </summary> /// </summary>
public class TimeoutException : CBDDCException public class TimeoutException : CBDDCException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TimeoutException"/> class. /// Initializes a new instance of the <see cref="TimeoutException" /> class.
/// </summary> /// </summary>
/// <param name="operation">The operation that timed out.</param> /// <param name="operation">The operation that timed out.</param>
/// <param name="timeoutMs">The timeout in milliseconds.</param> /// <param name="timeoutMs">The timeout in milliseconds.</param>
public TimeoutException(string operation, int timeoutMs) public TimeoutException(string operation, int timeoutMs)
: base("TIMEOUT_ERROR", $"Operation '{operation}' timed out after {timeoutMs}ms") { } : base("TIMEOUT_ERROR", $"Operation '{operation}' timed out after {timeoutMs}ms")
{
}
} }
/// <summary> /// <summary>
/// Exception thrown when a document is not found in a collection. /// Exception thrown when a document is not found in a collection.
/// </summary> /// </summary>
public class DocumentNotFoundException : PersistenceException public class DocumentNotFoundException : PersistenceException
{ {
/// <summary> /// <summary>
/// Gets the document key that was not found. /// Initializes a new instance of the <see cref="DocumentNotFoundException" /> class.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the collection where the document was searched.
/// </summary>
public string Collection { get; }
/// <summary>
/// Initializes a new instance of the <see cref="DocumentNotFoundException"/> class.
/// </summary> /// </summary>
/// <param name="collection">The collection where the document was searched.</param> /// <param name="collection">The collection where the document was searched.</param>
/// <param name="key">The document key that was not found.</param> /// <param name="key">The document key that was not found.</param>
@@ -174,16 +183,28 @@ public class DocumentNotFoundException : PersistenceException
Collection = collection; Collection = collection;
Key = key; Key = key;
} }
/// <summary>
/// Gets the document key that was not found.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the collection where the document was searched.
/// </summary>
public string Collection { get; }
} }
/// <summary> /// <summary>
/// Exception thrown when a concurrency conflict occurs during persistence operations. /// Exception thrown when a concurrency conflict occurs during persistence operations.
/// </summary> /// </summary>
public class CBDDCConcurrencyException : PersistenceException public class CBDDCConcurrencyException : PersistenceException
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CBDDCConcurrencyException"/> class. /// Initializes a new instance of the <see cref="CBDDCConcurrencyException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
public CBDDCConcurrencyException(string message) : base(message) { } public CBDDCConcurrencyException(string message) : base(message)
} {
}
}

View File

@@ -1,31 +1,31 @@
using System; using System;
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
/// <summary> /// <summary>
/// Represents a Hybrid Logical Clock timestamp. /// Represents a Hybrid Logical Clock timestamp.
/// Provides a Total Ordering of events in a distributed system. /// Provides a Total Ordering of events in a distributed system.
/// Implements value semantics and comparable interfaces. /// Implements value semantics and comparable interfaces.
/// </summary> /// </summary>
public readonly struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable, IEquatable<HlcTimestamp> public readonly struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable, IEquatable<HlcTimestamp>
{ {
/// <summary> /// <summary>
/// Gets the physical time component of the timestamp. /// Gets the physical time component of the timestamp.
/// </summary> /// </summary>
public long PhysicalTime { get; } public long PhysicalTime { get; }
/// <summary> /// <summary>
/// Gets the logical counter component used to order events with equal physical time. /// Gets the logical counter component used to order events with equal physical time.
/// </summary> /// </summary>
public int LogicalCounter { get; } public int LogicalCounter { get; }
/// <summary> /// <summary>
/// Gets the node identifier that produced this timestamp. /// Gets the node identifier that produced this timestamp.
/// </summary> /// </summary>
public string NodeId { get; } public string NodeId { get; }
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="HlcTimestamp"/> struct. /// Initializes a new instance of the <see cref="HlcTimestamp" /> struct.
/// </summary> /// </summary>
/// <param name="physicalTime">The physical time component.</param> /// <param name="physicalTime">The physical time component.</param>
/// <param name="logicalCounter">The logical counter component.</param> /// <param name="logicalCounter">The logical counter component.</param>
@@ -35,36 +35,36 @@ public readonly struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable, IE
PhysicalTime = physicalTime; PhysicalTime = physicalTime;
LogicalCounter = logicalCounter; LogicalCounter = logicalCounter;
NodeId = nodeId ?? throw new ArgumentNullException(nameof(nodeId)); NodeId = nodeId ?? throw new ArgumentNullException(nameof(nodeId));
} }
/// <summary> /// <summary>
/// Compares two timestamps to establish a total order. /// Compares two timestamps to establish a total order.
/// Order: PhysicalTime -> LogicalCounter -> NodeId (lexicographical tie-breaker). /// Order: PhysicalTime -> LogicalCounter -> NodeId (lexicographical tie-breaker).
/// </summary> /// </summary>
/// <param name="other">The other timestamp to compare with this instance.</param> /// <param name="other">The other timestamp to compare with this instance.</param>
/// <returns> /// <returns>
/// A value less than zero if this instance is earlier than <paramref name="other"/>, zero if they are equal, /// A value less than zero if this instance is earlier than <paramref name="other" />, zero if they are equal,
/// or greater than zero if this instance is later than <paramref name="other"/>. /// or greater than zero if this instance is later than <paramref name="other" />.
/// </returns> /// </returns>
public int CompareTo(HlcTimestamp other) public int CompareTo(HlcTimestamp other)
{ {
int timeComparison = PhysicalTime.CompareTo(other.PhysicalTime); int timeComparison = PhysicalTime.CompareTo(other.PhysicalTime);
if (timeComparison != 0) return timeComparison; if (timeComparison != 0) return timeComparison;
int counterComparison = LogicalCounter.CompareTo(other.LogicalCounter); int counterComparison = LogicalCounter.CompareTo(other.LogicalCounter);
if (counterComparison != 0) return counterComparison; if (counterComparison != 0) return counterComparison;
// Use Ordinal comparison for consistent tie-breaking across cultures/platforms // Use Ordinal comparison for consistent tie-breaking across cultures/platforms
return string.Compare(NodeId, other.NodeId, StringComparison.Ordinal); return string.Compare(NodeId, other.NodeId, StringComparison.Ordinal);
} }
/// <summary> /// <summary>
/// Compares this instance with another object. /// Compares this instance with another object.
/// </summary> /// </summary>
/// <param name="obj">The object to compare with this instance.</param> /// <param name="obj">The object to compare with this instance.</param>
/// <returns> /// <returns>
/// A value less than zero if this instance is earlier than <paramref name="obj"/>, zero if equal, or greater /// A value less than zero if this instance is earlier than <paramref name="obj" />, zero if equal, or greater
/// than zero if later. /// than zero if later.
/// </returns> /// </returns>
public int CompareTo(object? obj) public int CompareTo(object? obj)
{ {
@@ -74,10 +74,10 @@ public readonly struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable, IE
} }
/// <summary> /// <summary>
/// Determines whether this instance and another timestamp are equal. /// Determines whether this instance and another timestamp are equal.
/// </summary> /// </summary>
/// <param name="other">The other timestamp to compare.</param> /// <param name="other">The other timestamp to compare.</param>
/// <returns><see langword="true"/> if the timestamps are equal; otherwise, <see langword="false"/>.</returns> /// <returns><see langword="true" /> if the timestamps are equal; otherwise, <see langword="false" />.</returns>
public bool Equals(HlcTimestamp other) public bool Equals(HlcTimestamp other)
{ {
return PhysicalTime == other.PhysicalTime && return PhysicalTime == other.PhysicalTime &&
@@ -96,42 +96,68 @@ public readonly struct HlcTimestamp : IComparable<HlcTimestamp>, IComparable, IE
{ {
unchecked unchecked
{ {
var hashCode = PhysicalTime.GetHashCode(); int hashCode = PhysicalTime.GetHashCode();
hashCode = (hashCode * 397) ^ LogicalCounter; hashCode = (hashCode * 397) ^ LogicalCounter;
// Ensure HashCode uses the same comparison logic as Equals/CompareTo // Ensure HashCode uses the same comparison logic as Equals/CompareTo
// Handle null NodeId gracefully (possible via default(HlcTimestamp)) // Handle null NodeId gracefully (possible via default(HlcTimestamp))
hashCode = (hashCode * 397) ^ (NodeId != null ? StringComparer.Ordinal.GetHashCode(NodeId) : 0); hashCode = (hashCode * 397) ^ (NodeId != null ? StringComparer.Ordinal.GetHashCode(NodeId) : 0);
return hashCode; return hashCode;
} }
} }
public static bool operator ==(HlcTimestamp left, HlcTimestamp right) => left.Equals(right); public static bool operator ==(HlcTimestamp left, HlcTimestamp right)
public static bool operator !=(HlcTimestamp left, HlcTimestamp right) => !left.Equals(right); {
return left.Equals(right);
// Standard comparison operators making usage in SyncOrchestrator cleaner (e.g., remote > local) }
public static bool operator <(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) < 0;
public static bool operator <=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) <= 0; public static bool operator !=(HlcTimestamp left, HlcTimestamp right)
public static bool operator >(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) > 0; {
public static bool operator >=(HlcTimestamp left, HlcTimestamp right) => left.CompareTo(right) >= 0; return !left.Equals(right);
}
// Standard comparison operators making usage in SyncOrchestrator cleaner (e.g., remote > local)
public static bool operator <(HlcTimestamp left, HlcTimestamp right)
{
return left.CompareTo(right) < 0;
}
public static bool operator <=(HlcTimestamp left, HlcTimestamp right)
{
return left.CompareTo(right) <= 0;
}
public static bool operator >(HlcTimestamp left, HlcTimestamp right)
{
return left.CompareTo(right) > 0;
}
public static bool operator >=(HlcTimestamp left, HlcTimestamp right)
{
return left.CompareTo(right) >= 0;
}
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() => FormattableString.Invariant($"{PhysicalTime}:{LogicalCounter}:{NodeId}"); public override string ToString()
{
return FormattableString.Invariant($"{PhysicalTime}:{LogicalCounter}:{NodeId}");
}
/// <summary> /// <summary>
/// Parses a timestamp string. /// Parses a timestamp string.
/// </summary> /// </summary>
/// <param name="s">The string to parse, in the format "PhysicalTime:LogicalCounter:NodeId".</param> /// <param name="s">The string to parse, in the format "PhysicalTime:LogicalCounter:NodeId".</param>
/// <returns>The parsed <see cref="HlcTimestamp"/>.</returns> /// <returns>The parsed <see cref="HlcTimestamp" />.</returns>
public static HlcTimestamp Parse(string s) public static HlcTimestamp Parse(string s)
{ {
if (string.IsNullOrEmpty(s)) throw new ArgumentNullException(nameof(s)); if (string.IsNullOrEmpty(s)) throw new ArgumentNullException(nameof(s));
var parts = s.Split(':'); string[] parts = s.Split(':');
if (parts.Length != 3) throw new FormatException("Invalid HlcTimestamp format. Expected 'PhysicalTime:LogicalCounter:NodeId'."); if (parts.Length != 3)
if (!long.TryParse(parts[0], out var physicalTime)) throw new FormatException("Invalid HlcTimestamp format. Expected 'PhysicalTime:LogicalCounter:NodeId'.");
throw new FormatException("Invalid PhysicalTime component in HlcTimestamp."); if (!long.TryParse(parts[0], out long physicalTime))
if (!int.TryParse(parts[1], out var logicalCounter)) throw new FormatException("Invalid PhysicalTime component in HlcTimestamp.");
throw new FormatException("Invalid LogicalCounter component in HlcTimestamp."); if (!int.TryParse(parts[1], out int logicalCounter))
var nodeId = parts[2]; throw new FormatException("Invalid LogicalCounter component in HlcTimestamp.");
return new HlcTimestamp(physicalTime, logicalCounter, nodeId); string nodeId = parts[2];
} return new HlcTimestamp(physicalTime, logicalCounter, nodeId);
} }
}

View File

@@ -6,13 +6,13 @@ using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Core.Management; namespace ZB.MOM.WW.CBDDC.Core.Management;
/// <summary> /// <summary>
/// Service for managing remote peer configurations. /// Service for managing remote peer configurations.
/// Provides CRUD operations for adding, removing, enabling/disabling remote cloud nodes. /// Provides CRUD operations for adding, removing, enabling/disabling remote cloud nodes.
/// </summary> /// </summary>
public interface IPeerManagementService public interface IPeerManagementService
{ {
/// <summary> /// <summary>
/// Adds a static remote peer with simple authentication. /// Adds a static remote peer with simple authentication.
/// </summary> /// </summary>
/// <param name="nodeId">Unique identifier for the remote peer.</param> /// <param name="nodeId">Unique identifier for the remote peer.</param>
/// <param name="address">Network address (hostname:port) of the remote peer.</param> /// <param name="address">Network address (hostname:port) of the remote peer.</param>
@@ -20,14 +20,14 @@ public interface IPeerManagementService
Task AddStaticPeerAsync(string nodeId, string address, CancellationToken cancellationToken = default); Task AddStaticPeerAsync(string nodeId, string address, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Removes a remote peer configuration. /// Removes a remote peer configuration.
/// </summary> /// </summary>
/// <param name="nodeId">Unique identifier of the peer to remove.</param> /// <param name="nodeId">Unique identifier of the peer to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default); Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Removes confirmation tracking for a peer and optionally removes static remote configuration. /// Removes confirmation tracking for a peer and optionally removes static remote configuration.
/// </summary> /// </summary>
/// <param name="nodeId">Unique identifier of the peer to untrack.</param> /// <param name="nodeId">Unique identifier of the peer to untrack.</param>
/// <param name="removeRemoteConfig">When true, also removes static remote peer configuration.</param> /// <param name="removeRemoteConfig">When true, also removes static remote peer configuration.</param>
@@ -38,23 +38,23 @@ public interface IPeerManagementService
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves all configured remote peers. /// Retrieves all configured remote peers.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of remote peer configurations.</returns> /// <returns>Collection of remote peer configurations.</returns>
Task<IEnumerable<RemotePeerConfiguration>> GetAllRemotePeersAsync(CancellationToken cancellationToken = default); Task<IEnumerable<RemotePeerConfiguration>> GetAllRemotePeersAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Enables synchronization with a remote peer. /// Enables synchronization with a remote peer.
/// </summary> /// </summary>
/// <param name="nodeId">Unique identifier of the peer to enable.</param> /// <param name="nodeId">Unique identifier of the peer to enable.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task EnablePeerAsync(string nodeId, CancellationToken cancellationToken = default); Task EnablePeerAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Disables synchronization with a remote peer (keeps configuration). /// Disables synchronization with a remote peer (keeps configuration).
/// </summary> /// </summary>
/// <param name="nodeId">Unique identifier of the peer to disable.</param> /// <param name="nodeId">Unique identifier of the peer to disable.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task DisablePeerAsync(string nodeId, CancellationToken cancellationToken = default); Task DisablePeerAsync(string nodeId, CancellationToken cancellationToken = default);
} }

View File

@@ -2,29 +2,28 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage;
namespace ZB.MOM.WW.CBDDC.Core.Management; namespace ZB.MOM.WW.CBDDC.Core.Management;
/// <summary> /// <summary>
/// Implementation of peer management service. /// Implementation of peer management service.
/// Provides CRUD operations for managing remote peer configurations. /// Provides CRUD operations for managing remote peer configurations.
/// /// Remote peer configurations are stored in a synchronized collection and automatically
/// Remote peer configurations are stored in a synchronized collection and automatically /// replicated across all nodes in the cluster. Any change made on one node will be
/// replicated across all nodes in the cluster. Any change made on one node will be /// synchronized to all other nodes through the normal CBDDC sync process.
/// synchronized to all other nodes through the normal CBDDC sync process.
/// </summary> /// </summary>
public class PeerManagementService : IPeerManagementService public class PeerManagementService : IPeerManagementService
{ {
private readonly IPeerConfigurationStore _store;
private readonly IPeerOplogConfirmationStore _peerOplogConfirmationStore;
private readonly ILogger<PeerManagementService> _logger; private readonly ILogger<PeerManagementService> _logger;
private readonly IPeerOplogConfirmationStore _peerOplogConfirmationStore;
private readonly IPeerConfigurationStore _store;
/// <summary> /// <summary>
/// Initializes a new instance of the PeerManagementService class. /// Initializes a new instance of the PeerManagementService class.
/// </summary> /// </summary>
/// <param name="store">Database instance for accessing the synchronized collection.</param> /// <param name="store">Database instance for accessing the synchronized collection.</param>
/// <param name="peerOplogConfirmationStore">Peer confirmation tracking store.</param> /// <param name="peerOplogConfirmationStore">Peer confirmation tracking store.</param>
@@ -35,12 +34,13 @@ public class PeerManagementService : IPeerManagementService
ILogger<PeerManagementService>? logger = null) ILogger<PeerManagementService>? logger = null)
{ {
_store = store ?? throw new ArgumentNullException(nameof(store)); _store = store ?? throw new ArgumentNullException(nameof(store));
_peerOplogConfirmationStore = peerOplogConfirmationStore ?? throw new ArgumentNullException(nameof(peerOplogConfirmationStore)); _peerOplogConfirmationStore = peerOplogConfirmationStore ??
throw new ArgumentNullException(nameof(peerOplogConfirmationStore));
_logger = logger ?? NullLogger<PeerManagementService>.Instance; _logger = logger ?? NullLogger<PeerManagementService>.Instance;
} }
/// <summary> /// <summary>
/// Adds or updates a static remote peer configuration. /// Adds or updates a static remote peer configuration.
/// </summary> /// </summary>
/// <param name="nodeId">The unique node identifier of the peer.</param> /// <param name="nodeId">The unique node identifier of the peer.</param>
/// <param name="address">The peer network address in host:port format.</param> /// <param name="address">The peer network address in host:port format.</param>
@@ -60,22 +60,23 @@ public class PeerManagementService : IPeerManagementService
}; };
await _store.SaveRemotePeerAsync(config, cancellationToken); await _store.SaveRemotePeerAsync(config, cancellationToken);
_logger.LogInformation("Added static remote peer: {NodeId} at {Address} (will sync to all cluster nodes)", nodeId, address); _logger.LogInformation("Added static remote peer: {NodeId} at {Address} (will sync to all cluster nodes)",
nodeId, address);
} }
/// <summary> /// <summary>
/// Removes a remote peer configuration. /// Removes a remote peer configuration.
/// </summary> /// </summary>
/// <param name="nodeId">The unique node identifier of the peer to remove.</param> /// <param name="nodeId">The unique node identifier of the peer to remove.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public async Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default) public async Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default)
{ {
await RemovePeerTrackingAsync(nodeId, removeRemoteConfig: true, cancellationToken); await RemovePeerTrackingAsync(nodeId, true, cancellationToken);
} }
/// <summary> /// <summary>
/// Removes peer tracking and optionally removes remote peer configuration. /// Removes peer tracking and optionally removes remote peer configuration.
/// </summary> /// </summary>
/// <param name="nodeId">The unique node identifier of the peer to untrack.</param> /// <param name="nodeId">The unique node identifier of the peer to untrack.</param>
/// <param name="removeRemoteConfig">When true, also removes static remote peer configuration.</param> /// <param name="removeRemoteConfig">When true, also removes static remote peer configuration.</param>
@@ -93,7 +94,8 @@ public class PeerManagementService : IPeerManagementService
if (removeRemoteConfig) if (removeRemoteConfig)
{ {
await _store.RemoveRemotePeerAsync(nodeId, cancellationToken); await _store.RemoveRemotePeerAsync(nodeId, cancellationToken);
_logger.LogInformation("Removed remote peer and tracking: {NodeId} (will sync to all cluster nodes)", nodeId); _logger.LogInformation("Removed remote peer and tracking: {NodeId} (will sync to all cluster nodes)",
nodeId);
return; return;
} }
@@ -101,17 +103,18 @@ public class PeerManagementService : IPeerManagementService
} }
/// <summary> /// <summary>
/// Gets all configured remote peers. /// Gets all configured remote peers.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains remote peer configurations.</returns> /// <returns>A task that represents the asynchronous operation. The task result contains remote peer configurations.</returns>
public async Task<IEnumerable<RemotePeerConfiguration>> GetAllRemotePeersAsync(CancellationToken cancellationToken = default) public async Task<IEnumerable<RemotePeerConfiguration>> GetAllRemotePeersAsync(
CancellationToken cancellationToken = default)
{ {
return await _store.GetRemotePeersAsync(cancellationToken); return await _store.GetRemotePeersAsync(cancellationToken);
} }
/// <summary> /// <summary>
/// Enables a configured remote peer. /// Enables a configured remote peer.
/// </summary> /// </summary>
/// <param name="nodeId">The unique node identifier of the peer to enable.</param> /// <param name="nodeId">The unique node identifier of the peer to enable.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
@@ -122,10 +125,7 @@ public class PeerManagementService : IPeerManagementService
var peer = await _store.GetRemotePeerAsync(nodeId, cancellationToken); var peer = await _store.GetRemotePeerAsync(nodeId, cancellationToken);
if (peer == null) if (peer == null) return; // Peer not found, nothing to enable
{
return; // Peer not found, nothing to enable
}
if (!peer.IsEnabled) if (!peer.IsEnabled)
{ {
@@ -136,7 +136,7 @@ public class PeerManagementService : IPeerManagementService
} }
/// <summary> /// <summary>
/// Disables a configured remote peer. /// Disables a configured remote peer.
/// </summary> /// </summary>
/// <param name="nodeId">The unique node identifier of the peer to disable.</param> /// <param name="nodeId">The unique node identifier of the peer to disable.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
@@ -147,10 +147,7 @@ public class PeerManagementService : IPeerManagementService
var peer = await _store.GetRemotePeerAsync(nodeId, cancellationToken); var peer = await _store.GetRemotePeerAsync(nodeId, cancellationToken);
if (peer == null) if (peer == null) return; // Peer not found, nothing to disable
{
return; // Peer not found, nothing to disable
}
if (peer.IsEnabled) if (peer.IsEnabled)
{ {
@@ -163,23 +160,16 @@ public class PeerManagementService : IPeerManagementService
private static void ValidateNodeId(string nodeId) private static void ValidateNodeId(string nodeId)
{ {
if (string.IsNullOrWhiteSpace(nodeId)) if (string.IsNullOrWhiteSpace(nodeId))
{
throw new ArgumentException("NodeId cannot be null or empty", nameof(nodeId)); throw new ArgumentException("NodeId cannot be null or empty", nameof(nodeId));
}
} }
private static void ValidateAddress(string address) private static void ValidateAddress(string address)
{ {
if (string.IsNullOrWhiteSpace(address)) if (string.IsNullOrWhiteSpace(address))
{
throw new ArgumentException("Address cannot be null or empty", nameof(address)); throw new ArgumentException("Address cannot be null or empty", nameof(address));
}
// Basic format validation (should contain host:port) // Basic format validation (should contain host:port)
if (!address.Contains(':')) if (!address.Contains(':'))
{
throw new ArgumentException("Address must be in format 'host:port'", nameof(address)); throw new ArgumentException("Address must be in format 'host:port'", nameof(address));
}
} }
}
}

View File

@@ -1,38 +1,40 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary>
/// Represents a method that handles peer node configuration change notifications.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="newConfig">The updated peer node configuration.</param>
public delegate void PeerNodeConfigurationChangedEventHandler(object? sender, PeerNodeConfiguration newConfig);
/// <summary> /// <summary>
/// Defines a contract for retrieving and monitoring configuration settings for a peer node. /// Represents a method that handles peer node configuration change notifications.
/// </summary> /// </summary>
/// <remarks>Implementations of this interface provide access to the current configuration and notify subscribers /// <param name="sender">The source of the event.</param>
/// when configuration changes occur. This interface is typically used by components that require up-to-date /// <param name="newConfig">The updated peer node configuration.</param>
/// configuration information for peer-to-peer networking scenarios.</remarks> public delegate void PeerNodeConfigurationChangedEventHandler(object? sender, PeerNodeConfiguration newConfig);
/// <summary>
/// Defines a contract for retrieving and monitoring configuration settings for a peer node.
/// </summary>
/// <remarks>
/// Implementations of this interface provide access to the current configuration and notify subscribers
/// when configuration changes occur. This interface is typically used by components that require up-to-date
/// configuration information for peer-to-peer networking scenarios.
/// </remarks>
public interface IPeerNodeConfigurationProvider public interface IPeerNodeConfigurationProvider
{ {
/// <summary> /// <summary>
/// Asynchronously retrieves the current configuration settings for the peer node. /// Asynchronously retrieves the current configuration settings for the peer node.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// A task that represents the asynchronous operation. The task result contains the current /// A task that represents the asynchronous operation. The task result contains the current
/// <see cref="PeerNodeConfiguration"/>. /// <see cref="PeerNodeConfiguration" />.
/// </returns> /// </returns>
public Task<PeerNodeConfiguration> GetConfiguration(); public Task<PeerNodeConfiguration> GetConfiguration();
/// <summary> /// <summary>
/// Occurs when the configuration of the peer node changes. /// Occurs when the configuration of the peer node changes.
/// </summary> /// </summary>
/// <remarks>Subscribe to this event to be notified when any configuration settings for the peer node are /// <remarks>
/// modified. Event handlers can use this notification to update dependent components or respond to configuration /// Subscribe to this event to be notified when any configuration settings for the peer node are
/// changes as needed.</remarks> /// modified. Event handlers can use this notification to update dependent components or respond to configuration
/// changes as needed.
/// </remarks>
public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged; public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged;
} }

View File

@@ -1,20 +1,20 @@
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary> /// <summary>
/// Defines the role of a node in the distributed network cluster. /// Defines the role of a node in the distributed network cluster.
/// </summary> /// </summary>
public enum NodeRole public enum NodeRole
{ {
/// <summary> /// <summary>
/// Standard member node that synchronizes only within the local area network. /// Standard member node that synchronizes only within the local area network.
/// Does not connect to cloud remote nodes. /// Does not connect to cloud remote nodes.
/// </summary> /// </summary>
Member = 0, Member = 0,
/// <summary> /// <summary>
/// Leader node that acts as a gateway to cloud remote nodes. /// Leader node that acts as a gateway to cloud remote nodes.
/// Elected via the Bully algorithm (lexicographically smallest NodeId). /// Elected via the Bully algorithm (lexicographically smallest NodeId).
/// Responsible for synchronizing local cluster changes with cloud nodes. /// Responsible for synchronizing local cluster changes with cloud nodes.
/// </summary> /// </summary>
CloudGateway = 1 CloudGateway = 1
} }

View File

@@ -1,53 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary> /// <summary>
/// Represents a peer node in a distributed network, including its unique identifier, network address, and last seen /// Represents a peer node in a distributed network, including its unique identifier, network address, and last seen
/// timestamp. /// timestamp.
/// </summary> /// </summary>
public class PeerNode public class PeerNode
{ {
/// <summary> /// <summary>
/// Gets the unique identifier for the node. /// Initializes a new instance of the PeerNode class with the specified node identifier, network address, and last
/// </summary> /// seen timestamp.
public string NodeId { get; }
/// <summary>
/// Gets the address associated with the current instance.
/// </summary>
public string Address { get; }
/// <summary>
/// Gets the date and time when the entity was last observed or updated.
/// </summary>
public DateTimeOffset LastSeen { get; }
/// <summary>
/// Gets the configuration settings for the peer node.
/// </summary>
public PeerNodeConfiguration? Configuration { get; }
/// <summary>
/// Gets the type of the peer node (LanDiscovered, StaticRemote, or CloudRemote).
/// </summary>
public PeerType Type { get; }
/// <summary>
/// Gets the role assigned to this node within the cluster.
/// </summary>
public NodeRole Role { get; }
/// <summary>
/// Gets the list of collections this peer is interested in.
/// </summary>
public System.Collections.Generic.IReadOnlyList<string> InterestingCollections { get; }
/// <summary>
/// Initializes a new instance of the PeerNode class with the specified node identifier, network address, and last
/// seen timestamp.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier for the peer node. Cannot be null or empty.</param> /// <param name="nodeId">The unique identifier for the peer node. Cannot be null or empty.</param>
/// <param name="address">The network address of the peer node. Cannot be null or empty.</param> /// <param name="address">The network address of the peer node. Cannot be null or empty.</param>
@@ -57,10 +21,10 @@ public class PeerNode
/// <param name="configuration">The peer node configuration</param> /// <param name="configuration">The peer node configuration</param>
/// <param name="interestingCollections">The list of collections this peer is interested in.</param> /// <param name="interestingCollections">The list of collections this peer is interested in.</param>
public PeerNode( public PeerNode(
string nodeId, string nodeId,
string address, string address,
DateTimeOffset lastSeen, DateTimeOffset lastSeen,
PeerType type = PeerType.LanDiscovered, PeerType type = PeerType.LanDiscovered,
NodeRole role = NodeRole.Member, NodeRole role = NodeRole.Member,
PeerNodeConfiguration? configuration = null, PeerNodeConfiguration? configuration = null,
IEnumerable<string>? interestingCollections = null) IEnumerable<string>? interestingCollections = null)
@@ -73,4 +37,39 @@ public class PeerNode
Configuration = configuration; Configuration = configuration;
InterestingCollections = new List<string>(interestingCollections ?? []).AsReadOnly(); InterestingCollections = new List<string>(interestingCollections ?? []).AsReadOnly();
} }
}
/// <summary>
/// Gets the unique identifier for the node.
/// </summary>
public string NodeId { get; }
/// <summary>
/// Gets the address associated with the current instance.
/// </summary>
public string Address { get; }
/// <summary>
/// Gets the date and time when the entity was last observed or updated.
/// </summary>
public DateTimeOffset LastSeen { get; }
/// <summary>
/// Gets the configuration settings for the peer node.
/// </summary>
public PeerNodeConfiguration? Configuration { get; }
/// <summary>
/// Gets the type of the peer node (LanDiscovered, StaticRemote, or CloudRemote).
/// </summary>
public PeerType Type { get; }
/// <summary>
/// Gets the role assigned to this node within the cluster.
/// </summary>
public NodeRole Role { get; }
/// <summary>
/// Gets the list of collections this peer is interested in.
/// </summary>
public IReadOnlyList<string> InterestingCollections { get; }
}

View File

@@ -1,96 +1,101 @@
using System; using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary> /// <summary>
/// Represents the configuration settings for a peer node in a distributed network. /// Represents the configuration settings for a peer node in a distributed network.
/// </summary> /// </summary>
/// <remarks>Use this class to specify identification, network port, and authentication details required for a /// <remarks>
/// peer node to participate in a cluster or peer-to-peer environment. The <see cref="Default"/> property provides a /// Use this class to specify identification, network port, and authentication details required for a
/// basic configuration suitable for development or testing scenarios.</remarks> /// peer node to participate in a cluster or peer-to-peer environment. The <see cref="Default" /> property provides a
/// basic configuration suitable for development or testing scenarios.
/// </remarks>
public class PeerNodeConfiguration public class PeerNodeConfiguration
{ {
/// <summary> /// <summary>
/// Gets or sets the unique identifier for the node. /// Gets or sets the unique identifier for the node.
/// </summary> /// </summary>
public string NodeId { get; set; } = string.Empty; public string NodeId { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the TCP port number used for network communication. /// Gets or sets the TCP port number used for network communication.
/// </summary> /// </summary>
public int TcpPort { get; set; } public int TcpPort { get; set; }
/// <summary> /// <summary>
/// Gets or sets the authentication token used to authorize API requests. /// Gets or sets the authentication token used to authorize API requests.
/// </summary> /// </summary>
public string AuthToken { get; set; } = string.Empty; public string AuthToken { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Maximum size of the document cache items. Default: 10. /// Maximum size of the document cache items. Default: 10.
/// </summary> /// </summary>
public int MaxDocumentCacheSize { get; set; } = 100; public int MaxDocumentCacheSize { get; set; } = 100;
/// <summary> /// <summary>
/// Maximum size of offline queue. Default: 1000. /// Maximum size of offline queue. Default: 1000.
/// </summary> /// </summary>
public int MaxQueueSize { get; set; } = 1000; public int MaxQueueSize { get; set; } = 1000;
/// <summary> /// <summary>
/// Number of retry attempts for failed network operations. Default: 3. /// Number of retry attempts for failed network operations. Default: 3.
/// </summary> /// </summary>
public int RetryAttempts { get; set; } = 3; public int RetryAttempts { get; set; } = 3;
/// <summary> /// <summary>
/// Delay between retry attempts in milliseconds. Default: 1000ms. /// Delay between retry attempts in milliseconds. Default: 1000ms.
/// </summary> /// </summary>
public int RetryDelayMs { get; set; } = 1000; public int RetryDelayMs { get; set; } = 1000;
/// <summary> /// <summary>
/// Interval between periodic maintenance operations (Oplog pruning) in minutes. Default: 60 minutes. /// Interval between periodic maintenance operations (Oplog pruning) in minutes. Default: 60 minutes.
/// </summary> /// </summary>
public int MaintenanceIntervalMinutes { get; set; } = 60; public int MaintenanceIntervalMinutes { get; set; } = 60;
/// <summary> /// <summary>
/// Oplog retention period in hours. Entries older than this will be pruned. Default: 24 hours. /// Oplog retention period in hours. Entries older than this will be pruned. Default: 24 hours.
/// </summary> /// </summary>
public int OplogRetentionHours { get; set; } = 24; public int OplogRetentionHours { get; set; } = 24;
/// <summary> /// <summary>
/// Gets or sets a list of known peers to connect to directly, bypassing discovery. /// Gets or sets a list of known peers to connect to directly, bypassing discovery.
/// </summary> /// </summary>
public System.Collections.Generic.List<KnownPeerConfiguration> KnownPeers { get; set; } = new(); public List<KnownPeerConfiguration> KnownPeers { get; set; } = new();
/// <summary> /// <summary>
/// Gets the default configuration settings for a peer node. /// Gets the default configuration settings for a peer node.
/// </summary> /// </summary>
/// <remarks>Each access returns a new instance of the configuration with a unique node identifier. The /// <remarks>
/// default settings use TCP port 9000 and a generated authentication token. Modify the returned instance as needed /// Each access returns a new instance of the configuration with a unique node identifier. The
/// before use.</remarks> /// default settings use TCP port 9000 and a generated authentication token. Modify the returned instance as needed
public static PeerNodeConfiguration Default => new PeerNodeConfiguration /// before use.
{ /// </remarks>
NodeId = Guid.NewGuid().ToString(), public static PeerNodeConfiguration Default => new()
TcpPort = 9000, {
AuthToken = Guid.NewGuid().ToString("N") NodeId = Guid.NewGuid().ToString(),
}; TcpPort = 9000,
AuthToken = Guid.NewGuid().ToString("N")
};
} }
/// <summary> /// <summary>
/// Configuration for a known peer node. /// Configuration for a known peer node.
/// </summary> /// </summary>
public class KnownPeerConfiguration public class KnownPeerConfiguration
{ {
/// <summary> /// <summary>
/// The unique identifier of the peer node. /// The unique identifier of the peer node.
/// </summary> /// </summary>
public string NodeId { get; set; } = string.Empty; public string NodeId { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The hostname or IP address of the peer. /// The hostname or IP address of the peer.
/// </summary> /// </summary>
public string Host { get; set; } = string.Empty; public string Host { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The TCP port of the peer. /// The TCP port of the peer.
/// </summary> /// </summary>
public int Port { get; set; } public int Port { get; set; }
} }

View File

@@ -1,26 +1,26 @@
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary> /// <summary>
/// Defines the type of peer node in the distributed network. /// Defines the type of peer node in the distributed network.
/// </summary> /// </summary>
public enum PeerType public enum PeerType
{ {
/// <summary> /// <summary>
/// Peer discovered via UDP broadcast on the local area network. /// Peer discovered via UDP broadcast on the local area network.
/// These peers are ephemeral and removed after timeout when no longer broadcasting. /// These peers are ephemeral and removed after timeout when no longer broadcasting.
/// </summary> /// </summary>
LanDiscovered = 0, LanDiscovered = 0,
/// <summary> /// <summary>
/// Peer manually configured with a static address. /// Peer manually configured with a static address.
/// These peers are persistent across restarts and stored in the database. /// These peers are persistent across restarts and stored in the database.
/// </summary> /// </summary>
StaticRemote = 1, StaticRemote = 1,
/// <summary> /// <summary>
/// Cloud remote node. /// Cloud remote node.
/// Always active if internet connectivity is available. /// Always active if internet connectivity is available.
/// Synchronized only by the elected leader node to reduce overhead. /// Synchronized only by the elected leader node to reduce overhead.
/// </summary> /// </summary>
CloudRemote = 2 CloudRemote = 2
} }

View File

@@ -1,38 +1,39 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary> /// <summary>
/// Configuration for a remote peer node that is persistent across restarts. /// Configuration for a remote peer node that is persistent across restarts.
/// This collection is automatically synchronized across all nodes in the cluster. /// This collection is automatically synchronized across all nodes in the cluster.
/// </summary> /// </summary>
public class RemotePeerConfiguration public class RemotePeerConfiguration
{ {
/// <summary> /// <summary>
/// Gets or sets the unique identifier for the remote peer node. /// Gets or sets the unique identifier for the remote peer node.
/// </summary> /// </summary>
[Key] [Key]
public string NodeId { get; set; } = ""; public string NodeId { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the network address of the remote peer (hostname:port). /// Gets or sets the network address of the remote peer (hostname:port).
/// </summary> /// </summary>
public string Address { get; set; } = ""; public string Address { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the type of the peer (StaticRemote or CloudRemote). /// Gets or sets the type of the peer (StaticRemote or CloudRemote).
/// </summary> /// </summary>
public PeerType Type { get; set; } public PeerType Type { get; set; }
/// <summary> /// <summary>
/// Gets or sets whether this peer is enabled for synchronization. /// Gets or sets whether this peer is enabled for synchronization.
/// Disabled peers are stored but not used for sync. /// Disabled peers are stored but not used for sync.
/// </summary> /// </summary>
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
/// <summary> /// <summary>
/// Gets or sets the list of collections this peer is interested in. /// Gets or sets the list of collections this peer is interested in.
/// If empty, the peer is interested in all collections. /// If empty, the peer is interested in all collections.
/// </summary> /// </summary>
public System.Collections.Generic.List<string> InterestingCollections { get; set; } = new(); public List<string> InterestingCollections { get; set; } = new();
} }

View File

@@ -1,32 +1,16 @@
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Network; namespace ZB.MOM.WW.CBDDC.Core.Network;
/// <summary> /// <summary>
/// Provides peer node configuration from an in-memory static source. /// Provides peer node configuration from an in-memory static source.
/// </summary> /// </summary>
public class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider public class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider
{ {
private PeerNodeConfiguration _configuration = new(); private PeerNodeConfiguration _configuration = new();
/// <summary> /// <summary>
/// Gets or sets the current peer node configuration. /// Initializes a new instance of the <see cref="StaticPeerNodeConfigurationProvider" /> class.
/// </summary>
public PeerNodeConfiguration Configuration
{
get => _configuration;
set
{
if (_configuration != value)
{
_configuration = value;
OnConfigurationChanged(_configuration);
}
}
}
/// <summary>
/// Initializes a new instance of the <see cref="StaticPeerNodeConfigurationProvider"/> class.
/// </summary> /// </summary>
/// <param name="configuration">The initial peer node configuration.</param> /// <param name="configuration">The initial peer node configuration.</param>
public StaticPeerNodeConfigurationProvider(PeerNodeConfiguration configuration) public StaticPeerNodeConfigurationProvider(PeerNodeConfiguration configuration)
@@ -35,12 +19,28 @@ public class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvide
} }
/// <summary> /// <summary>
/// Occurs when the peer node configuration changes. /// Gets or sets the current peer node configuration.
/// </summary>
public PeerNodeConfiguration Configuration
{
get => _configuration;
set
{
if (_configuration != value)
{
_configuration = value;
OnConfigurationChanged(_configuration);
}
}
}
/// <summary>
/// Occurs when the peer node configuration changes.
/// </summary> /// </summary>
public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged; public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged;
/// <summary> /// <summary>
/// Gets the current peer node configuration. /// Gets the current peer node configuration.
/// </summary> /// </summary>
/// <returns>A task whose result is the current configuration.</returns> /// <returns>A task whose result is the current configuration.</returns>
public Task<PeerNodeConfiguration> GetConfiguration() public Task<PeerNodeConfiguration> GetConfiguration()
@@ -49,11 +49,11 @@ public class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvide
} }
/// <summary> /// <summary>
/// Raises the <see cref="ConfigurationChanged"/> event. /// Raises the <see cref="ConfigurationChanged" /> event.
/// </summary> /// </summary>
/// <param name="newConfig">The new peer node configuration.</param> /// <param name="newConfig">The new peer node configuration.</param>
protected virtual void OnConfigurationChanged(PeerNodeConfiguration newConfig) protected virtual void OnConfigurationChanged(PeerNodeConfiguration newConfig)
{ {
ConfigurationChanged?.Invoke(this, newConfig); ConfigurationChanged?.Invoke(this, newConfig);
} }
} }

View File

@@ -1,5 +1,7 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
@@ -10,84 +12,56 @@ public enum OperationType
Delete Delete
} }
public static class OplogEntryExtensions public static class OplogEntryExtensions
{ {
/// <summary> /// <summary>
/// Computes a deterministic hash for the specified oplog entry. /// Computes a deterministic hash for the specified oplog entry.
/// </summary> /// </summary>
/// <param name="entry">The oplog entry to hash.</param> /// <param name="entry">The oplog entry to hash.</param>
/// <returns>The lowercase hexadecimal SHA-256 hash of the entry.</returns> /// <returns>The lowercase hexadecimal SHA-256 hash of the entry.</returns>
public static string ComputeHash(this OplogEntry entry) public static string ComputeHash(this OplogEntry entry)
{ {
using var sha256 = System.Security.Cryptography.SHA256.Create(); using var sha256 = SHA256.Create();
var sb = new System.Text.StringBuilder(); var sb = new StringBuilder();
sb.Append(entry.Collection); sb.Append(entry.Collection);
sb.Append('|'); sb.Append('|');
sb.Append(entry.Key); sb.Append(entry.Key);
sb.Append('|'); sb.Append('|');
// Ensure stable string representation for Enum (integer value) // Ensure stable string representation for Enum (integer value)
sb.Append(((int)entry.Operation).ToString(System.Globalization.CultureInfo.InvariantCulture)); sb.Append(((int)entry.Operation).ToString(CultureInfo.InvariantCulture));
sb.Append('|'); sb.Append('|');
// Payload excluded from hash to avoid serialization non-determinism // Payload excluded from hash to avoid serialization non-determinism
// sb.Append(entry.Payload...); // sb.Append(entry.Payload...);
sb.Append('|'); sb.Append('|');
// Timestamp.ToString() is now Invariant // Timestamp.ToString() is now Invariant
sb.Append(entry.Timestamp.ToString()); sb.Append(entry.Timestamp.ToString());
sb.Append('|'); sb.Append('|');
sb.Append(entry.PreviousHash); sb.Append(entry.PreviousHash);
var bytes = System.Text.Encoding.UTF8.GetBytes(sb.ToString()); byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hashBytes = sha256.ComputeHash(bytes); byte[] hashBytes = sha256.ComputeHash(bytes);
// Convert to hex string // Convert to hex string
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
} }
} }
public class OplogEntry public class OplogEntry
{ {
/// <summary> /// <summary>
/// Gets the collection name associated with this entry. /// Initializes a new instance of the <see cref="OplogEntry" /> class.
/// </summary> /// </summary>
public string Collection { get; } /// <param name="collection">The collection name.</param>
/// <summary> /// <param name="key">The document key.</param>
/// Gets the document key associated with this entry. /// <param name="operation">The operation type.</param>
/// </summary> /// <param name="payload">The serialized payload.</param>
public string Key { get; } /// <param name="timestamp">The logical timestamp.</param>
/// <summary> /// <param name="previousHash">The previous entry hash.</param>
/// Gets the operation represented by this entry. /// <param name="hash">The current entry hash. If null, it is computed.</param>
/// </summary> public OplogEntry(string collection, string key, OperationType operation, JsonElement? payload,
public OperationType Operation { get; } HlcTimestamp timestamp, string previousHash, string? hash = null)
/// <summary> {
/// Gets the serialized payload for the operation.
/// </summary>
public JsonElement? Payload { get; }
/// <summary>
/// Gets the logical timestamp for this entry.
/// </summary>
public HlcTimestamp Timestamp { get; }
/// <summary>
/// Gets the hash of this entry.
/// </summary>
public string Hash { get; }
/// <summary>
/// Gets the hash of the previous entry in the chain.
/// </summary>
public string PreviousHash { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OplogEntry"/> class.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="operation">The operation type.</param>
/// <param name="payload">The serialized payload.</param>
/// <param name="timestamp">The logical timestamp.</param>
/// <param name="previousHash">The previous entry hash.</param>
/// <param name="hash">The current entry hash. If null, it is computed.</param>
public OplogEntry(string collection, string key, OperationType operation, JsonElement? payload, HlcTimestamp timestamp, string previousHash, string? hash = null)
{
Collection = collection; Collection = collection;
Key = key; Key = key;
Operation = operation; Operation = operation;
@@ -98,10 +72,45 @@ public class OplogEntry
} }
/// <summary> /// <summary>
/// Verifies if the stored Hash matches the content. /// Gets the collection name associated with this entry.
/// </summary>
public string Collection { get; }
/// <summary>
/// Gets the document key associated with this entry.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the operation represented by this entry.
/// </summary>
public OperationType Operation { get; }
/// <summary>
/// Gets the serialized payload for the operation.
/// </summary>
public JsonElement? Payload { get; }
/// <summary>
/// Gets the logical timestamp for this entry.
/// </summary>
public HlcTimestamp Timestamp { get; }
/// <summary>
/// Gets the hash of this entry.
/// </summary>
public string Hash { get; }
/// <summary>
/// Gets the hash of the previous entry in the chain.
/// </summary>
public string PreviousHash { get; }
/// <summary>
/// Verifies if the stored Hash matches the content.
/// </summary> /// </summary>
public bool IsValid() public bool IsValid()
{ {
return Hash == this.ComputeHash(); return Hash == this.ComputeHash();
} }
} }

View File

@@ -3,42 +3,42 @@ using System;
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
/// <summary> /// <summary>
/// Represents a persisted confirmation watermark for a tracked peer and source node. /// Represents a persisted confirmation watermark for a tracked peer and source node.
/// </summary> /// </summary>
public class PeerOplogConfirmation public class PeerOplogConfirmation
{ {
/// <summary> /// <summary>
/// Gets or sets the tracked peer node identifier. /// Gets or sets the tracked peer node identifier.
/// </summary> /// </summary>
public string PeerNodeId { get; set; } = ""; public string PeerNodeId { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the source node identifier this confirmation applies to. /// Gets or sets the source node identifier this confirmation applies to.
/// </summary> /// </summary>
public string SourceNodeId { get; set; } = ""; public string SourceNodeId { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the physical wall-clock component of the confirmed HLC timestamp. /// Gets or sets the physical wall-clock component of the confirmed HLC timestamp.
/// </summary> /// </summary>
public long ConfirmedWall { get; set; } public long ConfirmedWall { get; set; }
/// <summary> /// <summary>
/// Gets or sets the logical counter component of the confirmed HLC timestamp. /// Gets or sets the logical counter component of the confirmed HLC timestamp.
/// </summary> /// </summary>
public int ConfirmedLogic { get; set; } public int ConfirmedLogic { get; set; }
/// <summary> /// <summary>
/// Gets or sets the confirmed hash at the watermark. /// Gets or sets the confirmed hash at the watermark.
/// </summary> /// </summary>
public string ConfirmedHash { get; set; } = ""; public string ConfirmedHash { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets when this confirmation record was last updated in UTC. /// Gets or sets when this confirmation record was last updated in UTC.
/// </summary> /// </summary>
public DateTimeOffset LastConfirmedUtc { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset LastConfirmedUtc { get; set; } = DateTimeOffset.UtcNow;
/// <summary> /// <summary>
/// Gets or sets whether this tracked peer is active for pruning/sync gating. /// Gets or sets whether this tracked peer is active for pruning/sync gating.
/// </summary> /// </summary>
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
} }

View File

@@ -1,225 +1,269 @@
using System.Text.Json; namespace ZB.MOM.WW.CBDDC.Core;
namespace ZB.MOM.WW.CBDDC.Core; public abstract class QueryNode
{
public abstract class QueryNode { } }
public class Eq : QueryNode public class Eq : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new equality query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The value to compare against.</param>
public Eq(string field, object value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the value to compare against. /// Gets the value to compare against.
/// </summary> /// </summary>
public object Value { get; } public object Value { get; }
/// <summary>
/// Initializes a new equality query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The value to compare against.</param>
public Eq(string field, object value) { Field = field; Value = value; }
} }
public class Gt : QueryNode public class Gt : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new greater-than query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Gt(string field, object value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the threshold value. /// Gets the threshold value.
/// </summary> /// </summary>
public object Value { get; } public object Value { get; }
/// <summary>
/// Initializes a new greater-than query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Gt(string field, object value) { Field = field; Value = value; }
} }
public class Lt : QueryNode public class Lt : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new less-than query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Lt(string field, object value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the threshold value. /// Gets the threshold value.
/// </summary> /// </summary>
public object Value { get; } public object Value { get; }
/// <summary>
/// Initializes a new less-than query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Lt(string field, object value) { Field = field; Value = value; }
} }
public class Gte : QueryNode public class Gte : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new greater-than-or-equal query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Gte(string field, object value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the threshold value. /// Gets the threshold value.
/// </summary> /// </summary>
public object Value { get; } public object Value { get; }
/// <summary>
/// Initializes a new greater-than-or-equal query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Gte(string field, object value) { Field = field; Value = value; }
} }
public class Lte : QueryNode public class Lte : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new less-than-or-equal query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Lte(string field, object value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the threshold value. /// Gets the threshold value.
/// </summary> /// </summary>
public object Value { get; } public object Value { get; }
/// <summary>
/// Initializes a new less-than-or-equal query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The threshold value.</param>
public Lte(string field, object value) { Field = field; Value = value; }
} }
public class Neq : QueryNode public class Neq : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new not-equal query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The value to compare against.</param>
public Neq(string field, object value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the value to compare against. /// Gets the value to compare against.
/// </summary> /// </summary>
public object Value { get; } public object Value { get; }
/// <summary>
/// Initializes a new not-equal query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The value to compare against.</param>
public Neq(string field, object value) { Field = field; Value = value; }
} }
public class In : QueryNode public class In : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new in-list query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="values">The set of values to compare against.</param>
public In(string field, object[] values)
{
Field = field;
Values = values;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the set of values to compare against. /// Gets the set of values to compare against.
/// </summary> /// </summary>
public object[] Values { get; } public object[] Values { get; }
/// <summary>
/// Initializes a new in-list query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="values">The set of values to compare against.</param>
public In(string field, object[] values) { Field = field; Values = values; }
} }
public class Contains : QueryNode public class Contains : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new contains query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The substring value to search for.</param>
public Contains(string field, string value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the substring value to search for. /// Gets the substring value to search for.
/// </summary> /// </summary>
public string Value { get; } public string Value { get; }
/// <summary>
/// Initializes a new contains query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The substring value to search for.</param>
public Contains(string field, string value) { Field = field; Value = value; }
} }
public class NotContains : QueryNode public class NotContains : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the field name to compare. /// Initializes a new not-contains query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The substring value to exclude.</param>
public NotContains(string field, string value)
{
Field = field;
Value = value;
}
/// <summary>
/// Gets the field name to compare.
/// </summary> /// </summary>
public string Field { get; } public string Field { get; }
/// <summary> /// <summary>
/// Gets the substring value to exclude. /// Gets the substring value to exclude.
/// </summary> /// </summary>
public string Value { get; } public string Value { get; }
/// <summary>
/// Initializes a new not-contains query node.
/// </summary>
/// <param name="field">The field name to compare.</param>
/// <param name="value">The substring value to exclude.</param>
public NotContains(string field, string value) { Field = field; Value = value; }
} }
public class And : QueryNode public class And : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the left side of the logical operation. /// Initializes a new logical AND query node.
/// </summary>
/// <param name="left">The left query node.</param>
/// <param name="right">The right query node.</param>
public And(QueryNode left, QueryNode right)
{
Left = left;
Right = right;
}
/// <summary>
/// Gets the left side of the logical operation.
/// </summary> /// </summary>
public QueryNode Left { get; } public QueryNode Left { get; }
/// <summary> /// <summary>
/// Gets the right side of the logical operation. /// Gets the right side of the logical operation.
/// </summary> /// </summary>
public QueryNode Right { get; } public QueryNode Right { get; }
/// <summary>
/// Initializes a new logical AND query node.
/// </summary>
/// <param name="left">The left query node.</param>
/// <param name="right">The right query node.</param>
public And(QueryNode left, QueryNode right) { Left = left; Right = right; }
} }
public class Or : QueryNode public class Or : QueryNode
{ {
/// <summary> /// <summary>
/// Gets the left side of the logical operation. /// Initializes a new logical OR query node.
/// </summary>
/// <param name="left">The left query node.</param>
/// <param name="right">The right query node.</param>
public Or(QueryNode left, QueryNode right)
{
Left = left;
Right = right;
}
/// <summary>
/// Gets the left side of the logical operation.
/// </summary> /// </summary>
public QueryNode Left { get; } public QueryNode Left { get; }
/// <summary> /// <summary>
/// Gets the right side of the logical operation. /// Gets the right side of the logical operation.
/// </summary> /// </summary>
public QueryNode Right { get; } public QueryNode Right { get; }
}
/// <summary>
/// Initializes a new logical OR query node.
/// </summary>
/// <param name="left">The left query node.</param>
/// <param name="right">The right query node.</param>
public Or(QueryNode left, QueryNode right) { Left = left; Right = right; }
}

View File

@@ -4,7 +4,9 @@ Core abstractions and logic for **CBDDC**, a peer-to-peer data synchronization m
## What Is CBDDC? ## What Is CBDDC?
CBDDC is **not** a database it's a sync layer that plugs into your existing data store (BLite) and enables automatic P2P replication across nodes in a mesh network. Your application reads and writes to its database as usual; CBDDC handles synchronization in the background. CBDDC is **not** a database <EFBFBD> it's a sync layer that plugs into your existing data store (BLite) and enables automatic
P2P replication across nodes in a mesh network. Your application reads and writes to its database as usual; CBDDC
handles synchronization in the background.
## What's In This Package ## What's In This Package
@@ -17,7 +19,7 @@ CBDDC is **not** a database
```bash ```bash
# Pick a persistence provider # Pick a persistence provider
dotnet add package ZB.MOM.WW.CBDDC.Persistence # Embedded document DB dotnet add package ZB.MOM.WW.CBDDC.Persistence # Embedded document DB
# Add networking # Add networking
dotnet add package ZB.MOM.WW.CBDDC.Network dotnet add package ZB.MOM.WW.CBDDC.Network
@@ -65,12 +67,12 @@ builder.Services.AddCBDDCCore()
## Key Concepts ## Key Concepts
| Concept | Description | | Concept | Description |
|---------|-------------| |-------------------|------------------------------------------------------------------------------|
| **CDC** | Change Data Capture watches collections registered via `WatchCollection()` | | **CDC** | Change Data Capture <EFBFBD> watches collections registered via `WatchCollection()` |
| **Oplog** | Append-only hash-chained journal of changes per node | | **Oplog** | Append-only hash-chained journal of changes per node |
| **VectorClock** | Tracks causal ordering across the mesh | | **VectorClock** | Tracks causal ordering across the mesh |
| **DocumentStore** | Your bridge between entities and the sync engine | | **DocumentStore** | Your bridge between entities and the sync engine |
## Architecture ## Architecture
@@ -91,15 +93,16 @@ Your App ? DbContext.SaveChangesAsync()
## Related Packages ## Related Packages
- **ZB.MOM.WW.CBDDC.Persistence** � BLite embedded provider (.NET 10+) - **ZB.MOM.WW.CBDDC.Persistence** <EFBFBD> BLite embedded provider (.NET 10+)
- **ZB.MOM.WW.CBDDC.Network** P2P networking (UDP discovery, TCP sync, Gossip) - **ZB.MOM.WW.CBDDC.Network** <EFBFBD> P2P networking (UDP discovery, TCP sync, Gossip)
## Documentation ## Documentation
- **[Complete Documentation](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net)** - **[Complete Documentation](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net)**
- **[Sample Application](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net/tree/main/samples/ZB.MOM.WW.CBDDC.Sample.Console)** - **[Sample Application](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net/tree/main/samples/ZB.MOM.WW.CBDDC.Sample.Console)
**
- **[Integration Guide](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net#integrating-with-your-database)** - **[Integration Guide](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net#integrating-with-your-database)**
## License ## License
MIT see [LICENSE](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net/blob/main/LICENSE) MIT <EFBFBD> see [LICENSE](https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net/blob/main/LICENSE)

View File

@@ -1,27 +1,28 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Resilience namespace ZB.MOM.WW.CBDDC.Core.Resilience;
public interface IRetryPolicy
{ {
public interface IRetryPolicy /// <summary>
{ /// Executes an asynchronous operation with retry handling.
/// <summary> /// </summary>
/// Executes an asynchronous operation with retry handling. /// <param name="operation">The operation to execute.</param>
/// </summary> /// <param name="operationName">The operation name used for diagnostics.</param>
/// <param name="operation">The operation to execute.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <param name="operationName">The operation name used for diagnostics.</param> /// <returns>A task that represents the asynchronous execution.</returns>
/// <param name="cancellationToken">A token used to cancel the operation.</param> Task ExecuteAsync(Func<Task> operation, string operationName, CancellationToken cancellationToken = default);
/// <returns>A task that represents the asynchronous execution.</returns>
Task ExecuteAsync(Func<Task> operation, string operationName, CancellationToken cancellationToken = default); /// <summary>
/// <summary> /// Executes an asynchronous operation with retry handling and returns a result.
/// Executes an asynchronous operation with retry handling and returns a result. /// </summary>
/// </summary> /// <typeparam name="T">The result type.</typeparam>
/// <typeparam name="T">The result type.</typeparam> /// <param name="operation">The operation to execute.</param>
/// <param name="operation">The operation to execute.</param> /// <param name="operationName">The operation name used for diagnostics.</param>
/// <param name="operationName">The operation name used for diagnostics.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <returns>A task that represents the asynchronous execution and yields the operation result.</returns>
/// <returns>A task that represents the asynchronous execution and yields the operation result.</returns> Task<T> ExecuteAsync<T>(Func<Task<T>> operation, string operationName,
Task<T> ExecuteAsync<T>(Func<Task<T>> operation, string operationName, CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
}

View File

@@ -1,50 +1,53 @@
using System; using System;
using System.Threading; using System.IO;
using System.Threading.Tasks; using System.Net.Sockets;
using ZB.MOM.WW.CBDDC.Core.Exceptions; using System.Threading;
using ZB.MOM.WW.CBDDC.Core.Network; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core.Exceptions;
using ZB.MOM.WW.CBDDC.Core.Network;
using TimeoutException = ZB.MOM.WW.CBDDC.Core.Exceptions.TimeoutException;
namespace ZB.MOM.WW.CBDDC.Core.Resilience; namespace ZB.MOM.WW.CBDDC.Core.Resilience;
/// <summary> /// <summary>
/// Provides retry logic for transient failures. /// Provides retry logic for transient failures.
/// </summary> /// </summary>
public class RetryPolicy : IRetryPolicy public class RetryPolicy : IRetryPolicy
{ {
private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider; private readonly ILogger<RetryPolicy> _logger;
private readonly ILogger<RetryPolicy> _logger; private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider;
/// <summary>
/// Initializes a new instance of the <see cref="RetryPolicy"/> class.
/// </summary>
/// <param name="peerNodeConfigurationProvider">The provider for retry configuration values.</param>
/// <param name="logger">The logger instance.</param>
public RetryPolicy(IPeerNodeConfigurationProvider peerNodeConfigurationProvider, ILogger<RetryPolicy>? logger = null)
{
_logger = logger ?? NullLogger<RetryPolicy>.Instance;
_peerNodeConfigurationProvider = peerNodeConfigurationProvider
?? throw new ArgumentNullException(nameof(peerNodeConfigurationProvider));
}
/// <summary> /// <summary>
/// Executes an operation with retry logic. /// Initializes a new instance of the <see cref="RetryPolicy" /> class.
/// </summary> /// </summary>
/// <typeparam name="T">The result type returned by the operation.</typeparam> /// <param name="peerNodeConfigurationProvider">The provider for retry configuration values.</param>
/// <param name="operation">The asynchronous operation to execute.</param> /// <param name="logger">The logger instance.</param>
/// <param name="operationName">The operation name used for logging.</param> public RetryPolicy(IPeerNodeConfigurationProvider peerNodeConfigurationProvider,
/// <param name="cancellationToken">A token used to cancel retry delays.</param> ILogger<RetryPolicy>? logger = null)
public async Task<T> ExecuteAsync<T>( {
Func<Task<T>> operation, _logger = logger ?? NullLogger<RetryPolicy>.Instance;
string operationName, _peerNodeConfigurationProvider = peerNodeConfigurationProvider
CancellationToken cancellationToken = default) ?? throw new ArgumentNullException(nameof(peerNodeConfigurationProvider));
}
/// <summary>
/// Executes an operation with retry logic.
/// </summary>
/// <typeparam name="T">The result type returned by the operation.</typeparam>
/// <param name="operation">The asynchronous operation to execute.</param>
/// <param name="operationName">The operation name used for logging.</param>
/// <param name="cancellationToken">A token used to cancel retry delays.</param>
public async Task<T> ExecuteAsync<T>(
Func<Task<T>> operation,
string operationName,
CancellationToken cancellationToken = default)
{ {
var config = await _peerNodeConfigurationProvider.GetConfiguration(); var config = await _peerNodeConfigurationProvider.GetConfiguration();
Exception? lastException = null; Exception? lastException = null;
for (int attempt = 1; attempt <= config.RetryAttempts; attempt++) for (var attempt = 1; attempt <= config.RetryAttempts; attempt++)
{
try try
{ {
_logger.LogDebug("Executing {Operation} (attempt {Attempt}/{Max})", _logger.LogDebug("Executing {Operation} (attempt {Attempt}/{Max})",
@@ -55,7 +58,7 @@ public class RetryPolicy : IRetryPolicy
catch (Exception ex) when (attempt < config.RetryAttempts && IsTransient(ex)) catch (Exception ex) when (attempt < config.RetryAttempts && IsTransient(ex))
{ {
lastException = ex; lastException = ex;
var delay = config.RetryDelayMs * attempt; // Exponential backoff int delay = config.RetryDelayMs * attempt; // Exponential backoff
_logger.LogWarning(ex, _logger.LogWarning(ex,
"Operation {Operation} failed (attempt {Attempt}/{Max}). Retrying in {Delay}ms...", "Operation {Operation} failed (attempt {Attempt}/{Max}). Retrying in {Delay}ms...",
@@ -63,36 +66,31 @@ public class RetryPolicy : IRetryPolicy
await Task.Delay(delay, cancellationToken); await Task.Delay(delay, cancellationToken);
} }
}
if (lastException != null) if (lastException != null)
{ _logger.LogError(lastException,
_logger.LogError(lastException, "Operation {Operation} failed after {Attempts} attempts",
"Operation {Operation} failed after {Attempts} attempts", operationName, config.RetryAttempts);
operationName, config.RetryAttempts); else
} _logger.LogError(
else "Operation {Operation} failed after {Attempts} attempts",
{ operationName, config.RetryAttempts);
_logger.LogError(
"Operation {Operation} failed after {Attempts} attempts",
operationName, config.RetryAttempts);
}
throw new CBDDCException("RETRY_EXHAUSTED", throw new CBDDCException("RETRY_EXHAUSTED",
$"Operation '{operationName}' failed after {config.RetryAttempts} attempts", $"Operation '{operationName}' failed after {config.RetryAttempts} attempts",
lastException!); lastException!);
} }
/// <summary> /// <summary>
/// Executes an operation with retry logic (void return). /// Executes an operation with retry logic (void return).
/// </summary> /// </summary>
/// <param name="operation">The asynchronous operation to execute.</param> /// <param name="operation">The asynchronous operation to execute.</param>
/// <param name="operationName">The operation name used for logging.</param> /// <param name="operationName">The operation name used for logging.</param>
/// <param name="cancellationToken">A token used to cancel retry delays.</param> /// <param name="cancellationToken">A token used to cancel retry delays.</param>
public async Task ExecuteAsync( public async Task ExecuteAsync(
Func<Task> operation, Func<Task> operation,
string operationName, string operationName,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
@@ -104,13 +102,13 @@ public class RetryPolicy : IRetryPolicy
private static bool IsTransient(Exception ex) private static bool IsTransient(Exception ex)
{ {
// Network errors are typically transient // Network errors are typically transient
if (ex is NetworkException or System.Net.Sockets.SocketException or System.IO.IOException) if (ex is NetworkException or SocketException or IOException)
return true; return true;
// Timeout errors are transient // Timeout errors are transient
if (ex is Exceptions.TimeoutException or OperationCanceledException) if (ex is TimeoutException or OperationCanceledException)
return true; return true;
return false; return false;
} }
} }

View File

@@ -1,21 +1,24 @@
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
public class SnapshotMetadata public class SnapshotMetadata
{ {
/// <summary> /// <summary>
/// Gets or sets the node identifier associated with the snapshot. /// Gets or sets the node identifier associated with the snapshot.
/// </summary> /// </summary>
public string NodeId { get; set; } = ""; public string NodeId { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the physical time component of the snapshot timestamp. /// Gets or sets the physical time component of the snapshot timestamp.
/// </summary> /// </summary>
public long TimestampPhysicalTime { get; set; } public long TimestampPhysicalTime { get; set; }
/// <summary> /// <summary>
/// Gets or sets the logical counter component of the snapshot timestamp. /// Gets or sets the logical counter component of the snapshot timestamp.
/// </summary> /// </summary>
public int TimestampLogicalCounter { get; set; } public int TimestampLogicalCounter { get; set; }
/// <summary> /// <summary>
/// Gets or sets the snapshot hash. /// Gets or sets the snapshot hash.
/// </summary> /// </summary>
public string Hash { get; set; } = ""; public string Hash { get; set; } = "";
} }

View File

@@ -1,16 +1,18 @@
using System; using System;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Represents an error that occurs when a database is found to be corrupt. /// Represents an error that occurs when a database is found to be corrupt.
/// </summary> /// </summary>
public class CorruptDatabaseException : Exception public class CorruptDatabaseException : Exception
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CorruptDatabaseException"/> class. /// Initializes a new instance of the <see cref="CorruptDatabaseException" /> class.
/// </summary> /// </summary>
/// <param name="message">The exception message.</param> /// <param name="message">The exception message.</param>
/// <param name="innerException">The underlying exception that caused this error.</param> /// <param name="innerException">The underlying exception that caused this error.</param>
public CorruptDatabaseException(string message, Exception innerException) : base(message, innerException) { } public CorruptDatabaseException(string message, Exception innerException) : base(message, innerException)
} {
}
}

View File

@@ -1,108 +1,115 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Defines the contract for storing and retrieving document metadata for sync tracking. /// Defines the contract for storing and retrieving document metadata for sync tracking.
/// Document metadata stores HLC timestamps and deleted state without modifying application entities. /// Document metadata stores HLC timestamps and deleted state without modifying application entities.
/// </summary> /// </summary>
public interface IDocumentMetadataStore : ISnapshotable<DocumentMetadata> public interface IDocumentMetadataStore : ISnapshotable<DocumentMetadata>
{ {
/// <summary> /// <summary>
/// Gets the metadata for a specific document. /// Gets the metadata for a specific document.
/// </summary> /// </summary>
/// <param name="collection">The collection name.</param> /// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The document metadata if found; otherwise null.</returns> /// <returns>The document metadata if found; otherwise null.</returns>
Task<DocumentMetadata?> GetMetadataAsync(string collection, string key, CancellationToken cancellationToken = default); Task<DocumentMetadata?> GetMetadataAsync(string collection, string key,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets metadata for all documents in a collection. /// Gets metadata for all documents in a collection.
/// </summary> /// </summary>
/// <param name="collection">The collection name.</param> /// <param name="collection">The collection name.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>Enumerable of document metadata for the collection.</returns> /// <returns>Enumerable of document metadata for the collection.</returns>
Task<IEnumerable<DocumentMetadata>> GetMetadataByCollectionAsync(string collection, CancellationToken cancellationToken = default); Task<IEnumerable<DocumentMetadata>> GetMetadataByCollectionAsync(string collection,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Upserts (inserts or updates) metadata for a document. /// Upserts (inserts or updates) metadata for a document.
/// </summary> /// </summary>
/// <param name="metadata">The metadata to upsert.</param> /// <param name="metadata">The metadata to upsert.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
Task UpsertMetadataAsync(DocumentMetadata metadata, CancellationToken cancellationToken = default); Task UpsertMetadataAsync(DocumentMetadata metadata, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Upserts metadata for multiple documents in batch. /// Upserts metadata for multiple documents in batch.
/// </summary> /// </summary>
/// <param name="metadatas">The metadata items to upsert.</param> /// <param name="metadatas">The metadata items to upsert.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
Task UpsertMetadataBatchAsync(IEnumerable<DocumentMetadata> metadatas, CancellationToken cancellationToken = default); Task UpsertMetadataBatchAsync(IEnumerable<DocumentMetadata> metadatas,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Marks a document as deleted by setting IsDeleted=true and updating the timestamp. /// Marks a document as deleted by setting IsDeleted=true and updating the timestamp.
/// </summary> /// </summary>
/// <param name="collection">The collection name.</param> /// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <param name="timestamp">The HLC timestamp of the deletion.</param> /// <param name="timestamp">The HLC timestamp of the deletion.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
Task MarkDeletedAsync(string collection, string key, HlcTimestamp timestamp, CancellationToken cancellationToken = default); Task MarkDeletedAsync(string collection, string key, HlcTimestamp timestamp,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all document metadata with timestamps after the specified timestamp. /// Gets all document metadata with timestamps after the specified timestamp.
/// Used for incremental sync to find documents modified since last sync. /// Used for incremental sync to find documents modified since last sync.
/// </summary> /// </summary>
/// <param name="since">The timestamp to compare against.</param> /// <param name="since">The timestamp to compare against.</param>
/// <param name="collections">Optional collection filter.</param> /// <param name="collections">Optional collection filter.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>Documents modified after the specified timestamp.</returns> /// <returns>Documents modified after the specified timestamp.</returns>
Task<IEnumerable<DocumentMetadata>> GetMetadataAfterAsync(HlcTimestamp since, IEnumerable<string>? collections = null, CancellationToken cancellationToken = default); Task<IEnumerable<DocumentMetadata>> GetMetadataAfterAsync(HlcTimestamp since,
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default);
} }
/// <summary> /// <summary>
/// Represents metadata for a document used in sync tracking. /// Represents metadata for a document used in sync tracking.
/// </summary> /// </summary>
public class DocumentMetadata public class DocumentMetadata
{ {
/// <summary> /// <summary>
/// Gets or sets the collection name. /// Initializes a new instance of the <see cref="DocumentMetadata" /> class.
/// </summary> /// </summary>
public string Collection { get; set; } = ""; public DocumentMetadata()
{
}
/// <summary> /// <summary>
/// Gets or sets the document key. /// Initializes a new instance of the <see cref="DocumentMetadata" /> class.
/// </summary> /// </summary>
public string Key { get; set; } = ""; /// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <summary> /// <param name="updatedAt">The last update timestamp.</param>
/// Gets or sets the HLC timestamp of the last modification. /// <param name="isDeleted">Whether the document is marked as deleted.</param>
/// </summary> public DocumentMetadata(string collection, string key, HlcTimestamp updatedAt, bool isDeleted = false)
public HlcTimestamp UpdatedAt { get; set; } {
Collection = collection;
/// <summary>
/// Gets or sets whether this document is marked as deleted (tombstone).
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="DocumentMetadata"/> class.
/// </summary>
public DocumentMetadata() { }
/// <summary>
/// Initializes a new instance of the <see cref="DocumentMetadata"/> class.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="updatedAt">The last update timestamp.</param>
/// <param name="isDeleted">Whether the document is marked as deleted.</param>
public DocumentMetadata(string collection, string key, HlcTimestamp updatedAt, bool isDeleted = false)
{
Collection = collection;
Key = key; Key = key;
UpdatedAt = updatedAt; UpdatedAt = updatedAt;
IsDeleted = isDeleted; IsDeleted = isDeleted;
} }
}
/// <summary>
/// Gets or sets the collection name.
/// </summary>
public string Collection { get; set; } = "";
/// <summary>
/// Gets or sets the document key.
/// </summary>
public string Key { get; set; } = "";
/// <summary>
/// Gets or sets the HLC timestamp of the last modification.
/// </summary>
public HlcTimestamp UpdatedAt { get; set; }
/// <summary>
/// Gets or sets whether this document is marked as deleted (tombstone).
/// </summary>
public bool IsDeleted { get; set; }
}

View File

@@ -1,91 +1,112 @@
using System; using System.Collections.Generic;
using System.Collections.Generic; using System.Threading;
using System.Threading; using System.Threading.Tasks;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Handles basic CRUD operations for documents. /// Handles basic CRUD operations for documents.
/// </summary> /// </summary>
public interface IDocumentStore : ISnapshotable<Document> public interface IDocumentStore : ISnapshotable<Document>
{ {
/// <summary> /// <summary>
/// Gets the collections this store is interested in. /// Gets the collections this store is interested in.
/// </summary> /// </summary>
IEnumerable<string> InterestedCollection { get; } IEnumerable<string> InterestedCollection { get; }
/// <summary> /// <summary>
/// Asynchronously retrieves a incoming from the specified collection by its key. /// Asynchronously retrieves a incoming from the specified collection by its key.
/// </summary> /// </summary>
/// <param name="collection">The name of the collection containing the incoming to retrieve. Cannot be null or empty.</param> /// <param name="collection">The name of the collection containing the incoming to retrieve. Cannot be null or empty.</param>
/// <param name="key">The unique key identifying the incoming within the collection. Cannot be null or empty.</param> /// <param name="key">The unique key identifying the incoming within the collection. Cannot be null or empty.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the incoming if found; otherwise, null.</returns> /// <returns>
/// A task that represents the asynchronous operation. The task result contains the incoming if found; otherwise,
/// null.
/// </returns>
Task<Document?> GetDocumentAsync(string collection, string key, CancellationToken cancellationToken = default); Task<Document?> GetDocumentAsync(string collection, string key, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves all documents belonging to the specified collection. /// Asynchronously retrieves all documents belonging to the specified collection.
/// </summary> /// </summary>
/// <param name="collection">The name of the collection from which to retrieve documents. Cannot be null or empty.</param> /// <param name="collection">The name of the collection from which to retrieve documents. Cannot be null or empty.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an enumerable collection of /// <returns>
/// documents in the specified collection. The collection is empty if no documents are found.</returns> /// A task that represents the asynchronous operation. The task result contains an enumerable collection of
Task<IEnumerable<Document>> GetDocumentsByCollectionAsync(string collection, CancellationToken cancellationToken = default); /// documents in the specified collection. The collection is empty if no documents are found.
/// </returns>
Task<IEnumerable<Document>> GetDocumentsByCollectionAsync(string collection,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously inserts a batch of documents into the data store. /// Asynchronously inserts a batch of documents into the data store.
/// </summary> /// </summary>
/// <param name="documents">The collection of documents to insert. Cannot be null or contain null elements.</param> /// <param name="documents">The collection of documents to insert. Cannot be null or contain null elements.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result is <see langword="true"/> if all documents /// <returns>
/// were inserted successfully; otherwise, <see langword="false"/>.</returns> /// A task that represents the asynchronous operation. The task result is <see langword="true" /> if all documents
Task<bool> InsertBatchDocumentsAsync(IEnumerable<Document> documents, CancellationToken cancellationToken = default); /// were inserted successfully; otherwise, <see langword="false" />.
/// </returns>
Task<bool> InsertBatchDocumentsAsync(IEnumerable<Document> documents,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously updates the specified incoming in the data store. /// Asynchronously updates the specified incoming in the data store.
/// </summary> /// </summary>
/// <param name="document">The incoming to update. Cannot be null.</param> /// <param name="document">The incoming to update. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the update operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the update operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result is <see langword="true"/> if the incoming was /// <returns>
/// successfully updated; otherwise, <see langword="false"/>.</returns> /// A task that represents the asynchronous operation. The task result is <see langword="true" /> if the incoming was
/// successfully updated; otherwise, <see langword="false" />.
/// </returns>
Task<bool> PutDocumentAsync(Document document, CancellationToken cancellationToken = default); Task<bool> PutDocumentAsync(Document document, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously updates a batch of documents in the data store. /// Asynchronously updates a batch of documents in the data store.
/// </summary> /// </summary>
/// <param name="documents">The collection of documents to update. Cannot be null or contain null elements.</param> /// <param name="documents">The collection of documents to update. Cannot be null or contain null elements.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result is <see langword="true"/> if all documents /// <returns>
/// were updated successfully; otherwise, <see langword="false"/>.</returns> /// A task that represents the asynchronous operation. The task result is <see langword="true" /> if all documents
Task<bool> UpdateBatchDocumentsAsync(IEnumerable<Document> documents, CancellationToken cancellationToken = default); /// were updated successfully; otherwise, <see langword="false" />.
/// </returns>
Task<bool> UpdateBatchDocumentsAsync(IEnumerable<Document> documents,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously deletes a incoming identified by the specified key from the given collection. /// Asynchronously deletes a incoming identified by the specified key from the given collection.
/// </summary> /// </summary>
/// <param name="collection">The name of the collection containing the incoming to delete. Cannot be null or empty.</param> /// <param name="collection">The name of the collection containing the incoming to delete. Cannot be null or empty.</param>
/// <param name="key">The unique key identifying the incoming to delete. Cannot be null or empty.</param> /// <param name="key">The unique key identifying the incoming to delete. Cannot be null or empty.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the delete operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the delete operation.</param>
/// <returns>A task that represents the asynchronous delete operation. The task result is <see langword="true"/> if the /// <returns>
/// incoming was successfully deleted; otherwise, <see langword="false"/>.</returns> /// A task that represents the asynchronous delete operation. The task result is <see langword="true" /> if the
/// incoming was successfully deleted; otherwise, <see langword="false" />.
/// </returns>
Task<bool> DeleteDocumentAsync(string collection, string key, CancellationToken cancellationToken = default); Task<bool> DeleteDocumentAsync(string collection, string key, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously deletes a batch of documents identified by their keys. /// Asynchronously deletes a batch of documents identified by their keys.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// If any of the specified documents cannot be deleted, the method returns <see langword="false"/> but does not /// If any of the specified documents cannot be deleted, the method returns <see langword="false" /> but does not
/// throw an exception. The operation is performed asynchronously and may complete partially if cancellation is requested. /// throw an exception. The operation is performed asynchronously and may complete partially if cancellation is
/// </remarks> /// requested.
/// <param name="documentKeys">A collection of incoming keys that specify the documents to delete. Cannot be null or contain null or empty /// </remarks>
/// values.</param> /// <param name="documentKeys">
/// A collection of incoming keys that specify the documents to delete. Cannot be null or contain null or empty
/// values.
/// </param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the delete operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the delete operation.</param>
/// <returns>A task that represents the asynchronous delete operation. The task result is <see langword="true"/> if all /// <returns>
/// specified documents were successfully deleted; otherwise, <see langword="false"/>.</returns> /// A task that represents the asynchronous delete operation. The task result is <see langword="true" /> if all
Task<bool> DeleteBatchDocumentsAsync(IEnumerable<string> documentKeys, CancellationToken cancellationToken = default); /// specified documents were successfully deleted; otherwise, <see langword="false" />.
/// </returns>
Task<bool> DeleteBatchDocumentsAsync(IEnumerable<string> documentKeys,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously merges the specified incoming with existing data and returns the updated incoming. /// Asynchronously merges the specified incoming with existing data and returns the updated incoming.
/// </summary> /// </summary>
/// <param name="incoming">The incoming to merge. Cannot be null.</param> /// <param name="incoming">The incoming to merge. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the merge operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the merge operation.</param>
@@ -93,11 +114,14 @@ public interface IDocumentStore : ISnapshotable<Document>
Task<Document> MergeAsync(Document incoming, CancellationToken cancellationToken = default); Task<Document> MergeAsync(Document incoming, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves documents identified by the specified collection and key pairs. /// Asynchronously retrieves documents identified by the specified collection and key pairs.
/// </summary> /// </summary>
/// <param name="documentKeys">A list of tuples, each containing the collection name and the document key that uniquely identify the documents /// <param name="documentKeys">
/// to retrieve. Cannot be null or empty.</param> /// A list of tuples, each containing the collection name and the document key that uniquely identify the documents
/// to retrieve. Cannot be null or empty.
/// </param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous retrieval operation.</returns> /// <returns>A task that represents the asynchronous retrieval operation.</returns>
Task<IEnumerable<Document>> GetDocumentsAsync(List<(string Collection, string Key)> documentKeys, CancellationToken cancellationToken); Task<IEnumerable<Document>> GetDocumentsAsync(List<(string Collection, string Key)> documentKeys,
} CancellationToken cancellationToken);
}

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -7,17 +6,17 @@ using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Handles operations related to the Operation Log (Oplog), synchronization, and logical clocks. /// Handles operations related to the Operation Log (Oplog), synchronization, and logical clocks.
/// </summary> /// </summary>
public interface IOplogStore : ISnapshotable<OplogEntry> public interface IOplogStore : ISnapshotable<OplogEntry>
{ {
/// <summary> /// <summary>
/// Occurs when changes are applied to the store from external sources (sync). /// Occurs when changes are applied to the store from external sources (sync).
/// </summary> /// </summary>
event EventHandler<ChangesAppliedEventArgs> ChangesApplied; event EventHandler<ChangesAppliedEventArgs> ChangesApplied;
/// <summary> /// <summary>
/// Appends a new entry to the operation log asynchronously. /// Appends a new entry to the operation log asynchronously.
/// </summary> /// </summary>
/// <param name="entry">The operation log entry to append. Cannot be null.</param> /// <param name="entry">The operation log entry to append. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the append operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the append operation.</param>
@@ -25,57 +24,64 @@ public interface IOplogStore : ISnapshotable<OplogEntry>
Task AppendOplogEntryAsync(OplogEntry entry, CancellationToken cancellationToken = default); Task AppendOplogEntryAsync(OplogEntry entry, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves all oplog entries that occurred after the specified timestamp. /// Asynchronously retrieves all oplog entries that occurred after the specified timestamp.
/// </summary> /// </summary>
/// <param name="timestamp">The timestamp after which oplog entries should be returned.</param> /// <param name="timestamp">The timestamp after which oplog entries should be returned.</param>
/// <param name="collections">An optional collection of collection names to filter the results.</param> /// <param name="collections">An optional collection of collection names to filter the results.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation containing matching oplog entries.</returns> /// <returns>A task that represents the asynchronous operation containing matching oplog entries.</returns>
Task<IEnumerable<OplogEntry>> GetOplogAfterAsync(HlcTimestamp timestamp, IEnumerable<string>? collections = null, CancellationToken cancellationToken = default); Task<IEnumerable<OplogEntry>> GetOplogAfterAsync(HlcTimestamp timestamp, IEnumerable<string>? collections = null,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves the latest observed hybrid logical clock (HLC) timestamp. /// Asynchronously retrieves the latest observed hybrid logical clock (HLC) timestamp.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation containing the latest HLC timestamp.</returns> /// <returns>A task that represents the asynchronous operation containing the latest HLC timestamp.</returns>
Task<HlcTimestamp> GetLatestTimestampAsync(CancellationToken cancellationToken = default); Task<HlcTimestamp> GetLatestTimestampAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves the current vector clock representing the state of distributed events. /// Asynchronously retrieves the current vector clock representing the state of distributed events.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation containing the current vector clock.</returns> /// <returns>A task that represents the asynchronous operation containing the current vector clock.</returns>
Task<VectorClock> GetVectorClockAsync(CancellationToken cancellationToken = default); Task<VectorClock> GetVectorClockAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves a collection of oplog entries for the specified node that occurred after the given timestamp. /// Retrieves a collection of oplog entries for the specified node that occurred after the given timestamp.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier of the node for which to retrieve oplog entries. Cannot be null or empty.</param> /// <param name="nodeId">The unique identifier of the node for which to retrieve oplog entries. Cannot be null or empty.</param>
/// <param name="since">The timestamp after which oplog entries should be returned.</param> /// <param name="since">The timestamp after which oplog entries should be returned.</param>
/// <param name="collections">An optional collection of collection names to filter the oplog entries.</param> /// <param name="collections">An optional collection of collection names to filter the oplog entries.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation containing oplog entries for the specified node.</returns> /// <returns>A task that represents the asynchronous operation containing oplog entries for the specified node.</returns>
Task<IEnumerable<OplogEntry>> GetOplogForNodeAfterAsync(string nodeId, HlcTimestamp since, IEnumerable<string>? collections = null, CancellationToken cancellationToken = default); Task<IEnumerable<OplogEntry>> GetOplogForNodeAfterAsync(string nodeId, HlcTimestamp since,
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves the hash of the most recent entry for the specified node. /// Asynchronously retrieves the hash of the most recent entry for the specified node.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier of the node for which to retrieve the last entry hash. Cannot be null or empty.</param> /// <param name="nodeId">
/// The unique identifier of the node for which to retrieve the last entry hash. Cannot be null or
/// empty.
/// </param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation containing the hash string of the last entry or null.</returns> /// <returns>A task that represents the asynchronous operation containing the hash string of the last entry or null.</returns>
Task<string?> GetLastEntryHashAsync(string nodeId, CancellationToken cancellationToken = default); Task<string?> GetLastEntryHashAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves a sequence of oplog entries representing the chain between the specified start and end hashes. /// Asynchronously retrieves a sequence of oplog entries representing the chain between the specified start and end
/// hashes.
/// </summary> /// </summary>
/// <param name="startHash">The hash of the first entry in the chain range. Cannot be null or empty.</param> /// <param name="startHash">The hash of the first entry in the chain range. Cannot be null or empty.</param>
/// <param name="endHash">The hash of the last entry in the chain range. Cannot be null or empty.</param> /// <param name="endHash">The hash of the last entry in the chain range. Cannot be null or empty.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation containing OplogEntry objects in chain order.</returns> /// <returns>A task that represents the asynchronous operation containing OplogEntry objects in chain order.</returns>
Task<IEnumerable<OplogEntry>> GetChainRangeAsync(string startHash, string endHash, CancellationToken cancellationToken = default); Task<IEnumerable<OplogEntry>> GetChainRangeAsync(string startHash, string endHash,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves the oplog entry associated with the specified hash value. /// Asynchronously retrieves the oplog entry associated with the specified hash value.
/// </summary> /// </summary>
/// <param name="hash">The hash string identifying the oplog entry to retrieve. Cannot be null or empty.</param> /// <param name="hash">The hash string identifying the oplog entry to retrieve. Cannot be null or empty.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
@@ -83,7 +89,7 @@ public interface IOplogStore : ISnapshotable<OplogEntry>
Task<OplogEntry?> GetEntryByHashAsync(string hash, CancellationToken cancellationToken = default); Task<OplogEntry?> GetEntryByHashAsync(string hash, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Applies a batch of oplog entries asynchronously to the target data store. /// Applies a batch of oplog entries asynchronously to the target data store.
/// </summary> /// </summary>
/// <param name="oplogEntries">A collection of OplogEntry objects representing the operations to apply. Cannot be null.</param> /// <param name="oplogEntries">A collection of OplogEntry objects representing the operations to apply. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the batch operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the batch operation.</param>
@@ -91,11 +97,10 @@ public interface IOplogStore : ISnapshotable<OplogEntry>
Task ApplyBatchAsync(IEnumerable<OplogEntry> oplogEntries, CancellationToken cancellationToken = default); Task ApplyBatchAsync(IEnumerable<OplogEntry> oplogEntries, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously removes entries from the oplog that are older than the specified cutoff timestamp. /// Asynchronously removes entries from the oplog that are older than the specified cutoff timestamp.
/// </summary> /// </summary>
/// <param name="cutoff">The timestamp that defines the upper bound for entries to be pruned.</param> /// <param name="cutoff">The timestamp that defines the upper bound for entries to be pruned.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the prune operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the prune operation.</param>
/// <returns>A task that represents the asynchronous prune operation.</returns> /// <returns>A task that represents the asynchronous prune operation.</returns>
Task PruneOplogAsync(HlcTimestamp cutoff, CancellationToken cancellationToken = default); Task PruneOplogAsync(HlcTimestamp cutoff, CancellationToken cancellationToken = default);
}
}

View File

@@ -6,26 +6,26 @@ using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Handles storage and retrieval of remote peer configurations. /// Handles storage and retrieval of remote peer configurations.
/// </summary> /// </summary>
public interface IPeerConfigurationStore : ISnapshotable<RemotePeerConfiguration> public interface IPeerConfigurationStore : ISnapshotable<RemotePeerConfiguration>
{ {
/// <summary> /// <summary>
/// Saves or updates a remote peer configuration in the persistent store. /// Saves or updates a remote peer configuration in the persistent store.
/// </summary> /// </summary>
/// <param name="peer">The remote peer configuration to save.</param> /// <param name="peer">The remote peer configuration to save.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task SaveRemotePeerAsync(RemotePeerConfiguration peer, CancellationToken cancellationToken = default); Task SaveRemotePeerAsync(RemotePeerConfiguration peer, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves all remote peer configurations from the persistent store. /// Retrieves all remote peer configurations from the persistent store.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of remote peer configurations.</returns> /// <returns>Collection of remote peer configurations.</returns>
Task<IEnumerable<RemotePeerConfiguration>> GetRemotePeersAsync(CancellationToken cancellationToken = default); Task<IEnumerable<RemotePeerConfiguration>> GetRemotePeersAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves the configuration for a remote peer identified by the specified node ID. /// Asynchronously retrieves the configuration for a remote peer identified by the specified node ID.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier of the remote peer whose configuration is to be retrieved.</param> /// <param name="nodeId">The unique identifier of the remote peer whose configuration is to be retrieved.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
@@ -33,9 +33,9 @@ public interface IPeerConfigurationStore : ISnapshotable<RemotePeerConfiguration
Task<RemotePeerConfiguration?> GetRemotePeerAsync(string nodeId, CancellationToken cancellationToken); Task<RemotePeerConfiguration?> GetRemotePeerAsync(string nodeId, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Removes a remote peer configuration from the persistent store. /// Removes a remote peer configuration from the persistent store.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier of the peer to remove.</param> /// <param name="nodeId">The unique identifier of the peer to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default); Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default);
} }

View File

@@ -6,12 +6,12 @@ using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Defines persistence operations for peer oplog confirmation tracking. /// Defines persistence operations for peer oplog confirmation tracking.
/// </summary> /// </summary>
public interface IPeerOplogConfirmationStore : ISnapshotable<PeerOplogConfirmation> public interface IPeerOplogConfirmationStore : ISnapshotable<PeerOplogConfirmation>
{ {
/// <summary> /// <summary>
/// Ensures the specified peer is tracked for confirmation-based pruning. /// Ensures the specified peer is tracked for confirmation-based pruning.
/// </summary> /// </summary>
/// <param name="peerNodeId">The peer node identifier.</param> /// <param name="peerNodeId">The peer node identifier.</param>
/// <param name="address">The peer network address.</param> /// <param name="address">The peer network address.</param>
@@ -24,7 +24,7 @@ public interface IPeerOplogConfirmationStore : ISnapshotable<PeerOplogConfirmati
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Updates the confirmation watermark for a tracked peer and source node. /// Updates the confirmation watermark for a tracked peer and source node.
/// </summary> /// </summary>
/// <param name="peerNodeId">The tracked peer node identifier.</param> /// <param name="peerNodeId">The tracked peer node identifier.</param>
/// <param name="sourceNodeId">The source node identifier of the confirmed oplog stream.</param> /// <param name="sourceNodeId">The source node identifier of the confirmed oplog stream.</param>
@@ -39,14 +39,14 @@ public interface IPeerOplogConfirmationStore : ISnapshotable<PeerOplogConfirmati
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all persisted peer confirmations. /// Gets all persisted peer confirmations.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>All peer confirmations.</returns> /// <returns>All peer confirmations.</returns>
Task<IEnumerable<PeerOplogConfirmation>> GetConfirmationsAsync(CancellationToken cancellationToken = default); Task<IEnumerable<PeerOplogConfirmation>> GetConfirmationsAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets persisted confirmations for a specific tracked peer. /// Gets persisted confirmations for a specific tracked peer.
/// </summary> /// </summary>
/// <param name="peerNodeId">The peer node identifier.</param> /// <param name="peerNodeId">The peer node identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
@@ -56,16 +56,16 @@ public interface IPeerOplogConfirmationStore : ISnapshotable<PeerOplogConfirmati
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Deactivates tracking for the specified peer. /// Deactivates tracking for the specified peer.
/// </summary> /// </summary>
/// <param name="peerNodeId">The peer node identifier.</param> /// <param name="peerNodeId">The peer node identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
Task RemovePeerTrackingAsync(string peerNodeId, CancellationToken cancellationToken = default); Task RemovePeerTrackingAsync(string peerNodeId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all active tracked peer identifiers. /// Gets all active tracked peer identifiers.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>Distinct active tracked peer identifiers.</returns> /// <returns>Distinct active tracked peer identifiers.</returns>
Task<IEnumerable<string>> GetActiveTrackedPeersAsync(CancellationToken cancellationToken = default); Task<IEnumerable<string>> GetActiveTrackedPeersAsync(CancellationToken cancellationToken = default);
} }

View File

@@ -7,16 +7,21 @@ namespace ZB.MOM.WW.CBDDC.Core.Storage;
public interface ISnapshotMetadataStore : ISnapshotable<SnapshotMetadata> public interface ISnapshotMetadataStore : ISnapshotable<SnapshotMetadata>
{ {
/// <summary> /// <summary>
/// Asynchronously retrieves the snapshot metadata associated with the specified node identifier. /// Asynchronously retrieves the snapshot metadata associated with the specified node identifier.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier of the node for which to retrieve snapshot metadata. Cannot be null or empty.</param> /// <param name="nodeId">
/// The unique identifier of the node for which to retrieve snapshot metadata. Cannot be null or
/// empty.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param> /// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="SnapshotMetadata"/> /// <returns>
/// for the specified node if found; otherwise, <see langword="null"/>.</returns> /// A task that represents the asynchronous operation. The task result contains the <see cref="SnapshotMetadata" />
/// for the specified node if found; otherwise, <see langword="null" />.
/// </returns>
Task<SnapshotMetadata?> GetSnapshotMetadataAsync(string nodeId, CancellationToken cancellationToken = default); Task<SnapshotMetadata?> GetSnapshotMetadataAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously inserts the specified snapshot metadata into the data store. /// Asynchronously inserts the specified snapshot metadata into the data store.
/// </summary> /// </summary>
/// <param name="metadata">The snapshot metadata to insert. Cannot be null.</param> /// <param name="metadata">The snapshot metadata to insert. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
@@ -24,7 +29,7 @@ public interface ISnapshotMetadataStore : ISnapshotable<SnapshotMetadata>
Task InsertSnapshotMetadataAsync(SnapshotMetadata metadata, CancellationToken cancellationToken = default); Task InsertSnapshotMetadataAsync(SnapshotMetadata metadata, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously updates the metadata for an existing snapshot. /// Asynchronously updates the metadata for an existing snapshot.
/// </summary> /// </summary>
/// <param name="existingMeta">The metadata object representing the snapshot to update. Cannot be null.</param> /// <param name="existingMeta">The metadata object representing the snapshot to update. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
@@ -32,7 +37,7 @@ public interface ISnapshotMetadataStore : ISnapshotable<SnapshotMetadata>
Task UpdateSnapshotMetadataAsync(SnapshotMetadata existingMeta, CancellationToken cancellationToken = default); Task UpdateSnapshotMetadataAsync(SnapshotMetadata existingMeta, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously retrieves the hash of the current snapshot for the specified node. /// Asynchronously retrieves the hash of the current snapshot for the specified node.
/// </summary> /// </summary>
/// <param name="nodeId">The unique identifier of the node for which to obtain the snapshot hash.</param> /// <param name="nodeId">The unique identifier of the node for which to obtain the snapshot hash.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
@@ -40,9 +45,9 @@ public interface ISnapshotMetadataStore : ISnapshotable<SnapshotMetadata>
Task<string?> GetSnapshotHashAsync(string nodeId, CancellationToken cancellationToken = default); Task<string?> GetSnapshotHashAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all snapshot metadata entries. Used for initializing VectorClock cache. /// Gets all snapshot metadata entries. Used for initializing VectorClock cache.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token.</param> /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>All snapshot metadata entries.</returns> /// <returns>All snapshot metadata entries.</returns>
Task<IEnumerable<SnapshotMetadata>> GetAllSnapshotMetadataAsync(CancellationToken cancellationToken = default); Task<IEnumerable<SnapshotMetadata>> GetAllSnapshotMetadataAsync(CancellationToken cancellationToken = default);
} }

View File

@@ -5,12 +5,12 @@ using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Handles full database lifecycle operations such as snapshots, replacement, and clearing data. /// Handles full database lifecycle operations such as snapshots, replacement, and clearing data.
/// </summary> /// </summary>
public interface ISnapshotService public interface ISnapshotService
{ {
/// <summary> /// <summary>
/// Asynchronously creates a snapshot of the current state and writes it to the specified destination stream. /// Asynchronously creates a snapshot of the current state and writes it to the specified destination stream.
/// </summary> /// </summary>
/// <param name="destination">The stream to which the snapshot data will be written.</param> /// <param name="destination">The stream to which the snapshot data will be written.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the snapshot creation operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the snapshot creation operation.</param>
@@ -18,7 +18,7 @@ public interface ISnapshotService
Task CreateSnapshotAsync(Stream destination, CancellationToken cancellationToken = default); Task CreateSnapshotAsync(Stream destination, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Replaces the existing database with the contents provided in the specified stream asynchronously. /// Replaces the existing database with the contents provided in the specified stream asynchronously.
/// </summary> /// </summary>
/// <param name="databaseStream">A stream containing the new database data to be used for replacement.</param> /// <param name="databaseStream">A stream containing the new database data to be used for replacement.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
@@ -26,10 +26,10 @@ public interface ISnapshotService
Task ReplaceDatabaseAsync(Stream databaseStream, CancellationToken cancellationToken = default); Task ReplaceDatabaseAsync(Stream databaseStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Merges the provided snapshot stream into the current data store asynchronously. /// Merges the provided snapshot stream into the current data store asynchronously.
/// </summary> /// </summary>
/// <param name="snapshotStream">A stream containing the snapshot data to be merged.</param> /// <param name="snapshotStream">A stream containing the snapshot data to be merged.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the merge operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the merge operation.</param>
/// <returns>A task that represents the asynchronous merge operation.</returns> /// <returns>A task that represents the asynchronous merge operation.</returns>
Task MergeSnapshotAsync(Stream snapshotStream, CancellationToken cancellationToken = default); Task MergeSnapshotAsync(Stream snapshotStream, CancellationToken cancellationToken = default);
} }

View File

@@ -7,24 +7,28 @@ namespace ZB.MOM.WW.CBDDC.Core.Storage;
public interface ISnapshotable<T> public interface ISnapshotable<T>
{ {
/// <summary> /// <summary>
/// Asynchronously deletes the underlying data store and all of its contents. /// Asynchronously deletes the underlying data store and all of its contents.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the drop operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the drop operation.</param>
/// <remarks>After calling this method, the data store and all stored data will be permanently removed. /// <remarks>
/// This operation cannot be undone. Any further operations on the data store may result in errors.</remarks> /// After calling this method, the data store and all stored data will be permanently removed.
/// This operation cannot be undone. Any further operations on the data store may result in errors.
/// </remarks>
/// <returns>A task that represents the asynchronous drop operation.</returns> /// <returns>A task that represents the asynchronous drop operation.</returns>
Task DropAsync(CancellationToken cancellationToken = default); Task DropAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Asynchronously exports a collection of items of type T. /// Asynchronously exports a collection of items of type T.
/// </summary> /// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the export operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the export operation.</param>
/// <returns>A task that represents the asynchronous export operation. The task result contains an enumerable collection of /// <returns>
/// exported items of type T.</returns> /// A task that represents the asynchronous export operation. The task result contains an enumerable collection of
/// exported items of type T.
/// </returns>
Task<IEnumerable<T>> ExportAsync(CancellationToken cancellationToken = default); Task<IEnumerable<T>> ExportAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Imports the specified collection of items asynchronously. /// Imports the specified collection of items asynchronously.
/// </summary> /// </summary>
/// <param name="items">The collection of items to import. Cannot be null. Each item will be processed in sequence.</param> /// <param name="items">The collection of items to import. Cannot be null. Each item will be processed in sequence.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the import operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the import operation.</param>
@@ -32,13 +36,15 @@ public interface ISnapshotable<T>
Task ImportAsync(IEnumerable<T> items, CancellationToken cancellationToken = default); Task ImportAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Merges the specified collection of items into the target data store asynchronously. /// Merges the specified collection of items into the target data store asynchronously.
/// </summary> /// </summary>
/// <remarks>If the operation is canceled via the provided cancellation token, the returned task will be /// <remarks>
/// in a canceled state. The merge operation may update existing items or add new items, depending on the /// If the operation is canceled via the provided cancellation token, the returned task will be
/// implementation.</remarks> /// in a canceled state. The merge operation may update existing items or add new items, depending on the
/// implementation.
/// </remarks>
/// <param name="items">The collection of items to merge into the data store. Cannot be null.</param> /// <param name="items">The collection of items to merge into the data store. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the merge operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the merge operation.</param>
/// <returns>A task that represents the asynchronous merge operation.</returns> /// <returns>A task that represents the asynchronous merge operation.</returns>
Task MergeAsync(IEnumerable<T> items, CancellationToken cancellationToken = default); Task MergeAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
} }

View File

@@ -1,49 +1,49 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Storage; namespace ZB.MOM.WW.CBDDC.Core.Storage;
/// <summary> /// <summary>
/// Manages Vector Clock state for the local node. /// Manages Vector Clock state for the local node.
/// Tracks the latest timestamp and hash per node for sync coordination. /// Tracks the latest timestamp and hash per node for sync coordination.
/// </summary> /// </summary>
public interface IVectorClockService public interface IVectorClockService
{ {
/// <summary> /// <summary>
/// Indicates whether the cache has been populated with initial data. /// Indicates whether the cache has been populated with initial data.
/// Reset to false by <see cref="Invalidate"/>. /// Reset to false by <see cref="Invalidate" />.
/// </summary> /// </summary>
bool IsInitialized { get; set; } bool IsInitialized { get; set; }
/// <summary> /// <summary>
/// Updates the cache with a new OplogEntry's timestamp and hash. /// Updates the cache with a new OplogEntry's timestamp and hash.
/// Called by both DocumentStore (local CDC) and OplogStore (remote sync). /// Called by both DocumentStore (local CDC) and OplogStore (remote sync).
/// </summary> /// </summary>
/// <param name="entry">The oplog entry containing timestamp and hash data.</param> /// <param name="entry">The oplog entry containing timestamp and hash data.</param>
void Update(OplogEntry entry); void Update(OplogEntry entry);
/// <summary> /// <summary>
/// Returns the current Vector Clock built from cached node timestamps. /// Returns the current Vector Clock built from cached node timestamps.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
Task<VectorClock> GetVectorClockAsync(CancellationToken cancellationToken = default); Task<VectorClock> GetVectorClockAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Returns the latest known timestamp across all nodes. /// Returns the latest known timestamp across all nodes.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
Task<HlcTimestamp> GetLatestTimestampAsync(CancellationToken cancellationToken = default); Task<HlcTimestamp> GetLatestTimestampAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Returns the last known hash for the specified node. /// Returns the last known hash for the specified node.
/// Returns null if the node is unknown. /// Returns null if the node is unknown.
/// </summary> /// </summary>
/// <param name="nodeId">The node identifier.</param> /// <param name="nodeId">The node identifier.</param>
string? GetLastHash(string nodeId); string? GetLastHash(string nodeId);
/// <summary> /// <summary>
/// Updates the cache with a specific node's timestamp and hash. /// Updates the cache with a specific node's timestamp and hash.
/// Used for snapshot metadata fallback. /// Used for snapshot metadata fallback.
/// </summary> /// </summary>
/// <param name="nodeId">The node identifier.</param> /// <param name="nodeId">The node identifier.</param>
/// <param name="timestamp">The timestamp to store for the node.</param> /// <param name="timestamp">The timestamp to store for the node.</param>
@@ -51,8 +51,8 @@ public interface IVectorClockService
void UpdateNode(string nodeId, HlcTimestamp timestamp, string hash); void UpdateNode(string nodeId, HlcTimestamp timestamp, string hash);
/// <summary> /// <summary>
/// Clears the cache and resets <see cref="IsInitialized"/> to false, /// Clears the cache and resets <see cref="IsInitialized" /> to false,
/// forcing re-initialization on next access. /// forcing re-initialization on next access.
/// </summary> /// </summary>
void Invalidate(); void Invalidate();
} }

View File

@@ -1,21 +1,9 @@
using System.Text.Json; namespace ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Core;
namespace ZB.MOM.WW.CBDDC.Core.Sync;
public class ConflictResolutionResult public class ConflictResolutionResult
{ {
/// <summary> /// <summary>
/// Gets a value indicating whether the remote change should be applied. /// Initializes a new instance of the <see cref="ConflictResolutionResult" /> class.
/// </summary>
public bool ShouldApply { get; }
/// <summary>
/// Gets the merged document to apply when conflict resolution produced one.
/// </summary>
public Document? MergedDocument { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ConflictResolutionResult"/> class.
/// </summary> /// </summary>
/// <param name="shouldApply">Indicates whether the change should be applied.</param> /// <param name="shouldApply">Indicates whether the change should be applied.</param>
/// <param name="mergedDocument">The merged document produced by resolution, if any.</param> /// <param name="mergedDocument">The merged document produced by resolution, if any.</param>
@@ -26,25 +14,42 @@ public class ConflictResolutionResult
} }
/// <summary> /// <summary>
/// Creates a result indicating that the resolved document should be applied. /// Gets a value indicating whether the remote change should be applied.
/// </summary>
public bool ShouldApply { get; }
/// <summary>
/// Gets the merged document to apply when conflict resolution produced one.
/// </summary>
public Document? MergedDocument { get; }
/// <summary>
/// Creates a result indicating that the resolved document should be applied.
/// </summary> /// </summary>
/// <param name="document">The merged document to apply.</param> /// <param name="document">The merged document to apply.</param>
/// <returns>A resolution result that applies the provided document.</returns> /// <returns>A resolution result that applies the provided document.</returns>
public static ConflictResolutionResult Apply(Document document) => new(true, document); public static ConflictResolutionResult Apply(Document document)
{
return new ConflictResolutionResult(true, document);
}
/// <summary> /// <summary>
/// Creates a result indicating that the remote change should be ignored. /// Creates a result indicating that the remote change should be ignored.
/// </summary> /// </summary>
/// <returns>A resolution result that skips applying the remote change.</returns> /// <returns>A resolution result that skips applying the remote change.</returns>
public static ConflictResolutionResult Ignore() => new(false, null); public static ConflictResolutionResult Ignore()
{
return new ConflictResolutionResult(false, null);
}
} }
public interface IConflictResolver public interface IConflictResolver
{ {
/// <summary> /// <summary>
/// Resolves a conflict between local state and a remote oplog entry. /// Resolves a conflict between local state and a remote oplog entry.
/// </summary> /// </summary>
/// <param name="local">The local document state, if present.</param> /// <param name="local">The local document state, if present.</param>
/// <param name="remote">The incoming remote oplog entry.</param> /// <param name="remote">The incoming remote oplog entry.</param>
/// <returns>The resolution outcome indicating whether and how to apply changes.</returns> /// <returns>The resolution outcome indicating whether and how to apply changes.</returns>
ConflictResolutionResult Resolve(Document? local, OplogEntry remote); ConflictResolutionResult Resolve(Document? local, OplogEntry remote);
} }

View File

@@ -1,40 +1,40 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Core.Sync namespace ZB.MOM.WW.CBDDC.Core.Sync;
/// <summary>
/// Represents a queue for operations that should be executed when connectivity is restored.
/// </summary>
public interface IOfflineQueue
{ {
/// <summary> /// <summary>
/// Represents a queue for operations that should be executed when connectivity is restored. /// Gets the number of pending operations in the queue.
/// </summary> /// </summary>
public interface IOfflineQueue int Count { get; }
{
/// <summary>
/// Gets the number of pending operations in the queue.
/// </summary>
int Count { get; }
/// <summary> /// <summary>
/// Clears all pending operations from the queue. /// Clears all pending operations from the queue.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Clear(); Task Clear();
/// <summary> /// <summary>
/// Enqueues a pending operation. /// Enqueues a pending operation.
/// </summary> /// </summary>
/// <param name="operation">The operation to enqueue.</param> /// <param name="operation">The operation to enqueue.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Enqueue(PendingOperation operation); Task Enqueue(PendingOperation operation);
/// <summary> /// <summary>
/// Flushes the queue by executing each pending operation. /// Flushes the queue by executing each pending operation.
/// </summary> /// </summary>
/// <param name="executor">The delegate used to execute each operation.</param> /// <param name="executor">The delegate used to execute each operation.</param>
/// <param name="cancellationToken">A token used to cancel the flush operation.</param> /// <param name="cancellationToken">A token used to cancel the flush operation.</param>
/// <returns> /// <returns>
/// A task that returns a tuple containing the number of successful and failed operations. /// A task that returns a tuple containing the number of successful and failed operations.
/// </returns> /// </returns>
Task<(int Successful, int Failed)> FlushAsync(Func<PendingOperation, Task> executor, CancellationToken cancellationToken = default); Task<(int Successful, int Failed)> FlushAsync(Func<PendingOperation, Task> executor,
} CancellationToken cancellationToken = default);
} }

View File

@@ -1,24 +1,22 @@
using System.Text.Json;
using ZB.MOM.WW.CBDDC.Core;
namespace ZB.MOM.WW.CBDDC.Core.Sync; namespace ZB.MOM.WW.CBDDC.Core.Sync;
public class LastWriteWinsConflictResolver : IConflictResolver public class LastWriteWinsConflictResolver : IConflictResolver
{ {
/// <summary> /// <summary>
/// Resolves document conflicts by preferring the entry with the latest timestamp. /// Resolves document conflicts by preferring the entry with the latest timestamp.
/// </summary> /// </summary>
/// <param name="local">The local document, if available.</param> /// <param name="local">The local document, if available.</param>
/// <param name="remote">The incoming remote oplog entry.</param> /// <param name="remote">The incoming remote oplog entry.</param>
/// <returns>The conflict resolution result indicating whether to apply or ignore the remote change.</returns> /// <returns>The conflict resolution result indicating whether to apply or ignore the remote change.</returns>
public ConflictResolutionResult Resolve(Document? local, OplogEntry remote) public ConflictResolutionResult Resolve(Document? local, OplogEntry remote)
{ {
// If no local document exists, always apply remote change // If no local document exists, always apply remote change
if (local == null) if (local == null)
{ {
// Construct new document from oplog entry // Construct new document from oplog entry
var content = remote.Payload ?? default; var content = remote.Payload ?? default;
var newDoc = new Document(remote.Collection, remote.Key, content, remote.Timestamp, remote.Operation == OperationType.Delete); var newDoc = new Document(remote.Collection, remote.Key, content, remote.Timestamp,
remote.Operation == OperationType.Delete);
return ConflictResolutionResult.Apply(newDoc); return ConflictResolutionResult.Apply(newDoc);
} }
@@ -27,11 +25,12 @@ public class LastWriteWinsConflictResolver : IConflictResolver
{ {
// Remote is newer, apply it // Remote is newer, apply it
var content = remote.Payload ?? default; var content = remote.Payload ?? default;
var newDoc = new Document(remote.Collection, remote.Key, content, remote.Timestamp, remote.Operation == OperationType.Delete); var newDoc = new Document(remote.Collection, remote.Key, content, remote.Timestamp,
remote.Operation == OperationType.Delete);
return ConflictResolutionResult.Apply(newDoc); return ConflictResolutionResult.Apply(newDoc);
} }
// Local is newer or equal, ignore remote // Local is newer or equal, ignore remote
return ConflictResolutionResult.Ignore(); return ConflictResolutionResult.Ignore();
} }
} }

View File

@@ -1,37 +1,38 @@
using ZB.MOM.WW.CBDDC.Core.Network; using System;
using Microsoft.Extensions.Logging; using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions; using System.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Core.Sync; namespace ZB.MOM.WW.CBDDC.Core.Sync;
/// <summary> /// <summary>
/// Queue for operations performed while offline. /// Queue for operations performed while offline.
/// </summary> /// </summary>
public class OfflineQueue : IOfflineQueue public class OfflineQueue : IOfflineQueue
{ {
private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider; private readonly object _lock = new();
private readonly Queue<PendingOperation> _queue = new(); private readonly ILogger<OfflineQueue> _logger;
private readonly ILogger<OfflineQueue> _logger; private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider;
private readonly object _lock = new(); private readonly Queue<PendingOperation> _queue = new();
/// <summary>
/// Initializes a new instance of the <see cref="OfflineQueue"/> class.
/// </summary>
/// <param name="peerNodeConfigurationProvider">The configuration provider used for queue limits.</param>
/// <param name="logger">The logger instance.</param>
public OfflineQueue(IPeerNodeConfigurationProvider peerNodeConfigurationProvider, ILogger<OfflineQueue>? logger = null)
{
_peerNodeConfigurationProvider = peerNodeConfigurationProvider;
_logger = logger ?? NullLogger<OfflineQueue>.Instance;
}
/// <summary> /// <summary>
/// Gets the number of pending operations. /// Initializes a new instance of the <see cref="OfflineQueue" /> class.
/// </summary>
/// <param name="peerNodeConfigurationProvider">The configuration provider used for queue limits.</param>
/// <param name="logger">The logger instance.</param>
public OfflineQueue(IPeerNodeConfigurationProvider peerNodeConfigurationProvider,
ILogger<OfflineQueue>? logger = null)
{
_peerNodeConfigurationProvider = peerNodeConfigurationProvider;
_logger = logger ?? NullLogger<OfflineQueue>.Instance;
}
/// <summary>
/// Gets the number of pending operations.
/// </summary> /// </summary>
public int Count public int Count
{ {
@@ -44,15 +45,15 @@ public class OfflineQueue : IOfflineQueue
} }
} }
/// <summary> /// <summary>
/// Enqueues an operation for later execution. /// Enqueues an operation for later execution.
/// </summary> /// </summary>
/// <param name="operation">The pending operation to enqueue.</param> /// <param name="operation">The pending operation to enqueue.</param>
/// <returns>A task that represents the asynchronous enqueue operation.</returns> /// <returns>A task that represents the asynchronous enqueue operation.</returns>
public async Task Enqueue(PendingOperation operation) public async Task Enqueue(PendingOperation operation)
{ {
var config = await _peerNodeConfigurationProvider.GetConfiguration(); var config = await _peerNodeConfigurationProvider.GetConfiguration();
lock (_lock) lock (_lock)
{ {
if (_queue.Count >= config.MaxQueueSize) if (_queue.Count >= config.MaxQueueSize)
{ {
@@ -67,15 +68,16 @@ public class OfflineQueue : IOfflineQueue
} }
} }
/// <summary> /// <summary>
/// Flushes all pending operations. /// Flushes all pending operations.
/// </summary> /// </summary>
/// <param name="executor">The delegate that executes each pending operation.</param> /// <param name="executor">The delegate that executes each pending operation.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param> /// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <returns>A task whose result contains the number of successful and failed operations.</returns> /// <returns>A task whose result contains the number of successful and failed operations.</returns>
public async Task<(int Successful, int Failed)> FlushAsync(Func<PendingOperation, Task> executor, CancellationToken cancellationToken = default) public async Task<(int Successful, int Failed)> FlushAsync(Func<PendingOperation, Task> executor,
{ CancellationToken cancellationToken = default)
List<PendingOperation> operations; {
List<PendingOperation> operations;
lock (_lock) lock (_lock)
{ {
@@ -91,11 +93,10 @@ public class OfflineQueue : IOfflineQueue
_logger.LogInformation("Flushing {Count} pending operations", operations.Count); _logger.LogInformation("Flushing {Count} pending operations", operations.Count);
int successful = 0; var successful = 0;
int failed = 0; var failed = 0;
foreach (var op in operations) foreach (var op in operations)
{
try try
{ {
await executor(op); await executor(op);
@@ -107,7 +108,6 @@ public class OfflineQueue : IOfflineQueue
_logger.LogError(ex, "Failed to execute pending {Type} operation for {Collection}:{Key}", _logger.LogError(ex, "Failed to execute pending {Type} operation for {Collection}:{Key}",
op.Type, op.Collection, op.Key); op.Type, op.Collection, op.Key);
} }
}
_logger.LogInformation("Flush completed: {Successful} successful, {Failed} failed", _logger.LogInformation("Flush completed: {Successful} successful, {Failed} failed",
successful, failed); successful, failed);
@@ -116,15 +116,15 @@ public class OfflineQueue : IOfflineQueue
} }
/// <summary> /// <summary>
/// Clears all pending operations. /// Clears all pending operations.
/// </summary> /// </summary>
public async Task Clear() public async Task Clear()
{ {
lock (_lock) lock (_lock)
{ {
var count = _queue.Count; int count = _queue.Count;
_queue.Clear(); _queue.Clear();
_logger.LogInformation("Cleared {Count} pending operations", count); _logger.LogInformation("Cleared {Count} pending operations", count);
} }
} }
} }

View File

@@ -1,32 +1,34 @@
using System; using System;
using System.Threading;
using System.Threading.Tasks; namespace ZB.MOM.WW.CBDDC.Core.Sync;
namespace ZB.MOM.WW.CBDDC.Core.Sync; /// <summary>
/// Represents a pending operation to be executed when connection is restored.
/// <summary> /// </summary>
/// Represents a pending operation to be executed when connection is restored.
/// </summary>
public class PendingOperation public class PendingOperation
{ {
/// <summary> /// <summary>
/// Gets or sets the operation type. /// Gets or sets the operation type.
/// </summary> /// </summary>
public string Type { get; set; } = ""; public string Type { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the collection targeted by the operation. /// Gets or sets the collection targeted by the operation.
/// </summary> /// </summary>
public string Collection { get; set; } = ""; public string Collection { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the document key targeted by the operation. /// Gets or sets the document key targeted by the operation.
/// </summary> /// </summary>
public string Key { get; set; } = ""; public string Key { get; set; } = "";
/// <summary> /// <summary>
/// Gets or sets the payload associated with the operation. /// Gets or sets the payload associated with the operation.
/// </summary> /// </summary>
public object? Data { get; set; } public object? Data { get; set; }
/// <summary> /// <summary>
/// Gets or sets the UTC time when the operation was queued. /// Gets or sets the UTC time when the operation was queued.
/// </summary> /// </summary>
public DateTime QueuedAt { get; set; } public DateTime QueuedAt { get; set; }
} }

View File

@@ -1,28 +1,27 @@
using System;
using System.Buffers; using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Text.Json; using System.Text.Json;
namespace ZB.MOM.WW.CBDDC.Core.Sync; namespace ZB.MOM.WW.CBDDC.Core.Sync;
/// <summary> /// <summary>
/// Resolves merge conflicts by recursively merging object and array nodes. /// Resolves merge conflicts by recursively merging object and array nodes.
/// </summary> /// </summary>
public class RecursiveNodeMergeConflictResolver : IConflictResolver public class RecursiveNodeMergeConflictResolver : IConflictResolver
{ {
/// <summary> /// <summary>
/// Resolves a conflict between a local document and a remote operation. /// Resolves a conflict between a local document and a remote operation.
/// </summary> /// </summary>
/// <param name="local">The local document, or <see langword="null"/> if none exists.</param> /// <param name="local">The local document, or <see langword="null" /> if none exists.</param>
/// <param name="remote">The remote operation to apply.</param> /// <param name="remote">The remote operation to apply.</param>
/// <returns>The conflict resolution result indicating whether and what to apply.</returns> /// <returns>The conflict resolution result indicating whether and what to apply.</returns>
public ConflictResolutionResult Resolve(Document? local, OplogEntry remote) public ConflictResolutionResult Resolve(Document? local, OplogEntry remote)
{ {
if (local == null) if (local == null)
{ {
var content = remote.Payload ?? default; var content = remote.Payload ?? default;
var newDoc = new Document(remote.Collection, remote.Key, content, remote.Timestamp, remote.Operation == OperationType.Delete); var newDoc = new Document(remote.Collection, remote.Key, content, remote.Timestamp,
remote.Operation == OperationType.Delete);
return ConflictResolutionResult.Apply(newDoc); return ConflictResolutionResult.Apply(newDoc);
} }
@@ -33,6 +32,7 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
var newDoc = new Document(remote.Collection, remote.Key, default, remote.Timestamp, true); var newDoc = new Document(remote.Collection, remote.Key, default, remote.Timestamp, true);
return ConflictResolutionResult.Apply(newDoc); return ConflictResolutionResult.Apply(newDoc);
} }
return ConflictResolutionResult.Ignore(); return ConflictResolutionResult.Ignore();
} }
@@ -41,12 +41,14 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
var localTs = local.UpdatedAt; var localTs = local.UpdatedAt;
var remoteTs = remote.Timestamp; var remoteTs = remote.Timestamp;
if (localJson.ValueKind == JsonValueKind.Undefined) return ConflictResolutionResult.Apply(new Document(remote.Collection, remote.Key, remoteJson, remoteTs, false)); if (localJson.ValueKind == JsonValueKind.Undefined)
if (remoteJson.ValueKind == JsonValueKind.Undefined) return ConflictResolutionResult.Ignore(); return ConflictResolutionResult.Apply(new Document(remote.Collection, remote.Key, remoteJson, remoteTs,
false));
// Optimization: Use ArrayBufferWriter (Net6.0) or MemoryStream (NS2.0) if (remoteJson.ValueKind == JsonValueKind.Undefined) return ConflictResolutionResult.Ignore();
// Utf8JsonWriter works with both, but ArrayBufferWriter is more efficient for high throughput.
// Optimization: Use ArrayBufferWriter (Net6.0) or MemoryStream (NS2.0)
// Utf8JsonWriter works with both, but ArrayBufferWriter is more efficient for high throughput.
JsonElement mergedDocJson; JsonElement mergedDocJson;
#if NET6_0_OR_GREATER #if NET6_0_OR_GREATER
@@ -55,7 +57,8 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
{ {
MergeJson(writer, localJson, localTs, remoteJson, remoteTs); MergeJson(writer, localJson, localTs, remoteJson, remoteTs);
} }
mergedDocJson = JsonDocument.Parse(bufferWriter.WrittenMemory).RootElement;
mergedDocJson = JsonDocument.Parse(bufferWriter.WrittenMemory).RootElement;
#else #else
using (var ms = new MemoryStream()) using (var ms = new MemoryStream())
{ {
@@ -67,13 +70,14 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
mergedDocJson = JsonDocument.Parse(ms.ToArray()).RootElement; mergedDocJson = JsonDocument.Parse(ms.ToArray()).RootElement;
} }
#endif #endif
var maxTimestamp = remoteTs.CompareTo(localTs) > 0 ? remoteTs : localTs; var maxTimestamp = remoteTs.CompareTo(localTs) > 0 ? remoteTs : localTs;
var mergedDoc = new Document(remote.Collection, remote.Key, mergedDocJson, maxTimestamp, false); var mergedDoc = new Document(remote.Collection, remote.Key, mergedDocJson, maxTimestamp, false);
return ConflictResolutionResult.Apply(mergedDoc); return ConflictResolutionResult.Apply(mergedDoc);
} }
private void MergeJson(Utf8JsonWriter writer, JsonElement local, HlcTimestamp localTs, JsonElement remote, HlcTimestamp remoteTs) private void MergeJson(Utf8JsonWriter writer, JsonElement local, HlcTimestamp localTs, JsonElement remote,
HlcTimestamp remoteTs)
{ {
if (local.ValueKind != remote.ValueKind) if (local.ValueKind != remote.ValueKind)
{ {
@@ -93,7 +97,7 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
break; break;
default: default:
// Primitives // Primitives
if (local.GetRawText() == remote.GetRawText()) if (local.GetRawText() == remote.GetRawText())
{ {
local.WriteTo(writer); local.WriteTo(writer);
} }
@@ -102,54 +106,51 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
if (remoteTs.CompareTo(localTs) > 0) remote.WriteTo(writer); if (remoteTs.CompareTo(localTs) > 0) remote.WriteTo(writer);
else local.WriteTo(writer); else local.WriteTo(writer);
} }
break; break;
} }
} }
private void MergeObjects(Utf8JsonWriter writer, JsonElement local, HlcTimestamp localTs, JsonElement remote, HlcTimestamp remoteTs) private void MergeObjects(Utf8JsonWriter writer, JsonElement local, HlcTimestamp localTs, JsonElement remote,
HlcTimestamp remoteTs)
{ {
writer.WriteStartObject(); writer.WriteStartObject();
// We need to iterate keys. To avoid double iteration efficiently, we can use a dictionary for the UNION of keys. // We need to iterate keys. To avoid double iteration efficiently, we can use a dictionary for the UNION of keys.
// But populating a dictionary is effectively what we did before. // But populating a dictionary is effectively what we did before.
// Can we do better? // Can we do better?
// Yes: Iterate Local, write merged/local. Track handled keys. Then iterate Remote, write remaining. // Yes: Iterate Local, write merged/local. Track handled keys. Then iterate Remote, write remaining.
var processedKeys = new HashSet<string>(); var processedKeys = new HashSet<string>();
foreach (var prop in local.EnumerateObject()) foreach (var prop in local.EnumerateObject())
{ {
var key = prop.Name; string key = prop.Name;
processedKeys.Add(key); // Mark as processed processedKeys.Add(key); // Mark as processed
writer.WritePropertyName(key); writer.WritePropertyName(key);
if (remote.TryGetProperty(key, out var remoteVal)) if (remote.TryGetProperty(key, out var remoteVal))
{
// Collision -> Merge // Collision -> Merge
MergeJson(writer, prop.Value, localTs, remoteVal, remoteTs); MergeJson(writer, prop.Value, localTs, remoteVal, remoteTs);
}
else else
{
// Only local // Only local
prop.Value.WriteTo(writer); prop.Value.WriteTo(writer);
}
} }
foreach (var prop in remote.EnumerateObject()) foreach (var prop in remote.EnumerateObject())
{
if (!processedKeys.Contains(prop.Name)) if (!processedKeys.Contains(prop.Name))
{ {
// New from remote // New from remote
writer.WritePropertyName(prop.Name); writer.WritePropertyName(prop.Name);
prop.Value.WriteTo(writer); prop.Value.WriteTo(writer);
} }
}
writer.WriteEndObject(); writer.WriteEndObject();
} }
private void MergeArrays(Utf8JsonWriter writer, JsonElement local, HlcTimestamp localTs, JsonElement remote, HlcTimestamp remoteTs) private void MergeArrays(Utf8JsonWriter writer, JsonElement local, HlcTimestamp localTs, JsonElement remote,
HlcTimestamp remoteTs)
{ {
// Heuristic check // Heuristic check
bool localIsObj = HasObjects(local); bool localIsObj = HasObjects(local);
@@ -164,10 +165,10 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
} }
if (localIsObj != remoteIsObj) if (localIsObj != remoteIsObj)
{ {
// Mixed mistmatch LWW // Mixed mistmatch LWW
if (remoteTs.CompareTo(localTs) > 0) remote.WriteTo(writer); if (remoteTs.CompareTo(localTs) > 0) remote.WriteTo(writer);
else local.WriteTo(writer); else local.WriteTo(writer);
return; return;
} }
@@ -184,44 +185,36 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
return; return;
} }
writer.WriteStartArray(); writer.WriteStartArray();
// We want to write Union of items by ID. // We want to write Union of items by ID.
// To preserve some semblance of order (or just determinism), we can iterate local IDs first, then remote new IDs. // To preserve some semblance of order (or just determinism), we can iterate local IDs first, then remote new IDs.
// Or just use the dictionary values. // Or just use the dictionary values.
// NOTE: We cannot simply write to writer inside the map loop if we are creating a merged map. // NOTE: We cannot simply write to writer inside the map loop if we are creating a merged map.
// Let's iterate the union of keys similar to Objects. // Let's iterate the union of keys similar to Objects.
var processedIds = new HashSet<string>(); var processedIds = new HashSet<string>();
// 1. Process Local Items (Merge or Write) // 1. Process Local Items (Merge or Write)
foreach (var kvp in localMap) foreach (var kvp in localMap)
{ {
var id = kvp.Key; string id = kvp.Key;
var localItem = kvp.Value; var localItem = kvp.Value;
processedIds.Add(id); processedIds.Add(id);
if (remoteMap.TryGetValue(id, out var remoteItem)) if (remoteMap.TryGetValue(id, out var remoteItem))
{
// Merge recursively // Merge recursively
MergeJson(writer, localItem, localTs, remoteItem, remoteTs); MergeJson(writer, localItem, localTs, remoteItem, remoteTs);
}
else else
{
// Keep local item // Keep local item
localItem.WriteTo(writer); localItem.WriteTo(writer);
}
} }
// 2. Process New Remote Items // 2. Process New Remote Items
foreach (var kvp in remoteMap) foreach (var kvp in remoteMap)
{
if (!processedIds.Contains(kvp.Key)) if (!processedIds.Contains(kvp.Key))
{
kvp.Value.WriteTo(writer); kvp.Value.WriteTo(writer);
}
}
writer.WriteEndArray(); writer.WriteEndArray();
} }
@@ -249,6 +242,7 @@ public class RecursiveNodeMergeConflictResolver : IConflictResolver
map[id] = item; map[id] = item;
} }
return map; return map;
} }
} }

View File

@@ -5,86 +5,84 @@ using System.Linq;
namespace ZB.MOM.WW.CBDDC.Core; namespace ZB.MOM.WW.CBDDC.Core;
/// <summary> /// <summary>
/// Represents a Vector Clock for tracking causality in a distributed system. /// Represents a Vector Clock for tracking causality in a distributed system.
/// Maps NodeId -> HlcTimestamp to track the latest known state of each node. /// Maps NodeId -> HlcTimestamp to track the latest known state of each node.
/// </summary> /// </summary>
public class VectorClock public class VectorClock
{ {
private readonly Dictionary<string, HlcTimestamp> _clock; private readonly Dictionary<string, HlcTimestamp> _clock;
/// <summary>
/// Initializes a new empty vector clock.
/// </summary>
public VectorClock()
{
_clock = new Dictionary<string, HlcTimestamp>(StringComparer.Ordinal);
}
/// <summary>
/// Initializes a new vector clock from an existing clock state.
/// </summary>
/// <param name="clock">The clock state to copy.</param>
public VectorClock(Dictionary<string, HlcTimestamp> clock)
{
_clock = new Dictionary<string, HlcTimestamp>(clock, StringComparer.Ordinal);
}
/// <summary> /// <summary>
/// Gets all node IDs in this vector clock. /// Initializes a new empty vector clock.
/// </summary>
public VectorClock()
{
_clock = new Dictionary<string, HlcTimestamp>(StringComparer.Ordinal);
}
/// <summary>
/// Initializes a new vector clock from an existing clock state.
/// </summary>
/// <param name="clock">The clock state to copy.</param>
public VectorClock(Dictionary<string, HlcTimestamp> clock)
{
_clock = new Dictionary<string, HlcTimestamp>(clock, StringComparer.Ordinal);
}
/// <summary>
/// Gets all node IDs in this vector clock.
/// </summary> /// </summary>
public IEnumerable<string> NodeIds => _clock.Keys; public IEnumerable<string> NodeIds => _clock.Keys;
/// <summary> /// <summary>
/// Gets the timestamp for a specific node, or default if not present. /// Gets the timestamp for a specific node, or default if not present.
/// </summary> /// </summary>
/// <param name="nodeId">The node identifier.</param> /// <param name="nodeId">The node identifier.</param>
public HlcTimestamp GetTimestamp(string nodeId) public HlcTimestamp GetTimestamp(string nodeId)
{ {
return _clock.TryGetValue(nodeId, out var ts) ? ts : default; return _clock.TryGetValue(nodeId, out var ts) ? ts : default;
} }
/// <summary> /// <summary>
/// Sets or updates the timestamp for a specific node. /// Sets or updates the timestamp for a specific node.
/// </summary> /// </summary>
/// <param name="nodeId">The node identifier.</param> /// <param name="nodeId">The node identifier.</param>
/// <param name="timestamp">The timestamp to set.</param> /// <param name="timestamp">The timestamp to set.</param>
public void SetTimestamp(string nodeId, HlcTimestamp timestamp) public void SetTimestamp(string nodeId, HlcTimestamp timestamp)
{ {
_clock[nodeId] = timestamp; _clock[nodeId] = timestamp;
} }
/// <summary> /// <summary>
/// Merges another vector clock into this one, taking the maximum timestamp for each node. /// Merges another vector clock into this one, taking the maximum timestamp for each node.
/// </summary> /// </summary>
/// <param name="other">The vector clock to merge from.</param> /// <param name="other">The vector clock to merge from.</param>
public void Merge(VectorClock other) public void Merge(VectorClock other)
{ {
foreach (var nodeId in other.NodeIds) foreach (string nodeId in other.NodeIds)
{ {
var otherTs = other.GetTimestamp(nodeId); var otherTs = other.GetTimestamp(nodeId);
if (!_clock.TryGetValue(nodeId, out var currentTs) || otherTs.CompareTo(currentTs) > 0) if (!_clock.TryGetValue(nodeId, out var currentTs) || otherTs.CompareTo(currentTs) > 0)
{
_clock[nodeId] = otherTs; _clock[nodeId] = otherTs;
}
} }
} }
/// <summary> /// <summary>
/// Compares this vector clock with another to determine causality. /// Compares this vector clock with another to determine causality.
/// Returns: /// Returns:
/// - Positive: This is strictly ahead (dominates other) /// - Positive: This is strictly ahead (dominates other)
/// - Negative: Other is strictly ahead (other dominates this) /// - Negative: Other is strictly ahead (other dominates this)
/// - Zero: Concurrent (neither dominates) /// - Zero: Concurrent (neither dominates)
/// </summary> /// </summary>
/// <param name="other">The vector clock to compare with.</param> /// <param name="other">The vector clock to compare with.</param>
public CausalityRelation CompareTo(VectorClock other) public CausalityRelation CompareTo(VectorClock other)
{ {
bool thisAhead = false; var thisAhead = false;
bool otherAhead = false; var otherAhead = false;
var allNodes = new HashSet<string>(_clock.Keys.Union(other._clock.Keys), StringComparer.Ordinal); var allNodes = new HashSet<string>(_clock.Keys.Union(other._clock.Keys), StringComparer.Ordinal);
foreach (var nodeId in allNodes) foreach (string nodeId in allNodes)
{ {
var thisTs = GetTimestamp(nodeId); var thisTs = GetTimestamp(nodeId);
var otherTs = other.GetTimestamp(nodeId); var otherTs = other.GetTimestamp(nodeId);
@@ -92,19 +90,11 @@ public class VectorClock
int cmp = thisTs.CompareTo(otherTs); int cmp = thisTs.CompareTo(otherTs);
if (cmp > 0) if (cmp > 0)
{
thisAhead = true; thisAhead = true;
} else if (cmp < 0) otherAhead = true;
else if (cmp < 0)
{
otherAhead = true;
}
// Early exit if concurrent // Early exit if concurrent
if (thisAhead && otherAhead) if (thisAhead && otherAhead) return CausalityRelation.Concurrent;
{
return CausalityRelation.Concurrent;
}
} }
if (thisAhead && !otherAhead) if (thisAhead && !otherAhead)
@@ -115,65 +105,56 @@ public class VectorClock
return CausalityRelation.Equal; return CausalityRelation.Equal;
} }
/// <summary> /// <summary>
/// Determines which nodes have updates that this vector clock doesn't have. /// Determines which nodes have updates that this vector clock doesn't have.
/// Returns node IDs where the other vector clock is ahead. /// Returns node IDs where the other vector clock is ahead.
/// </summary> /// </summary>
/// <param name="other">The vector clock to compare against.</param> /// <param name="other">The vector clock to compare against.</param>
public IEnumerable<string> GetNodesWithUpdates(VectorClock other) public IEnumerable<string> GetNodesWithUpdates(VectorClock other)
{ {
var allNodes = new HashSet<string>(_clock.Keys, StringComparer.Ordinal); var allNodes = new HashSet<string>(_clock.Keys, StringComparer.Ordinal);
foreach (var nodeId in other._clock.Keys) foreach (string nodeId in other._clock.Keys) allNodes.Add(nodeId);
{
allNodes.Add(nodeId);
}
foreach (var nodeId in allNodes) foreach (string nodeId in allNodes)
{ {
var thisTs = GetTimestamp(nodeId); var thisTs = GetTimestamp(nodeId);
var otherTs = other.GetTimestamp(nodeId); var otherTs = other.GetTimestamp(nodeId);
if (otherTs.CompareTo(thisTs) > 0) if (otherTs.CompareTo(thisTs) > 0) yield return nodeId;
{
yield return nodeId;
}
}
}
/// <summary>
/// Determines which nodes have updates that the other vector clock doesn't have.
/// Returns node IDs where this vector clock is ahead.
/// </summary>
/// <param name="other">The vector clock to compare against.</param>
public IEnumerable<string> GetNodesToPush(VectorClock other)
{
var allNodes = new HashSet<string>(_clock.Keys.Union(other._clock.Keys), StringComparer.Ordinal);
foreach (var nodeId in allNodes)
{
var thisTs = GetTimestamp(nodeId);
var otherTs = other.GetTimestamp(nodeId);
if (thisTs.CompareTo(otherTs) > 0)
{
yield return nodeId;
}
} }
} }
/// <summary> /// <summary>
/// Creates a copy of this vector clock. /// Determines which nodes have updates that the other vector clock doesn't have.
/// Returns node IDs where this vector clock is ahead.
/// </summary>
/// <param name="other">The vector clock to compare against.</param>
public IEnumerable<string> GetNodesToPush(VectorClock other)
{
var allNodes = new HashSet<string>(_clock.Keys.Union(other._clock.Keys), StringComparer.Ordinal);
foreach (string nodeId in allNodes)
{
var thisTs = GetTimestamp(nodeId);
var otherTs = other.GetTimestamp(nodeId);
if (thisTs.CompareTo(otherTs) > 0) yield return nodeId;
}
}
/// <summary>
/// Creates a copy of this vector clock.
/// </summary> /// </summary>
public VectorClock Clone() public VectorClock Clone()
{ {
return new VectorClock(new Dictionary<string, HlcTimestamp>(_clock, StringComparer.Ordinal)); return new VectorClock(new Dictionary<string, HlcTimestamp>(_clock, StringComparer.Ordinal));
} }
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()
{ {
if (_clock.Count == 0) if (_clock.Count == 0)
return "{}"; return "{}";
var entries = _clock.Select(kvp => $"{kvp.Key}:{kvp.Value}"); var entries = _clock.Select(kvp => $"{kvp.Key}:{kvp.Value}");
return "{" + string.Join(", ", entries) + "}"; return "{" + string.Join(", ", entries) + "}";
@@ -181,16 +162,19 @@ public class VectorClock
} }
/// <summary> /// <summary>
/// Represents the causality relationship between two vector clocks. /// Represents the causality relationship between two vector clocks.
/// </summary> /// </summary>
public enum CausalityRelation public enum CausalityRelation
{ {
/// <summary>Both vector clocks are equal.</summary> /// <summary>Both vector clocks are equal.</summary>
Equal, Equal,
/// <summary>This vector clock is strictly ahead (dominates).</summary> /// <summary>This vector clock is strictly ahead (dominates).</summary>
StrictlyAhead, StrictlyAhead,
/// <summary>This vector clock is strictly behind (dominated).</summary> /// <summary>This vector clock is strictly behind (dominated).</summary>
StrictlyBehind, StrictlyBehind,
/// <summary>Vector clocks are concurrent (neither dominates).</summary> /// <summary>Vector clocks are concurrent (neither dominates).</summary>
Concurrent Concurrent
} }

View File

@@ -1,33 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>ZB.MOM.WW.CBDDC.Core</AssemblyName> <AssemblyName>ZB.MOM.WW.CBDDC.Core</AssemblyName>
<RootNamespace>ZB.MOM.WW.CBDDC.Core</RootNamespace> <RootNamespace>ZB.MOM.WW.CBDDC.Core</RootNamespace>
<PackageId>ZB.MOM.WW.CBDDC.Core</PackageId> <PackageId>ZB.MOM.WW.CBDDC.Core</PackageId>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>1.0.3</Version> <Version>1.0.3</Version>
<Authors>MrDevRobot</Authors> <Authors>MrDevRobot</Authors>
<Description>Core abstractions and logic for CBDDC, a lightweight P2P mesh database.</Description> <Description>Core abstractions and logic for CBDDC, a lightweight P2P mesh database.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>p2p;mesh;database;gossip;cbddc;lan;offline-first;distributed</PackageTags> <PackageTags>p2p;mesh;database;gossip;cbddc;lan;offline-first;distributed</PackageTags>
<PackageProjectUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</PackageProjectUrl> <PackageProjectUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</PackageProjectUrl>
<RepositoryUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</RepositoryUrl> <RepositoryUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</RepositoryUrl>
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" /> <None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Storage\Events\" /> <Folder Include="Storage\Events\"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,22 +1,22 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.CBDDC.Hosting.Configuration; using ZB.MOM.WW.CBDDC.Hosting.Configuration;
using ZB.MOM.WW.CBDDC.Hosting.HealthChecks; using ZB.MOM.WW.CBDDC.Hosting.HealthChecks;
using ZB.MOM.WW.CBDDC.Hosting.HostedServices; using ZB.MOM.WW.CBDDC.Hosting.HostedServices;
using ZB.MOM.WW.CBDDC.Hosting.Services; using ZB.MOM.WW.CBDDC.Hosting.Services;
using ZB.MOM.WW.CBDDC.Network; using ZB.MOM.WW.CBDDC.Network;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
namespace ZB.MOM.WW.CBDDC.Hosting; namespace ZB.MOM.WW.CBDDC.Hosting;
/// <summary> /// <summary>
/// Extension methods for configuring CBDDC in ASP.NET Core applications. /// Extension methods for configuring CBDDC in ASP.NET Core applications.
/// </summary> /// </summary>
public static class CBDDCHostingExtensions public static class CBDDCHostingExtensions
{ {
/// <summary> /// <summary>
/// Adds CBDDC ASP.NET integration with the specified configuration. /// Adds CBDDC ASP.NET integration with the specified configuration.
/// </summary> /// </summary>
/// <param name="services">The service collection.</param> /// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure CBDDC options.</param> /// <param name="configure">Action to configure CBDDC options.</param>
@@ -43,7 +43,7 @@ public static class CBDDCHostingExtensions
} }
/// <summary> /// <summary>
/// Adds CBDDC ASP.NET integration for single-cluster mode. /// Adds CBDDC ASP.NET integration for single-cluster mode.
/// </summary> /// </summary>
/// <param name="services">The service collection.</param> /// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure single-cluster options.</param> /// <param name="configure">Action to configure single-cluster options.</param>
@@ -51,10 +51,7 @@ public static class CBDDCHostingExtensions
this IServiceCollection services, this IServiceCollection services,
Action<ClusterOptions>? configure = null) Action<ClusterOptions>? configure = null)
{ {
return services.AddCBDDCHosting(options => return services.AddCBDDCHosting(options => { configure?.Invoke(options.Cluster); });
{
configure?.Invoke(options.Cluster);
});
} }
private static void RegisterSingleClusterServices( private static void RegisterSingleClusterServices(
@@ -81,12 +78,10 @@ public static class CBDDCHostingExtensions
{ {
// Health checks // Health checks
if (options.EnableHealthChecks) if (options.EnableHealthChecks)
{
services.AddHealthChecks() services.AddHealthChecks()
.AddCheck<CBDDCHealthCheck>( .AddCheck<CBDDCHealthCheck>(
"cbddc", "cbddc",
failureStatus: HealthStatus.Unhealthy, HealthStatus.Unhealthy,
tags: new[] { "db", "ready" }); new[] { "db", "ready" });
}
} }
} }

View File

@@ -1,18 +1,18 @@
namespace ZB.MOM.WW.CBDDC.Hosting.Configuration; namespace ZB.MOM.WW.CBDDC.Hosting.Configuration;
/// <summary> /// <summary>
/// Configuration options for CBDDC ASP.NET integration. /// Configuration options for CBDDC ASP.NET integration.
/// </summary> /// </summary>
public class CBDDCHostingOptions public class CBDDCHostingOptions
{ {
/// <summary> /// <summary>
/// Gets or sets the cluster configuration. /// Gets or sets the cluster configuration.
/// </summary> /// </summary>
public ClusterOptions Cluster { get; set; } = new(); public ClusterOptions Cluster { get; set; } = new();
/// <summary> /// <summary>
/// Gets or sets whether to enable health checks. /// Gets or sets whether to enable health checks.
/// Default: true /// Default: true
/// </summary> /// </summary>
public bool EnableHealthChecks { get; set; } = true; public bool EnableHealthChecks { get; set; } = true;
} }

View File

@@ -1,40 +1,39 @@
using System;
namespace ZB.MOM.WW.CBDDC.Hosting.Configuration; namespace ZB.MOM.WW.CBDDC.Hosting.Configuration;
/// <summary> /// <summary>
/// Configuration options for cluster mode. /// Configuration options for cluster mode.
/// </summary> /// </summary>
public class ClusterOptions public class ClusterOptions
{ {
/// <summary> /// <summary>
/// Gets or sets the node identifier for this instance. /// Gets or sets the node identifier for this instance.
/// </summary> /// </summary>
public string NodeId { get; set; } = Environment.MachineName; public string NodeId { get; set; } = Environment.MachineName;
/// <summary> /// <summary>
/// Gets or sets the TCP port for sync operations. /// Gets or sets the TCP port for sync operations.
/// Default: 5001 /// Default: 5001
/// </summary> /// </summary>
public int TcpPort { get; set; } = 5001; public int TcpPort { get; set; } = 5001;
/// <summary> /// <summary>
/// Gets or sets whether to enable UDP discovery. /// Gets or sets whether to enable UDP discovery.
/// Default: false (disabled in server mode) /// Default: false (disabled in server mode)
/// </summary> /// </summary>
public bool EnableUdpDiscovery { get; set; } = false; public bool EnableUdpDiscovery { get; set; } = false;
/// <summary> /// <summary>
/// Gets or sets the lag threshold (in milliseconds) used to determine when a tracked peer is considered lagging. /// Gets or sets the lag threshold (in milliseconds) used to determine when a tracked peer is considered lagging.
/// Peers above this threshold degrade health status. /// Peers above this threshold degrade health status.
/// Default: 30,000 ms. /// Default: 30,000 ms.
/// </summary> /// </summary>
public long PeerConfirmationLagThresholdMs { get; set; } = 30_000; public long PeerConfirmationLagThresholdMs { get; set; } = 30_000;
/// <summary> /// <summary>
/// Gets or sets the critical lag threshold (in milliseconds) used to determine when a tracked peer causes unhealthy status. /// Gets or sets the critical lag threshold (in milliseconds) used to determine when a tracked peer causes unhealthy
/// Peers above this threshold mark health as unhealthy. /// status.
/// Default: 120,000 ms. /// Peers above this threshold mark health as unhealthy.
/// Default: 120,000 ms.
/// </summary> /// </summary>
public long PeerConfirmationCriticalLagThresholdMs { get; set; } = 120_000; public long PeerConfirmationCriticalLagThresholdMs { get; set; } = 120_000;
} }

View File

@@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Hosting.Configuration; using ZB.MOM.WW.CBDDC.Hosting.Configuration;
@@ -10,17 +5,17 @@ using ZB.MOM.WW.CBDDC.Hosting.Configuration;
namespace ZB.MOM.WW.CBDDC.Hosting.HealthChecks; namespace ZB.MOM.WW.CBDDC.Hosting.HealthChecks;
/// <summary> /// <summary>
/// Health check for CBDDC persistence layer. /// Health check for CBDDC persistence layer.
/// Verifies that the database connection is healthy. /// Verifies that the database connection is healthy.
/// </summary> /// </summary>
public class CBDDCHealthCheck : IHealthCheck public class CBDDCHealthCheck : IHealthCheck
{ {
private readonly IOplogStore _oplogStore; private readonly IOplogStore _oplogStore;
private readonly IPeerOplogConfirmationStore _peerOplogConfirmationStore;
private readonly CBDDCHostingOptions _options; private readonly CBDDCHostingOptions _options;
private readonly IPeerOplogConfirmationStore _peerOplogConfirmationStore;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CBDDCHealthCheck"/> class. /// Initializes a new instance of the <see cref="CBDDCHealthCheck" /> class.
/// </summary> /// </summary>
/// <param name="oplogStore">The oplog store used to verify persistence health.</param> /// <param name="oplogStore">The oplog store used to verify persistence health.</param>
/// <param name="peerOplogConfirmationStore">The peer confirmation store used for confirmation lag health checks.</param> /// <param name="peerOplogConfirmationStore">The peer confirmation store used for confirmation lag health checks.</param>
@@ -31,16 +26,17 @@ public class CBDDCHealthCheck : IHealthCheck
CBDDCHostingOptions options) CBDDCHostingOptions options)
{ {
_oplogStore = oplogStore ?? throw new ArgumentNullException(nameof(oplogStore)); _oplogStore = oplogStore ?? throw new ArgumentNullException(nameof(oplogStore));
_peerOplogConfirmationStore = peerOplogConfirmationStore ?? throw new ArgumentNullException(nameof(peerOplogConfirmationStore)); _peerOplogConfirmationStore = peerOplogConfirmationStore ??
throw new ArgumentNullException(nameof(peerOplogConfirmationStore));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
} }
/// <summary> /// <summary>
/// Performs a health check against the CBDDC persistence layer. /// Performs a health check against the CBDDC persistence layer.
/// </summary> /// </summary>
/// <param name="context">The health check execution context.</param> /// <param name="context">The health check execution context.</param>
/// <param name="cancellationToken">A token used to cancel the health check.</param> /// <param name="cancellationToken">A token used to cancel the health check.</param>
/// <returns>A <see cref="HealthCheckResult"/> describing the health status.</returns> /// <returns>A <see cref="HealthCheckResult" /> describing the health status.</returns>
public async Task<HealthCheckResult> CheckHealthAsync( public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, HealthCheckContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -58,15 +54,18 @@ public class CBDDCHealthCheck : IHealthCheck
var peersWithNoConfirmation = new List<string>(); var peersWithNoConfirmation = new List<string>();
var laggingPeers = new List<string>(); var laggingPeers = new List<string>();
var criticalLaggingPeers = new List<string>(); var criticalLaggingPeers = new List<string>();
var lastSuccessfulConfirmationUpdateByPeer = new Dictionary<string, DateTimeOffset?>(StringComparer.Ordinal); var lastSuccessfulConfirmationUpdateByPeer =
new Dictionary<string, DateTimeOffset?>(StringComparer.Ordinal);
var maxLagMs = 0L; var maxLagMs = 0L;
var lagThresholdMs = Math.Max(0, _options.Cluster.PeerConfirmationLagThresholdMs); long lagThresholdMs = Math.Max(0, _options.Cluster.PeerConfirmationLagThresholdMs);
var criticalLagThresholdMs = Math.Max(lagThresholdMs, _options.Cluster.PeerConfirmationCriticalLagThresholdMs); long criticalLagThresholdMs =
Math.Max(lagThresholdMs, _options.Cluster.PeerConfirmationCriticalLagThresholdMs);
foreach (var peerNodeId in trackedPeers) foreach (string peerNodeId in trackedPeers)
{ {
var confirmations = (await _peerOplogConfirmationStore.GetConfirmationsForPeerAsync(peerNodeId, cancellationToken)) var confirmations =
(await _peerOplogConfirmationStore.GetConfirmationsForPeerAsync(peerNodeId, cancellationToken))
.Where(confirmation => confirmation.IsActive) .Where(confirmation => confirmation.IsActive)
.ToList(); .ToList();
@@ -83,19 +82,14 @@ public class CBDDCHealthCheck : IHealthCheck
.ThenBy(confirmation => confirmation.ConfirmedLogic) .ThenBy(confirmation => confirmation.ConfirmedLogic)
.First(); .First();
var lagMs = Math.Max(0, localHead.PhysicalTime - oldestConfirmation.ConfirmedWall); long lagMs = Math.Max(0, localHead.PhysicalTime - oldestConfirmation.ConfirmedWall);
maxLagMs = Math.Max(maxLagMs, lagMs); maxLagMs = Math.Max(maxLagMs, lagMs);
lastSuccessfulConfirmationUpdateByPeer[peerNodeId] = confirmations.Max(confirmation => confirmation.LastConfirmedUtc); lastSuccessfulConfirmationUpdateByPeer[peerNodeId] =
confirmations.Max(confirmation => confirmation.LastConfirmedUtc);
if (lagMs > lagThresholdMs) if (lagMs > lagThresholdMs) laggingPeers.Add(peerNodeId);
{
laggingPeers.Add(peerNodeId);
}
if (lagMs > criticalLagThresholdMs) if (lagMs > criticalLagThresholdMs) criticalLaggingPeers.Add(peerNodeId);
{
criticalLaggingPeers.Add(peerNodeId);
}
} }
var payload = new Dictionary<string, object> var payload = new Dictionary<string, object>
@@ -108,18 +102,14 @@ public class CBDDCHealthCheck : IHealthCheck
}; };
if (criticalLaggingPeers.Count > 0) if (criticalLaggingPeers.Count > 0)
{
return HealthCheckResult.Unhealthy( return HealthCheckResult.Unhealthy(
$"CBDDC is unhealthy. Critical lag detected for {criticalLaggingPeers.Count} tracked peer(s).", $"CBDDC is unhealthy. Critical lag detected for {criticalLaggingPeers.Count} tracked peer(s).",
data: payload); data: payload);
}
if (peersWithNoConfirmation.Count > 0 || laggingPeers.Count > 0) if (peersWithNoConfirmation.Count > 0 || laggingPeers.Count > 0)
{
return HealthCheckResult.Degraded( return HealthCheckResult.Degraded(
$"CBDDC is degraded. Lagging peers: {laggingPeers.Count}, unconfirmed peers: {peersWithNoConfirmation.Count}.", $"CBDDC is degraded. Lagging peers: {laggingPeers.Count}, unconfirmed peers: {peersWithNoConfirmation.Count}.",
data: payload); data: payload);
}
return HealthCheckResult.Healthy( return HealthCheckResult.Healthy(
$"CBDDC is healthy. Latest timestamp: {localHead.PhysicalTime}.", $"CBDDC is healthy. Latest timestamp: {localHead.PhysicalTime}.",
@@ -129,7 +119,7 @@ public class CBDDCHealthCheck : IHealthCheck
{ {
return HealthCheckResult.Unhealthy( return HealthCheckResult.Unhealthy(
"CBDDC persistence layer is unavailable", "CBDDC persistence layer is unavailable",
exception: ex); ex);
} }
} }
} }

View File

@@ -1,5 +1,3 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog.Context; using Serilog.Context;
@@ -8,7 +6,7 @@ using ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Hosting.HostedServices; namespace ZB.MOM.WW.CBDDC.Hosting.HostedServices;
/// <summary> /// <summary>
/// Hosted service that manages the lifecycle of the discovery service. /// Hosted service that manages the lifecycle of the discovery service.
/// </summary> /// </summary>
public class DiscoveryServiceHostedService : IHostedService public class DiscoveryServiceHostedService : IHostedService
{ {
@@ -16,7 +14,7 @@ public class DiscoveryServiceHostedService : IHostedService
private readonly ILogger<DiscoveryServiceHostedService> _logger; private readonly ILogger<DiscoveryServiceHostedService> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="DiscoveryServiceHostedService"/> class. /// Initializes a new instance of the <see cref="DiscoveryServiceHostedService" /> class.
/// </summary> /// </summary>
/// <param name="discoveryService">The discovery service to manage.</param> /// <param name="discoveryService">The discovery service to manage.</param>
/// <param name="logger">The logger used for service lifecycle events.</param> /// <param name="logger">The logger used for service lifecycle events.</param>
@@ -29,7 +27,7 @@ public class DiscoveryServiceHostedService : IHostedService
} }
/// <summary> /// <summary>
/// Starts the discovery service. /// Starts the discovery service.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel the startup operation.</param> /// <param name="cancellationToken">A token used to cancel the startup operation.</param>
/// <returns>A task that represents the asynchronous start operation.</returns> /// <returns>A task that represents the asynchronous start operation.</returns>
@@ -45,7 +43,7 @@ public class DiscoveryServiceHostedService : IHostedService
} }
/// <summary> /// <summary>
/// Stops the discovery service. /// Stops the discovery service.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel the shutdown operation.</param> /// <param name="cancellationToken">A token used to cancel the shutdown operation.</param>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// <returns>A task that represents the asynchronous stop operation.</returns>
@@ -59,4 +57,4 @@ public class DiscoveryServiceHostedService : IHostedService
await _discoveryService.Stop(); await _discoveryService.Stop();
_logger.LogInformation("Discovery Service stopped"); _logger.LogInformation("Discovery Service stopped");
} }
} }

View File

@@ -1,5 +1,3 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog.Context; using Serilog.Context;
@@ -8,15 +6,15 @@ using ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Hosting.HostedServices; namespace ZB.MOM.WW.CBDDC.Hosting.HostedServices;
/// <summary> /// <summary>
/// Hosted service that manages the lifecycle of the TCP sync server. /// Hosted service that manages the lifecycle of the TCP sync server.
/// </summary> /// </summary>
public class TcpSyncServerHostedService : IHostedService public class TcpSyncServerHostedService : IHostedService
{ {
private readonly ISyncServer _syncServer;
private readonly ILogger<TcpSyncServerHostedService> _logger; private readonly ILogger<TcpSyncServerHostedService> _logger;
private readonly ISyncServer _syncServer;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TcpSyncServerHostedService"/> class. /// Initializes a new instance of the <see cref="TcpSyncServerHostedService" /> class.
/// </summary> /// </summary>
/// <param name="syncServer">The sync server to start and stop.</param> /// <param name="syncServer">The sync server to start and stop.</param>
/// <param name="logger">The logger instance.</param> /// <param name="logger">The logger instance.</param>
@@ -29,7 +27,7 @@ public class TcpSyncServerHostedService : IHostedService
} }
/// <summary> /// <summary>
/// Starts the TCP sync server. /// Starts the TCP sync server.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel startup.</param> /// <param name="cancellationToken">A token used to cancel startup.</param>
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
@@ -44,7 +42,7 @@ public class TcpSyncServerHostedService : IHostedService
} }
/// <summary> /// <summary>
/// Stops the TCP sync server. /// Stops the TCP sync server.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel shutdown.</param> /// <param name="cancellationToken">A token used to cancel shutdown.</param>
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
@@ -57,4 +55,4 @@ public class TcpSyncServerHostedService : IHostedService
await _syncServer.Stop(); await _syncServer.Stop();
_logger.LogInformation("TCP Sync Server stopped"); _logger.LogInformation("TCP Sync Server stopped");
} }
} }

View File

@@ -41,6 +41,7 @@ app.Run();
## Health Checks ## Health Checks
CBDDC registers health checks that verify: CBDDC registers health checks that verify:
- Database connectivity - Database connectivity
- Latest timestamp retrieval - Latest timestamp retrieval
@@ -53,6 +54,7 @@ curl http://localhost:5000/health
### Cluster ### Cluster
Best for: Best for:
- Dedicated database servers - Dedicated database servers
- Simple deployments - Simple deployments
- Development/testing environments - Development/testing environments
@@ -60,6 +62,7 @@ Best for:
## Server Behavior ## Server Behavior
CBDDC servers operate in respond-only mode: CBDDC servers operate in respond-only mode:
- Accept incoming sync connections - Accept incoming sync connections
- Respond to sync requests - Respond to sync requests
- Do not initiate outbound sync - Do not initiate outbound sync
@@ -69,11 +72,11 @@ CBDDC servers operate in respond-only mode:
### ClusterOptions ### ClusterOptions
| Property | Type | Default | Description | | Property | Type | Default | Description |
|----------|------|---------|-------------| |--------------------|--------|-------------|------------------------|
| NodeId | string | MachineName | Unique node identifier | | NodeId | string | MachineName | Unique node identifier |
| TcpPort | int | 5001 | TCP port for sync | | TcpPort | int | 5001 | TCP port for sync |
| EnableUdpDiscovery | bool | false | Enable UDP discovery | | EnableUdpDiscovery | bool | false | Enable UDP discovery |
## Production Checklist ## Production Checklist

View File

@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Serilog.Context; using Serilog.Context;
@@ -9,24 +7,24 @@ using ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Hosting.Services; namespace ZB.MOM.WW.CBDDC.Hosting.Services;
/// <summary> /// <summary>
/// No-op implementation of IDiscoveryService for server scenarios. /// No-op implementation of IDiscoveryService for server scenarios.
/// Does not perform UDP broadcast discovery - relies on explicit peer configuration. /// Does not perform UDP broadcast discovery - relies on explicit peer configuration.
/// </summary> /// </summary>
public class NoOpDiscoveryService : IDiscoveryService public class NoOpDiscoveryService : IDiscoveryService
{ {
private readonly ILogger<NoOpDiscoveryService> _logger; private readonly ILogger<NoOpDiscoveryService> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="NoOpDiscoveryService"/> class. /// Initializes a new instance of the <see cref="NoOpDiscoveryService" /> class.
/// </summary> /// </summary>
/// <param name="logger">The logger instance to use, or <see langword="null"/> to use a no-op logger.</param> /// <param name="logger">The logger instance to use, or <see langword="null" /> to use a no-op logger.</param>
public NoOpDiscoveryService(ILogger<NoOpDiscoveryService>? logger = null) public NoOpDiscoveryService(ILogger<NoOpDiscoveryService>? logger = null)
{ {
_logger = logger ?? NullLogger<NoOpDiscoveryService>.Instance; _logger = logger ?? NullLogger<NoOpDiscoveryService>.Instance;
} }
/// <summary> /// <summary>
/// Gets the currently active peers. /// Gets the currently active peers.
/// </summary> /// </summary>
/// <returns>An empty sequence because discovery is disabled.</returns> /// <returns>An empty sequence because discovery is disabled.</returns>
public IEnumerable<PeerNode> GetActivePeers() public IEnumerable<PeerNode> GetActivePeers()
@@ -35,7 +33,7 @@ public class NoOpDiscoveryService : IDiscoveryService
} }
/// <summary> /// <summary>
/// Starts the discovery service. /// Starts the discovery service.
/// </summary> /// </summary>
/// <returns>A completed task.</returns> /// <returns>A completed task.</returns>
public Task Start() public Task Start()
@@ -49,7 +47,7 @@ public class NoOpDiscoveryService : IDiscoveryService
} }
/// <summary> /// <summary>
/// Stops the discovery service. /// Stops the discovery service.
/// </summary> /// </summary>
/// <returns>A completed task.</returns> /// <returns>A completed task.</returns>
public Task Stop() public Task Stop()
@@ -63,10 +61,10 @@ public class NoOpDiscoveryService : IDiscoveryService
} }
/// <summary> /// <summary>
/// Releases resources used by this instance. /// Releases resources used by this instance.
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
_logger.LogDebug("NoOpDiscoveryService disposed"); _logger.LogDebug("NoOpDiscoveryService disposed");
} }
} }

View File

@@ -1,4 +1,3 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Serilog.Context; using Serilog.Context;
@@ -7,24 +6,24 @@ using ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Hosting.Services; namespace ZB.MOM.WW.CBDDC.Hosting.Services;
/// <summary> /// <summary>
/// No-op implementation of ISyncOrchestrator for server scenarios. /// No-op implementation of ISyncOrchestrator for server scenarios.
/// Does not initiate outbound sync - only responds to incoming sync requests. /// Does not initiate outbound sync - only responds to incoming sync requests.
/// </summary> /// </summary>
public class NoOpSyncOrchestrator : ISyncOrchestrator public class NoOpSyncOrchestrator : ISyncOrchestrator
{ {
private readonly ILogger<NoOpSyncOrchestrator> _logger; private readonly ILogger<NoOpSyncOrchestrator> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="NoOpSyncOrchestrator"/> class. /// Initializes a new instance of the <see cref="NoOpSyncOrchestrator" /> class.
/// </summary> /// </summary>
/// <param name="logger">The logger instance to use, or <see langword="null"/> for a no-op logger.</param> /// <param name="logger">The logger instance to use, or <see langword="null" /> for a no-op logger.</param>
public NoOpSyncOrchestrator(ILogger<NoOpSyncOrchestrator>? logger = null) public NoOpSyncOrchestrator(ILogger<NoOpSyncOrchestrator>? logger = null)
{ {
_logger = logger ?? NullLogger<NoOpSyncOrchestrator>.Instance; _logger = logger ?? NullLogger<NoOpSyncOrchestrator>.Instance;
} }
/// <summary> /// <summary>
/// Starts the orchestrator lifecycle. /// Starts the orchestrator lifecycle.
/// </summary> /// </summary>
/// <returns>A completed task.</returns> /// <returns>A completed task.</returns>
public Task Start() public Task Start()
@@ -38,7 +37,7 @@ public class NoOpSyncOrchestrator : ISyncOrchestrator
} }
/// <summary> /// <summary>
/// Stops the orchestrator lifecycle. /// Stops the orchestrator lifecycle.
/// </summary> /// </summary>
/// <returns>A completed task.</returns> /// <returns>A completed task.</returns>
public Task Stop() public Task Stop()
@@ -52,10 +51,10 @@ public class NoOpSyncOrchestrator : ISyncOrchestrator
} }
/// <summary> /// <summary>
/// Releases resources used by the orchestrator. /// Releases resources used by the orchestrator.
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
_logger.LogDebug("NoOpSyncOrchestrator disposed"); _logger.LogDebug("NoOpSyncOrchestrator disposed");
} }
} }

View File

@@ -1,37 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.CBDDC.Network\ZB.MOM.WW.CBDDC.Network.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.CBDDC.Network\ZB.MOM.WW.CBDDC.Network.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0"/>
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<AssemblyName>ZB.MOM.WW.CBDDC.Hosting</AssemblyName> <AssemblyName>ZB.MOM.WW.CBDDC.Hosting</AssemblyName>
<RootNamespace>ZB.MOM.WW.CBDDC.Hosting</RootNamespace> <RootNamespace>ZB.MOM.WW.CBDDC.Hosting</RootNamespace>
<PackageId>ZB.MOM.WW.CBDDC.Hosting</PackageId> <PackageId>ZB.MOM.WW.CBDDC.Hosting</PackageId>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>1.0.3</Version> <Version>1.0.3</Version>
<Authors>MrDevRobot</Authors> <Authors>MrDevRobot</Authors>
<Description>ASP.NET Core integration for CBDDC with health checks and hosted services.</Description> <Description>ASP.NET Core integration for CBDDC with health checks and hosted services.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>p2p;database;aspnetcore;healthcheck;hosting;cluster</PackageTags> <PackageTags>p2p;database;aspnetcore;healthcheck;hosting;cluster</PackageTags>
<PackageProjectUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</PackageProjectUrl> <PackageProjectUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</PackageProjectUrl>
<RepositoryUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</RepositoryUrl> <RepositoryUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</RepositoryUrl>
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" /> <None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,37 +1,21 @@
using System; using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Linq;
using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core.Storage;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Represents a single CBDDC Peer Node. /// Represents a single CBDDC Peer Node.
/// Acts as a facade to orchestrate the lifecycle of Networking, Discovery, and Synchronization components. /// Acts as a facade to orchestrate the lifecycle of Networking, Discovery, and Synchronization components.
/// </summary> /// </summary>
public class CBDDCNode : ICBDDCNode public class CBDDCNode : ICBDDCNode
{ {
private readonly ILogger<CBDDCNode> _logger; private readonly ILogger<CBDDCNode> _logger;
/// <summary>
/// Gets the Sync Server instance.
/// </summary>
public ISyncServer Server { get; }
/// <summary> /// <summary>
/// Gets the Discovery Service instance. /// Initializes a new instance of the <see cref="CBDDCNode" /> class.
/// </summary>
public IDiscoveryService Discovery { get; }
/// <summary>
/// Gets the Synchronization Orchestrator instance.
/// </summary>
public ISyncOrchestrator Orchestrator { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CBDDCNode"/> class.
/// </summary> /// </summary>
/// <param name="server">The TCP server for handling incoming sync requests.</param> /// <param name="server">The TCP server for handling incoming sync requests.</param>
/// <param name="discovery">The UDP service for peer discovery.</param> /// <param name="discovery">The UDP service for peer discovery.</param>
@@ -50,7 +34,22 @@ public class CBDDCNode : ICBDDCNode
} }
/// <summary> /// <summary>
/// Starts all node components (Server, Discovery, Orchestrator). /// Gets the Sync Server instance.
/// </summary>
public ISyncServer Server { get; }
/// <summary>
/// Gets the Discovery Service instance.
/// </summary>
public IDiscoveryService Discovery { get; }
/// <summary>
/// Gets the Synchronization Orchestrator instance.
/// </summary>
public ISyncOrchestrator Orchestrator { get; }
/// <summary>
/// Starts all node components (Server, Discovery, Orchestrator).
/// </summary> /// </summary>
public async Task Start() public async Task Start()
{ {
@@ -66,7 +65,7 @@ public class CBDDCNode : ICBDDCNode
} }
/// <summary> /// <summary>
/// Stops all node components. /// Stops all node components.
/// </summary> /// </summary>
public async Task Stop() public async Task Stop()
{ {
@@ -82,7 +81,7 @@ public class CBDDCNode : ICBDDCNode
} }
/// <summary> /// <summary>
/// Gets the address information of this node. /// Gets the address information of this node.
/// </summary> /// </summary>
public NodeAddress Address public NodeAddress Address
{ {
@@ -93,12 +92,11 @@ public class CBDDCNode : ICBDDCNode
{ {
// If the server is listening on "Any" (0.0.0.0), we cannot advertise that as a connectable address. // If the server is listening on "Any" (0.0.0.0), we cannot advertise that as a connectable address.
// We must resolve the actual machine IP address that peers can reach. // We must resolve the actual machine IP address that peers can reach.
if (Equals(ep.Address, System.Net.IPAddress.Any) || Equals(ep.Address, System.Net.IPAddress.IPv6Any)) if (Equals(ep.Address, IPAddress.Any) || Equals(ep.Address, IPAddress.IPv6Any))
{
return new NodeAddress(GetLocalIpAddress(), ep.Port); return new NodeAddress(GetLocalIpAddress(), ep.Port);
}
return new NodeAddress(ep.Address.ToString(), ep.Port); return new NodeAddress(ep.Address.ToString(), ep.Port);
} }
return new NodeAddress("Unknown", 0); return new NodeAddress("Unknown", 0);
} }
} }
@@ -107,20 +105,17 @@ public class CBDDCNode : ICBDDCNode
{ {
try try
{ {
var interfaces = System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces() var interfaces = NetworkInterface.GetAllNetworkInterfaces()
.Where(i => i.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up .Where(i => i.OperationalStatus == OperationalStatus.Up
&& i.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Loopback); && i.NetworkInterfaceType != NetworkInterfaceType.Loopback);
foreach (var i in interfaces) foreach (var i in interfaces)
{ {
var props = i.GetIPProperties(); var props = i.GetIPProperties();
var ipInfo = props.UnicastAddresses var ipInfo = props.UnicastAddresses
.FirstOrDefault(u => u.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); // Prefer IPv4 .FirstOrDefault(u => u.Address.AddressFamily == AddressFamily.InterNetwork); // Prefer IPv4
if (ipInfo != null) if (ipInfo != null) return ipInfo.Address.ToString();
{
return ipInfo.Address.ToString();
}
} }
return "127.0.0.1"; return "127.0.0.1";
@@ -133,28 +128,32 @@ public class CBDDCNode : ICBDDCNode
} }
} }
public class NodeAddress public class NodeAddress
{ {
/// <summary> /// <summary>
/// Gets the host portion of the node address. /// Initializes a new instance of the <see cref="NodeAddress" /> class.
/// </summary> /// </summary>
public string Host { get; } /// <param name="host">The host name or IP address.</param>
/// <summary> /// <param name="port">The port number.</param>
/// Gets the port portion of the node address. public NodeAddress(string host, int port)
/// </summary> {
public int Port { get; } Host = host;
Port = port;
/// <summary> }
/// Initializes a new instance of the <see cref="NodeAddress"/> class.
/// </summary> /// <summary>
/// <param name="host">The host name or IP address.</param> /// Gets the host portion of the node address.
/// <param name="port">The port number.</param> /// </summary>
public NodeAddress(string host, int port) public string Host { get; }
{
Host = host; /// <summary>
Port = port; /// Gets the port portion of the node address.
} /// </summary>
public int Port { get; }
/// <inheritdoc />
public override string ToString() => $"{Host}:{Port}"; /// <inheritdoc />
} public override string ToString()
{
return $"{Host}:{Port}";
}
}

View File

@@ -1,22 +1,19 @@
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog.Context; using Serilog.Context;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Hosted service that automatically starts and stops the CBDDC node. /// Hosted service that automatically starts and stops the CBDDC node.
/// </summary> /// </summary>
public class CBDDCNodeService : IHostedService public class CBDDCNodeService : IHostedService
{ {
private readonly ICBDDCNode _node;
private readonly ILogger<CBDDCNodeService> _logger; private readonly ILogger<CBDDCNodeService> _logger;
private readonly ICBDDCNode _node;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CBDDCNodeService"/> class. /// Initializes a new instance of the <see cref="CBDDCNodeService" /> class.
/// </summary> /// </summary>
/// <param name="node">The CBDDC node to manage.</param> /// <param name="node">The CBDDC node to manage.</param>
/// <param name="logger">The logger instance.</param> /// <param name="logger">The logger instance.</param>
@@ -27,7 +24,7 @@ public class CBDDCNodeService : IHostedService
} }
/// <summary> /// <summary>
/// Starts the managed CBDDC node. /// Starts the managed CBDDC node.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel startup.</param> /// <param name="cancellationToken">A token used to cancel startup.</param>
/// <returns>A task that represents the asynchronous start operation.</returns> /// <returns>A task that represents the asynchronous start operation.</returns>
@@ -60,7 +57,7 @@ public class CBDDCNodeService : IHostedService
} }
/// <summary> /// <summary>
/// Stops the managed CBDDC node. /// Stops the managed CBDDC node.
/// </summary> /// </summary>
/// <param name="cancellationToken">A token used to cancel shutdown.</param> /// <param name="cancellationToken">A token used to cancel shutdown.</param>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// <returns>A task that represents the asynchronous stop operation.</returns>
@@ -82,4 +79,4 @@ public class CBDDCNodeService : IHostedService
// Don't rethrow during shutdown to avoid breaking the shutdown process // Don't rethrow during shutdown to avoid breaking the shutdown process
} }
} }
} }

View File

@@ -8,15 +8,15 @@ using ZB.MOM.WW.CBDDC.Core.Sync;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Provides extension methods for registering core CBDDC services. /// Provides extension methods for registering core CBDDC services.
/// </summary> /// </summary>
public static class CBDDCServiceCollectionExtensions public static class CBDDCServiceCollectionExtensions
{ {
/// <summary> /// <summary>
/// Registers core CBDDC service dependencies. /// Registers core CBDDC service dependencies.
/// </summary> /// </summary>
/// <param name="services">The service collection to update.</param> /// <param name="services">The service collection to update.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance for chaining.</returns> /// <returns>The same <see cref="IServiceCollection" /> instance for chaining.</returns>
public static IServiceCollection AddCBDDCCore(this IServiceCollection services) public static IServiceCollection AddCBDDCCore(this IServiceCollection services)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
@@ -29,4 +29,4 @@ public static class CBDDCServiceCollectionExtensions
return services; return services;
} }
} }

View File

@@ -1,38 +1,33 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Composite discovery service that combines UDP LAN discovery with persistent remote peers from the peerConfigurationStore. /// Composite discovery service that combines UDP LAN discovery with persistent remote peers from the
/// Periodically refreshes the remote peer list and merges with actively discovered LAN peers. /// peerConfigurationStore.
/// /// Periodically refreshes the remote peer list and merges with actively discovered LAN peers.
/// Remote peer configurations are stored in a synchronized collection that is automatically /// Remote peer configurations are stored in a synchronized collection that is automatically
/// replicated across all nodes in the cluster. Any node that adds a remote peer will have /// replicated across all nodes in the cluster. Any node that adds a remote peer will have
/// it synchronized to all other nodes automatically. /// it synchronized to all other nodes automatically.
/// </summary> /// </summary>
public class CompositeDiscoveryService : IDiscoveryService public class CompositeDiscoveryService : IDiscoveryService
{ {
private readonly IDiscoveryService _udpDiscovery;
private readonly IPeerConfigurationStore _peerConfigurationStore;
private readonly ILogger<CompositeDiscoveryService> _logger;
private readonly TimeSpan _refreshInterval;
private const string RemotePeersCollectionName = "_system_remote_peers"; private const string RemotePeersCollectionName = "_system_remote_peers";
private readonly ILogger<CompositeDiscoveryService> _logger;
private readonly IPeerConfigurationStore _peerConfigurationStore;
private readonly TimeSpan _refreshInterval;
private readonly ConcurrentDictionary<string, PeerNode> _remotePeers = new();
private readonly object _startStopLock = new();
private readonly IDiscoveryService _udpDiscovery;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private readonly ConcurrentDictionary<string, PeerNode> _remotePeers = new();
private readonly object _startStopLock = new object();
/// <summary> /// <summary>
/// Initializes a new instance of the CompositeDiscoveryService class. /// Initializes a new instance of the CompositeDiscoveryService class.
/// </summary> /// </summary>
/// <param name="udpDiscovery">UDP-based LAN discovery service.</param> /// <param name="udpDiscovery">UDP-based LAN discovery service.</param>
/// <param name="peerConfigurationStore">Database instance for accessing the synchronized remote peers collection.</param> /// <param name="peerConfigurationStore">Database instance for accessing the synchronized remote peers collection.</param>
@@ -45,13 +40,14 @@ public class CompositeDiscoveryService : IDiscoveryService
TimeSpan? refreshInterval = null) TimeSpan? refreshInterval = null)
{ {
_udpDiscovery = udpDiscovery ?? throw new ArgumentNullException(nameof(udpDiscovery)); _udpDiscovery = udpDiscovery ?? throw new ArgumentNullException(nameof(udpDiscovery));
_peerConfigurationStore = peerConfigurationStore ?? throw new ArgumentNullException(nameof(peerConfigurationStore)); _peerConfigurationStore =
peerConfigurationStore ?? throw new ArgumentNullException(nameof(peerConfigurationStore));
_logger = logger ?? NullLogger<CompositeDiscoveryService>.Instance; _logger = logger ?? NullLogger<CompositeDiscoveryService>.Instance;
_refreshInterval = refreshInterval ?? TimeSpan.FromMinutes(5); _refreshInterval = refreshInterval ?? TimeSpan.FromMinutes(5);
} }
/// <summary> /// <summary>
/// Gets the currently active peers from LAN discovery and configured remote peers. /// Gets the currently active peers from LAN discovery and configured remote peers.
/// </summary> /// </summary>
/// <returns>A sequence of active peer nodes.</returns> /// <returns>A sequence of active peer nodes.</returns>
public IEnumerable<PeerNode> GetActivePeers() public IEnumerable<PeerNode> GetActivePeers()
@@ -64,7 +60,7 @@ public class CompositeDiscoveryService : IDiscoveryService
} }
/// <summary> /// <summary>
/// Starts peer discovery and the remote peer refresh loop. /// Starts peer discovery and the remote peer refresh loop.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous start operation.</returns> /// <returns>A task that represents the asynchronous start operation.</returns>
public async Task Start() public async Task Start()
@@ -76,6 +72,7 @@ public class CompositeDiscoveryService : IDiscoveryService
_logger.LogWarning("Composite discovery service already started"); _logger.LogWarning("Composite discovery service already started");
return; return;
} }
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
} }
@@ -103,7 +100,7 @@ public class CompositeDiscoveryService : IDiscoveryService
} }
/// <summary> /// <summary>
/// Stops peer discovery and the remote peer refresh loop. /// Stops peer discovery and the remote peer refresh loop.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// <returns>A task that represents the asynchronous stop operation.</returns>
public async Task Stop() public async Task Stop()
@@ -143,7 +140,6 @@ public class CompositeDiscoveryService : IDiscoveryService
private async Task RefreshLoopAsync(CancellationToken cancellationToken) private async Task RefreshLoopAsync(CancellationToken cancellationToken)
{ {
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{
try try
{ {
await Task.Delay(_refreshInterval, cancellationToken); await Task.Delay(_refreshInterval, cancellationToken);
@@ -158,7 +154,6 @@ public class CompositeDiscoveryService : IDiscoveryService
{ {
_logger.LogError(ex, "Error during remote peer refresh"); _logger.LogError(ex, "Error during remote peer refresh");
} }
}
} }
private async Task RefreshRemotePeersAsync() private async Task RefreshRemotePeersAsync()
@@ -178,18 +173,18 @@ public class CompositeDiscoveryService : IDiscoveryService
config.NodeId, config.NodeId,
config.Address, config.Address,
now, // LastSeen is now for persistent peers (always considered active) now, // LastSeen is now for persistent peers (always considered active)
config.Type, config.Type // Remote peers are always members, never gateways
NodeRole.Member // Remote peers are always members, never gateways
); );
_remotePeers[config.NodeId] = peerNode; _remotePeers[config.NodeId] = peerNode;
} }
_logger.LogInformation("Refreshed remote peers: {Count} enabled peers loaded from synchronized collection", _remotePeers.Count); _logger.LogInformation("Refreshed remote peers: {Count} enabled peers loaded from synchronized collection",
_remotePeers.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to refresh remote peers from database"); _logger.LogError(ex, "Failed to refresh remote peers from database");
} }
} }
} }

View File

@@ -1,18 +1,16 @@
using System;
using System.IO;
using System.IO.Compression; using System.IO.Compression;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
public static class CompressionHelper public static class CompressionHelper
{ {
public const int THRESHOLD = 1024; // 1KB public const int THRESHOLD = 1024; // 1KB
/// <summary> /// <summary>
/// Gets a value indicating whether Brotli compression is supported on the current target framework. /// Gets a value indicating whether Brotli compression is supported on the current target framework.
/// </summary> /// </summary>
public static bool IsBrotliSupported public static bool IsBrotliSupported
{ {
get get
{ {
#if NET6_0_OR_GREATER #if NET6_0_OR_GREATER
@@ -20,16 +18,16 @@ public static class CompressionHelper
#else #else
return false; return false;
#endif #endif
} }
} }
/// <summary> /// <summary>
/// Compresses the specified data when Brotli is supported and the payload exceeds the threshold. /// Compresses the specified data when Brotli is supported and the payload exceeds the threshold.
/// </summary> /// </summary>
/// <param name="data">The input data to compress.</param> /// <param name="data">The input data to compress.</param>
/// <returns>The compressed payload, or the original payload if compression is skipped.</returns> /// <returns>The compressed payload, or the original payload if compression is skipped.</returns>
public static byte[] Compress(byte[] data) public static byte[] Compress(byte[] data)
{ {
if (data.Length < THRESHOLD || !IsBrotliSupported) return data; if (data.Length < THRESHOLD || !IsBrotliSupported) return data;
#if NET6_0_OR_GREATER #if NET6_0_OR_GREATER
@@ -38,19 +36,20 @@ public static class CompressionHelper
{ {
brotli.Write(data, 0, data.Length); brotli.Write(data, 0, data.Length);
} }
return output.ToArray(); return output.ToArray();
#else #else
return data; return data;
#endif #endif
} }
/// <summary> /// <summary>
/// Decompresses Brotli-compressed data. /// Decompresses Brotli-compressed data.
/// </summary> /// </summary>
/// <param name="compressedData">The compressed payload.</param> /// <param name="compressedData">The compressed payload.</param>
/// <returns>The decompressed payload.</returns> /// <returns>The decompressed payload.</returns>
public static byte[] Decompress(byte[] compressedData) public static byte[] Decompress(byte[] compressedData)
{ {
#if NET6_0_OR_GREATER #if NET6_0_OR_GREATER
using var input = new MemoryStream(compressedData); using var input = new MemoryStream(compressedData);
using var output = new MemoryStream(); using var output = new MemoryStream();
@@ -58,9 +57,10 @@ public static class CompressionHelper
{ {
brotli.CopyTo(output); brotli.CopyTo(output);
} }
return output.ToArray(); return output.ToArray();
#else #else
throw new NotSupportedException("Brotli decompression not supported on this platform."); throw new NotSupportedException("Brotli decompression not supported on this platform.");
#endif #endif
} }
} }

View File

@@ -1,35 +1,36 @@
using System.Threading.Tasks; namespace ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Network
{
public interface ICBDDCNode
{
/// <summary>
/// Gets the node address.
/// </summary>
NodeAddress Address { get; }
/// <summary>
/// Gets the discovery service.
/// </summary>
IDiscoveryService Discovery { get; }
/// <summary>
/// Gets the synchronization orchestrator.
/// </summary>
ISyncOrchestrator Orchestrator { get; }
/// <summary>
/// Gets the synchronization server.
/// </summary>
ISyncServer Server { get; }
/// <summary> public interface ICBDDCNode
/// Starts the node services. {
/// </summary> /// <summary>
/// <returns>A task that represents the asynchronous start operation.</returns> /// Gets the node address.
Task Start(); /// </summary>
/// <summary> NodeAddress Address { get; }
/// Stops the node services.
/// </summary> /// <summary>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// Gets the discovery service.
Task Stop(); /// </summary>
} IDiscoveryService Discovery { get; }
}
/// <summary>
/// Gets the synchronization orchestrator.
/// </summary>
ISyncOrchestrator Orchestrator { get; }
/// <summary>
/// Gets the synchronization server.
/// </summary>
ISyncServer Server { get; }
/// <summary>
/// Starts the node services.
/// </summary>
/// <returns>A task that represents the asynchronous start operation.</returns>
Task Start();
/// <summary>
/// Stops the node services.
/// </summary>
/// <returns>A task that represents the asynchronous stop operation.</returns>
Task Stop();
}

View File

@@ -1,30 +1,27 @@
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using System.Collections.Generic;
using System.Threading.Tasks; namespace ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Network /// <summary>
/// Defines peer discovery operations.
/// </summary>
public interface IDiscoveryService
{ {
/// <summary> /// <summary>
/// Defines peer discovery operations. /// Gets the currently active peers.
/// </summary> /// </summary>
public interface IDiscoveryService /// <returns>The active peer nodes.</returns>
{ IEnumerable<PeerNode> GetActivePeers();
/// <summary>
/// Gets the currently active peers.
/// </summary>
/// <returns>The active peer nodes.</returns>
IEnumerable<PeerNode> GetActivePeers();
/// <summary> /// <summary>
/// Starts the discovery service. /// Starts the discovery service.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Start(); Task Start();
/// <summary> /// <summary>
/// Stops the discovery service. /// Stops the discovery service.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Stop(); Task Stop();
} }
}

View File

@@ -1,16 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Calculates the effective oplog prune cutoff for maintenance. /// Calculates the effective oplog prune cutoff for maintenance.
/// </summary> /// </summary>
public interface IOplogPruneCutoffCalculator public interface IOplogPruneCutoffCalculator
{ {
/// <summary> /// <summary>
/// Calculates the effective prune cutoff for the provided node configuration. /// Calculates the effective prune cutoff for the provided node configuration.
/// </summary> /// </summary>
/// <param name="configuration">The local node configuration.</param> /// <param name="configuration">The local node configuration.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
@@ -18,4 +16,4 @@ public interface IOplogPruneCutoffCalculator
Task<OplogPruneCutoffDecision> CalculateEffectiveCutoffAsync( Task<OplogPruneCutoffDecision> CalculateEffectiveCutoffAsync(
PeerNodeConfiguration configuration, PeerNodeConfiguration configuration,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }

View File

@@ -1,22 +1,19 @@
using System.Threading.Tasks; namespace ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Network /// <summary>
/// Defines lifecycle operations for synchronization orchestration.
/// </summary>
public interface ISyncOrchestrator
{ {
/// <summary> /// <summary>
/// Defines lifecycle operations for synchronization orchestration. /// Starts synchronization orchestration.
/// </summary> /// </summary>
public interface ISyncOrchestrator /// <returns>A task that represents the asynchronous start operation.</returns>
{ Task Start();
/// <summary>
/// Starts synchronization orchestration.
/// </summary>
/// <returns>A task that represents the asynchronous start operation.</returns>
Task Start();
/// <summary> /// <summary>
/// Stops synchronization orchestration. /// Stops synchronization orchestration.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// <returns>A task that represents the asynchronous stop operation.</returns>
Task Stop(); Task Stop();
} }
}

View File

@@ -1,31 +1,33 @@
using System.Net; using System.Net;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Network;
namespace ZB.MOM.WW.CBDDC.Network;
/// <summary>
/// <summary> /// Defines the contract for a server that supports starting, stopping, and reporting its listening network endpoint
/// Defines the contract for a server that supports starting, stopping, and reporting its listening network endpoint for /// for
/// synchronization operations. /// synchronization operations.
/// </summary> /// </summary>
/// <remarks>Implementations of this interface are expected to provide asynchronous methods for starting and /// <remarks>
/// stopping the server. The listening endpoint may be null if the server is not currently active or has not been /// Implementations of this interface are expected to provide asynchronous methods for starting and
/// started.</remarks> /// stopping the server. The listening endpoint may be null if the server is not currently active or has not been
/// started.
/// </remarks>
public interface ISyncServer public interface ISyncServer
{ {
/// <summary> /// <summary>
/// Starts the synchronization server. /// Gets the network endpoint currently used by the server for listening.
/// </summary>
IPEndPoint? ListeningEndpoint { get; }
/// <summary>
/// Starts the synchronization server.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Start(); Task Start();
/// <summary> /// <summary>
/// Stops the synchronization server. /// Stops the synchronization server.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
Task Stop(); Task Stop();
}
/// <summary>
/// Gets the network endpoint currently used by the server for listening.
/// </summary>
IPEndPoint? ListeningEndpoint { get; }
}

View File

@@ -1,48 +1,26 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Network;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Network.Leadership; namespace ZB.MOM.WW.CBDDC.Network.Leadership;
/// <summary> /// <summary>
/// Implements the Bully algorithm for leader election. /// Implements the Bully algorithm for leader election.
/// The node with the lexicographically smallest NodeId becomes the cloud gateway (leader). /// The node with the lexicographically smallest NodeId becomes the cloud gateway (leader).
/// Elections run periodically (every 5 seconds) to adapt to cluster changes. /// Elections run periodically (every 5 seconds) to adapt to cluster changes.
/// </summary> /// </summary>
public class BullyLeaderElectionService : ILeaderElectionService public class BullyLeaderElectionService : ILeaderElectionService
{ {
private readonly IDiscoveryService _discoveryService;
private readonly IPeerNodeConfigurationProvider _configProvider; private readonly IPeerNodeConfigurationProvider _configProvider;
private readonly ILogger<BullyLeaderElectionService> _logger; private readonly IDiscoveryService _discoveryService;
private readonly TimeSpan _electionInterval; private readonly TimeSpan _electionInterval;
private readonly ILogger<BullyLeaderElectionService> _logger;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private string? _localNodeId; private string? _localNodeId;
private string? _currentGatewayNodeId;
private bool _isCloudGateway;
/// <summary> /// <summary>
/// Gets a value indicating whether this node is currently the cloud gateway leader. /// Initializes a new instance of the BullyLeaderElectionService class.
/// </summary>
public bool IsCloudGateway => _isCloudGateway;
/// <summary>
/// Gets the current gateway node identifier.
/// </summary>
public string? CurrentGatewayNodeId => _currentGatewayNodeId;
/// <summary>
/// Occurs when leadership changes.
/// </summary>
public event EventHandler<LeadershipChangedEventArgs>? LeadershipChanged;
/// <summary>
/// Initializes a new instance of the BullyLeaderElectionService class.
/// </summary> /// </summary>
/// <param name="discoveryService">Service providing active peer information.</param> /// <param name="discoveryService">Service providing active peer information.</param>
/// <param name="configProvider">Provider for local node configuration.</param> /// <param name="configProvider">Provider for local node configuration.</param>
@@ -61,7 +39,22 @@ public class BullyLeaderElectionService : ILeaderElectionService
} }
/// <summary> /// <summary>
/// Starts the leader election loop. /// Gets a value indicating whether this node is currently the cloud gateway leader.
/// </summary>
public bool IsCloudGateway { get; private set; }
/// <summary>
/// Gets the current gateway node identifier.
/// </summary>
public string? CurrentGatewayNodeId { get; private set; }
/// <summary>
/// Occurs when leadership changes.
/// </summary>
public event EventHandler<LeadershipChangedEventArgs>? LeadershipChanged;
/// <summary>
/// Starts the leader election loop.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous start operation.</returns> /// <returns>A task that represents the asynchronous start operation.</returns>
public async Task Start() public async Task Start()
@@ -82,7 +75,7 @@ public class BullyLeaderElectionService : ILeaderElectionService
} }
/// <summary> /// <summary>
/// Stops the leader election loop. /// Stops the leader election loop.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// <returns>A task that represents the asynchronous stop operation.</returns>
public Task Stop() public Task Stop()
@@ -100,7 +93,6 @@ public class BullyLeaderElectionService : ILeaderElectionService
private async Task ElectionLoopAsync(CancellationToken cancellationToken) private async Task ElectionLoopAsync(CancellationToken cancellationToken)
{ {
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{
try try
{ {
await Task.Delay(_electionInterval, cancellationToken); await Task.Delay(_electionInterval, cancellationToken);
@@ -115,7 +107,6 @@ public class BullyLeaderElectionService : ILeaderElectionService
{ {
_logger.LogError(ex, "Error during leader election"); _logger.LogError(ex, "Error during leader election");
} }
}
} }
private void RunElection() private void RunElection()
@@ -132,35 +123,31 @@ public class BullyLeaderElectionService : ILeaderElectionService
lanPeers.Add(_localNodeId); lanPeers.Add(_localNodeId);
// Bully algorithm: smallest NodeId wins (lexicographic comparison) // Bully algorithm: smallest NodeId wins (lexicographic comparison)
var newLeader = lanPeers.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault(); string? newLeader = lanPeers.OrderBy(id => id, StringComparer.Ordinal).FirstOrDefault();
if (newLeader == null) if (newLeader == null)
{
// No peers available, local node is leader by default // No peers available, local node is leader by default
newLeader = _localNodeId; newLeader = _localNodeId;
}
// Check if leadership changed // Check if leadership changed
if (newLeader != _currentGatewayNodeId) if (newLeader != CurrentGatewayNodeId)
{ {
var wasLeader = _isCloudGateway; bool wasLeader = IsCloudGateway;
_currentGatewayNodeId = newLeader; CurrentGatewayNodeId = newLeader;
_isCloudGateway = newLeader == _localNodeId; IsCloudGateway = newLeader == _localNodeId;
if (wasLeader != _isCloudGateway) if (wasLeader != IsCloudGateway)
{ {
if (_isCloudGateway) if (IsCloudGateway)
{ _logger.LogInformation(
_logger.LogInformation("🔐 This node is now the CLOUD GATEWAY (Leader) - Will sync with remote cloud nodes"); "🔐 This node is now the CLOUD GATEWAY (Leader) - Will sync with remote cloud nodes");
}
else else
{ _logger.LogInformation("👤 This node is now a MEMBER - Cloud sync handled by gateway: {Gateway}",
_logger.LogInformation("👤 This node is now a MEMBER - Cloud sync handled by gateway: {Gateway}", _currentGatewayNodeId); CurrentGatewayNodeId);
}
// Raise event // Raise event
LeadershipChanged?.Invoke(this, new LeadershipChangedEventArgs(_currentGatewayNodeId, _isCloudGateway)); LeadershipChanged?.Invoke(this, new LeadershipChangedEventArgs(CurrentGatewayNodeId, IsCloudGateway));
} }
} }
} }
} }

View File

@@ -1,65 +1,65 @@
using System;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Network.Leadership; namespace ZB.MOM.WW.CBDDC.Network.Leadership;
/// <summary> /// <summary>
/// Event arguments for leadership change events. /// Event arguments for leadership change events.
/// </summary> /// </summary>
public class LeadershipChangedEventArgs : EventArgs public class LeadershipChangedEventArgs : EventArgs
{ {
/// <summary> /// <summary>
/// Gets the NodeId of the current cloud gateway (leader). /// Initializes a new instance of the LeadershipChangedEventArgs class.
/// Null if no leader is elected.
/// </summary> /// </summary>
public string? CurrentGatewayNodeId { get; } /// <param name="currentGatewayNodeId">
/// The NodeId of the current gateway node, or <see langword="null" /> when none is
/// <summary> /// elected.
/// Gets whether the local node is now the cloud gateway. /// </param>
/// </summary>
public bool IsLocalNodeGateway { get; }
/// <summary>
/// Initializes a new instance of the LeadershipChangedEventArgs class.
/// </summary>
/// <param name="currentGatewayNodeId">The NodeId of the current gateway node, or <see langword="null"/> when none is elected.</param>
/// <param name="isLocalNodeGateway">A value indicating whether the local node is the gateway.</param> /// <param name="isLocalNodeGateway">A value indicating whether the local node is the gateway.</param>
public LeadershipChangedEventArgs(string? currentGatewayNodeId, bool isLocalNodeGateway) public LeadershipChangedEventArgs(string? currentGatewayNodeId, bool isLocalNodeGateway)
{ {
CurrentGatewayNodeId = currentGatewayNodeId; CurrentGatewayNodeId = currentGatewayNodeId;
IsLocalNodeGateway = isLocalNodeGateway; IsLocalNodeGateway = isLocalNodeGateway;
} }
/// <summary>
/// Gets the NodeId of the current cloud gateway (leader).
/// Null if no leader is elected.
/// </summary>
public string? CurrentGatewayNodeId { get; }
/// <summary>
/// Gets whether the local node is now the cloud gateway.
/// </summary>
public bool IsLocalNodeGateway { get; }
} }
/// <summary> /// <summary>
/// Service for managing leader election in a distributed cluster. /// Service for managing leader election in a distributed cluster.
/// Uses the Bully algorithm where the node with the lexicographically smallest NodeId becomes the leader. /// Uses the Bully algorithm where the node with the lexicographically smallest NodeId becomes the leader.
/// Only the leader (Cloud Gateway) synchronizes with remote cloud nodes. /// Only the leader (Cloud Gateway) synchronizes with remote cloud nodes.
/// </summary> /// </summary>
public interface ILeaderElectionService public interface ILeaderElectionService
{ {
/// <summary> /// <summary>
/// Gets whether the local node is currently the cloud gateway (leader). /// Gets whether the local node is currently the cloud gateway (leader).
/// </summary> /// </summary>
bool IsCloudGateway { get; } bool IsCloudGateway { get; }
/// <summary> /// <summary>
/// Gets the NodeId of the current cloud gateway, or null if no gateway is elected. /// Gets the NodeId of the current cloud gateway, or null if no gateway is elected.
/// </summary> /// </summary>
string? CurrentGatewayNodeId { get; } string? CurrentGatewayNodeId { get; }
/// <summary> /// <summary>
/// Event raised when leadership changes. /// Event raised when leadership changes.
/// </summary> /// </summary>
event EventHandler<LeadershipChangedEventArgs>? LeadershipChanged; event EventHandler<LeadershipChangedEventArgs>? LeadershipChanged;
/// <summary> /// <summary>
/// Starts the leader election service. /// Starts the leader election service.
/// </summary> /// </summary>
Task Start(); Task Start();
/// <summary> /// <summary>
/// Stops the leader election service. /// Stops the leader election service.
/// </summary> /// </summary>
Task Stop(); Task Stop();
} }

View File

@@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Storage;
@@ -10,7 +5,7 @@ using ZB.MOM.WW.CBDDC.Core.Storage;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Default implementation for effective oplog prune cutoff calculation. /// Default implementation for effective oplog prune cutoff calculation.
/// </summary> /// </summary>
public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
{ {
@@ -18,7 +13,7 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
private readonly IPeerOplogConfirmationStore? _peerOplogConfirmationStore; private readonly IPeerOplogConfirmationStore? _peerOplogConfirmationStore;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="OplogPruneCutoffCalculator"/> class. /// Initializes a new instance of the <see cref="OplogPruneCutoffCalculator" /> class.
/// </summary> /// </summary>
/// <param name="oplogStore">The oplog store.</param> /// <param name="oplogStore">The oplog store.</param>
/// <param name="peerOplogConfirmationStore">The optional peer confirmation store.</param> /// <param name="peerOplogConfirmationStore">The optional peer confirmation store.</param>
@@ -39,23 +34,19 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
var retentionCutoff = BuildRetentionCutoff(configuration); var retentionCutoff = BuildRetentionCutoff(configuration);
if (_peerOplogConfirmationStore == null) if (_peerOplogConfirmationStore == null)
{
return OplogPruneCutoffDecision.WithCutoff( return OplogPruneCutoffDecision.WithCutoff(
retentionCutoff, retentionCutoff,
confirmationCutoff: null, null,
effectiveCutoff: retentionCutoff, retentionCutoff,
reason: "Confirmation tracking is not configured."); "Confirmation tracking is not configured.");
}
var relevantSources = await GetRelevantSourceNodesAsync(cancellationToken); var relevantSources = await GetRelevantSourceNodesAsync(cancellationToken);
if (relevantSources.Count == 0) if (relevantSources.Count == 0)
{
return OplogPruneCutoffDecision.WithCutoff( return OplogPruneCutoffDecision.WithCutoff(
retentionCutoff, retentionCutoff,
confirmationCutoff: null, null,
effectiveCutoff: retentionCutoff, retentionCutoff,
reason: "No local non-default oplog/vector-clock sources were found."); "No local non-default oplog/vector-clock sources were found.");
}
var activeTrackedPeers = (await _peerOplogConfirmationStore.GetActiveTrackedPeersAsync(cancellationToken)) var activeTrackedPeers = (await _peerOplogConfirmationStore.GetActiveTrackedPeersAsync(cancellationToken))
.Where(peerNodeId => !string.IsNullOrWhiteSpace(peerNodeId)) .Where(peerNodeId => !string.IsNullOrWhiteSpace(peerNodeId))
@@ -63,19 +54,18 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
.ToList(); .ToList();
if (activeTrackedPeers.Count == 0) if (activeTrackedPeers.Count == 0)
{
return OplogPruneCutoffDecision.WithCutoff( return OplogPruneCutoffDecision.WithCutoff(
retentionCutoff, retentionCutoff,
confirmationCutoff: null, null,
effectiveCutoff: retentionCutoff, retentionCutoff,
reason: "No active tracked peers found for confirmation gating."); "No active tracked peers found for confirmation gating.");
}
HlcTimestamp? confirmationCutoff = null; HlcTimestamp? confirmationCutoff = null;
foreach (var peerNodeId in activeTrackedPeers) foreach (string peerNodeId in activeTrackedPeers)
{ {
var confirmationsForPeer = (await _peerOplogConfirmationStore.GetConfirmationsForPeerAsync(peerNodeId, cancellationToken)) var confirmationsForPeer =
(await _peerOplogConfirmationStore.GetConfirmationsForPeerAsync(peerNodeId, cancellationToken))
.Where(confirmation => confirmation.IsActive) .Where(confirmation => confirmation.IsActive)
.Where(confirmation => !string.IsNullOrWhiteSpace(confirmation.SourceNodeId)) .Where(confirmation => !string.IsNullOrWhiteSpace(confirmation.SourceNodeId))
.GroupBy(confirmation => confirmation.SourceNodeId, StringComparer.Ordinal) .GroupBy(confirmation => confirmation.SourceNodeId, StringComparer.Ordinal)
@@ -87,30 +77,25 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
.Last(), .Last(),
StringComparer.Ordinal); StringComparer.Ordinal);
foreach (var sourceNodeId in relevantSources) foreach (string sourceNodeId in relevantSources)
{ {
if (!confirmationsForPeer.TryGetValue(sourceNodeId, out var confirmedTimestamp) || confirmedTimestamp == default) if (!confirmationsForPeer.TryGetValue(sourceNodeId, out var confirmedTimestamp) ||
{ confirmedTimestamp == default)
return OplogPruneCutoffDecision.NoCutoff( return OplogPruneCutoffDecision.NoCutoff(
retentionCutoff, retentionCutoff,
$"Active tracked peer '{peerNodeId}' is missing confirmation for source '{sourceNodeId}'."); $"Active tracked peer '{peerNodeId}' is missing confirmation for source '{sourceNodeId}'.");
}
if (!confirmationCutoff.HasValue || confirmedTimestamp < confirmationCutoff.Value) if (!confirmationCutoff.HasValue || confirmedTimestamp < confirmationCutoff.Value)
{
confirmationCutoff = confirmedTimestamp; confirmationCutoff = confirmedTimestamp;
}
} }
} }
if (!confirmationCutoff.HasValue) if (!confirmationCutoff.HasValue)
{
return OplogPruneCutoffDecision.WithCutoff( return OplogPruneCutoffDecision.WithCutoff(
retentionCutoff, retentionCutoff,
confirmationCutoff: null, null,
effectiveCutoff: retentionCutoff, retentionCutoff,
reason: "No confirmation cutoff could be determined."); "No confirmation cutoff could be determined.");
}
var effectiveCutoff = retentionCutoff <= confirmationCutoff.Value var effectiveCutoff = retentionCutoff <= confirmationCutoff.Value
? retentionCutoff ? retentionCutoff
@@ -124,7 +109,7 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
private static HlcTimestamp BuildRetentionCutoff(PeerNodeConfiguration configuration) private static HlcTimestamp BuildRetentionCutoff(PeerNodeConfiguration configuration)
{ {
var retentionTimestamp = DateTimeOffset.UtcNow long retentionTimestamp = DateTimeOffset.UtcNow
.AddHours(-configuration.OplogRetentionHours) .AddHours(-configuration.OplogRetentionHours)
.ToUnixTimeMilliseconds(); .ToUnixTimeMilliseconds();
@@ -135,18 +120,12 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
{ {
var localVectorClock = await _oplogStore.GetVectorClockAsync(cancellationToken); var localVectorClock = await _oplogStore.GetVectorClockAsync(cancellationToken);
var sourceNodes = new HashSet<string>(StringComparer.Ordinal); var sourceNodes = new HashSet<string>(StringComparer.Ordinal);
foreach (var sourceNodeId in localVectorClock.NodeIds) foreach (string sourceNodeId in localVectorClock.NodeIds)
{ {
if (string.IsNullOrWhiteSpace(sourceNodeId)) if (string.IsNullOrWhiteSpace(sourceNodeId)) continue;
{
continue;
}
var timestamp = localVectorClock.GetTimestamp(sourceNodeId); var timestamp = localVectorClock.GetTimestamp(sourceNodeId);
if (timestamp == default) if (timestamp == default) continue;
{
continue;
}
sourceNodes.Add(sourceNodeId); sourceNodes.Add(sourceNodeId);
} }
@@ -161,4 +140,4 @@ public class OplogPruneCutoffCalculator : IOplogPruneCutoffCalculator
confirmation.ConfirmedLogic, confirmation.ConfirmedLogic,
confirmation.SourceNodeId ?? string.Empty); confirmation.SourceNodeId ?? string.Empty);
} }
} }

View File

@@ -3,7 +3,7 @@ using ZB.MOM.WW.CBDDC.Core;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Represents the prune cutoff decision for an oplog maintenance cycle. /// Represents the prune cutoff decision for an oplog maintenance cycle.
/// </summary> /// </summary>
public sealed class OplogPruneCutoffDecision public sealed class OplogPruneCutoffDecision
{ {
@@ -22,32 +22,32 @@ public sealed class OplogPruneCutoffDecision
} }
/// <summary> /// <summary>
/// Gets a value indicating whether pruning is allowed for this decision. /// Gets a value indicating whether pruning is allowed for this decision.
/// </summary> /// </summary>
public bool HasCutoff { get; } public bool HasCutoff { get; }
/// <summary> /// <summary>
/// Gets the retention-based cutoff. /// Gets the retention-based cutoff.
/// </summary> /// </summary>
public HlcTimestamp RetentionCutoff { get; } public HlcTimestamp RetentionCutoff { get; }
/// <summary> /// <summary>
/// Gets the confirmation-based cutoff, when available. /// Gets the confirmation-based cutoff, when available.
/// </summary> /// </summary>
public HlcTimestamp? ConfirmationCutoff { get; } public HlcTimestamp? ConfirmationCutoff { get; }
/// <summary> /// <summary>
/// Gets the effective cutoff to use for pruning when <see cref="HasCutoff"/> is true. /// Gets the effective cutoff to use for pruning when <see cref="HasCutoff" /> is true.
/// </summary> /// </summary>
public HlcTimestamp? EffectiveCutoff { get; } public HlcTimestamp? EffectiveCutoff { get; }
/// <summary> /// <summary>
/// Gets the explanatory reason for skip/special handling decisions. /// Gets the explanatory reason for skip/special handling decisions.
/// </summary> /// </summary>
public string Reason { get; } public string Reason { get; }
/// <summary> /// <summary>
/// Creates a prune-allowed decision with the provided cutoffs. /// Creates a prune-allowed decision with the provided cutoffs.
/// </summary> /// </summary>
/// <param name="retentionCutoff">The cutoff derived from retention policy.</param> /// <param name="retentionCutoff">The cutoff derived from retention policy.</param>
/// <param name="confirmationCutoff">The cutoff derived from peer confirmations, if available.</param> /// <param name="confirmationCutoff">The cutoff derived from peer confirmations, if available.</param>
@@ -60,25 +60,25 @@ public sealed class OplogPruneCutoffDecision
string reason = "") string reason = "")
{ {
return new OplogPruneCutoffDecision( return new OplogPruneCutoffDecision(
hasCutoff: true, true,
retentionCutoff: retentionCutoff, retentionCutoff,
confirmationCutoff: confirmationCutoff, confirmationCutoff,
effectiveCutoff: effectiveCutoff, effectiveCutoff,
reason: reason); reason);
} }
/// <summary> /// <summary>
/// Creates a prune-blocked decision. /// Creates a prune-blocked decision.
/// </summary> /// </summary>
/// <param name="retentionCutoff">The cutoff derived from retention policy.</param> /// <param name="retentionCutoff">The cutoff derived from retention policy.</param>
/// <param name="reason">The explanatory reason associated with the decision.</param> /// <param name="reason">The explanatory reason associated with the decision.</param>
public static OplogPruneCutoffDecision NoCutoff(HlcTimestamp retentionCutoff, string reason) public static OplogPruneCutoffDecision NoCutoff(HlcTimestamp retentionCutoff, string reason)
{ {
return new OplogPruneCutoffDecision( return new OplogPruneCutoffDecision(
hasCutoff: false, false,
retentionCutoff: retentionCutoff, retentionCutoff,
confirmationCutoff: null, null,
effectiveCutoff: null, null,
reason: reason); reason);
} }
} }

View File

@@ -1,57 +1,55 @@
using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Network; // For IMeshNetwork if we implement it
using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Network.Security;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting; using ZB.MOM.WW.CBDDC.Core.Network;
using System; using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Network.Telemetry;
// For IMeshNetwork if we implement it
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
public static class CBDDCNetworkExtensions public static class CBDDCNetworkExtensions
{ {
/// <summary> /// <summary>
/// Adds CBDDC network services to the service collection. /// Adds CBDDC network services to the service collection.
/// </summary> /// </summary>
/// <typeparam name="TPeerNodeConfigurationProvider">The peer node configuration provider implementation type.</typeparam> /// <typeparam name="TPeerNodeConfigurationProvider">The peer node configuration provider implementation type.</typeparam>
/// <param name="services">The service collection to register services into.</param> /// <param name="services">The service collection to register services into.</param>
/// <param name="useHostedService">If true, registers CBDDCNodeService as IHostedService to automatically start/stop the node.</param> /// <param name="useHostedService">
public static IServiceCollection AddCBDDCNetwork<TPeerNodeConfigurationProvider>( /// If true, registers CBDDCNodeService as IHostedService to automatically start/stop the
this IServiceCollection services, /// node.
bool useHostedService = true) /// </param>
public static IServiceCollection AddCBDDCNetwork<TPeerNodeConfigurationProvider>(
this IServiceCollection services,
bool useHostedService = true)
where TPeerNodeConfigurationProvider : class, IPeerNodeConfigurationProvider where TPeerNodeConfigurationProvider : class, IPeerNodeConfigurationProvider
{ {
services.TryAddSingleton<IPeerNodeConfigurationProvider, TPeerNodeConfigurationProvider>(); services.TryAddSingleton<IPeerNodeConfigurationProvider, TPeerNodeConfigurationProvider>();
services.TryAddSingleton<IAuthenticator, ClusterKeyAuthenticator>(); services.TryAddSingleton<IAuthenticator, ClusterKeyAuthenticator>();
services.TryAddSingleton<IPeerHandshakeService, SecureHandshakeService>(); services.TryAddSingleton<IPeerHandshakeService, SecureHandshakeService>();
services.TryAddSingleton<IDiscoveryService, UdpDiscoveryService>(); services.TryAddSingleton<IDiscoveryService, UdpDiscoveryService>();
services.TryAddSingleton<ZB.MOM.WW.CBDDC.Network.Telemetry.INetworkTelemetryService>(sp => services.TryAddSingleton<INetworkTelemetryService>(sp =>
{ {
var logger = sp.GetRequiredService<ILogger<ZB.MOM.WW.CBDDC.Network.Telemetry.NetworkTelemetryService>>(); var logger = sp.GetRequiredService<ILogger<NetworkTelemetryService>>();
var path = System.IO.Path.Combine(System.AppContext.BaseDirectory, "cbddc_metrics.bin"); string path = Path.Combine(AppContext.BaseDirectory, "cbddc_metrics.bin");
return new ZB.MOM.WW.CBDDC.Network.Telemetry.NetworkTelemetryService(logger, path); return new NetworkTelemetryService(logger, path);
}); });
services.TryAddSingleton<ISyncServer, TcpSyncServer>(); services.TryAddSingleton<ISyncServer, TcpSyncServer>();
services.TryAddSingleton<IOplogPruneCutoffCalculator, OplogPruneCutoffCalculator>(); services.TryAddSingleton<IOplogPruneCutoffCalculator, OplogPruneCutoffCalculator>();
services.TryAddSingleton<ISyncOrchestrator, SyncOrchestrator>(); services.TryAddSingleton<ISyncOrchestrator, SyncOrchestrator>();
services.TryAddSingleton<ICBDDCNode, CBDDCNode>(); services.TryAddSingleton<ICBDDCNode, CBDDCNode>();
// Optionally register hosted service for automatic node lifecycle management // Optionally register hosted service for automatic node lifecycle management
if (useHostedService) if (useHostedService) services.AddHostedService<CBDDCNodeService>();
{
services.AddHostedService<CBDDCNodeService>();
}
return services; return services;
} }
} }

View File

@@ -1,259 +1,252 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf; using Google.Protobuf;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Network.Proto; using ZB.MOM.WW.CBDDC.Network.Proto;
using ZB.MOM.WW.CBDDC.Network.Security; using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Network.Telemetry; using ZB.MOM.WW.CBDDC.Network.Telemetry;
namespace ZB.MOM.WW.CBDDC.Network.Protocol namespace ZB.MOM.WW.CBDDC.Network.Protocol;
/// <summary>
/// Handles the low-level framing, compression, encryption, and serialization of CBDDC messages.
/// Encapsulates the wire format: [Length (4)] [Type (1)] [Compression (1)] [Payload (N)]
/// </summary>
internal class ProtocolHandler
{ {
private readonly ILogger<ProtocolHandler> _logger;
private readonly SemaphoreSlim _readLock = new(1, 1);
private readonly INetworkTelemetryService? _telemetry;
private readonly SemaphoreSlim _writeLock = new(1, 1);
/// <summary> /// <summary>
/// Handles the low-level framing, compression, encryption, and serialization of CBDDC messages. /// Initializes a new instance of the <see cref="ProtocolHandler" /> class.
/// Encapsulates the wire format: [Length (4)] [Type (1)] [Compression (1)] [Payload (N)]
/// </summary> /// </summary>
internal class ProtocolHandler /// <param name="logger">The logger used for protocol diagnostics.</param>
{ /// <param name="telemetry">An optional telemetry service used to record network metrics.</param>
private readonly ILogger<ProtocolHandler> _logger; public ProtocolHandler(ILogger<ProtocolHandler> logger, INetworkTelemetryService? telemetry = null)
private readonly INetworkTelemetryService? _telemetry; {
private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1); _logger = logger;
private readonly SemaphoreSlim _readLock = new SemaphoreSlim(1, 1); _telemetry = telemetry;
}
/// <summary>
/// Initializes a new instance of the <see cref="ProtocolHandler"/> class.
/// </summary>
/// <param name="logger">The logger used for protocol diagnostics.</param>
/// <param name="telemetry">An optional telemetry service used to record network metrics.</param>
public ProtocolHandler(ILogger<ProtocolHandler> logger, INetworkTelemetryService? telemetry = null)
{
_logger = logger;
_telemetry = telemetry;
}
/// <summary>
/// Initializes a new instance of the <see cref="ProtocolHandler"/> class using a non-generic logger.
/// </summary>
/// <param name="logger">The logger used for protocol diagnostics.</param>
/// <param name="telemetry">An optional telemetry service used to record network metrics.</param>
internal ProtocolHandler(ILogger logger, INetworkTelemetryService? telemetry = null)
: this(new ForwardingLogger(logger), telemetry)
{
}
/// <summary>
/// Serializes and sends a protocol message to the provided stream.
/// </summary>
/// <param name="stream">The destination stream.</param>
/// <param name="type">The protocol message type.</param>
/// <param name="message">The message payload to serialize.</param>
/// <param name="useCompression">Whether payload compression should be attempted.</param>
/// <param name="cipherState">Optional cipher state used to encrypt outgoing payloads.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>A task that represents the asynchronous send operation.</returns>
public async Task SendMessageAsync(Stream stream, MessageType type, IMessage message, bool useCompression, CipherState? cipherState, CancellationToken token = default)
{
if (stream == null) throw new ArgumentNullException(nameof(stream));
// 1. Serialize /// <summary>
byte[] payloadBytes = message.ToByteArray(); /// Initializes a new instance of the <see cref="ProtocolHandler" /> class using a non-generic logger.
int originalSize = payloadBytes.Length; /// </summary>
byte compressionFlag = 0x00; /// <param name="logger">The logger used for protocol diagnostics.</param>
/// <param name="telemetry">An optional telemetry service used to record network metrics.</param>
internal ProtocolHandler(ILogger logger, INetworkTelemetryService? telemetry = null)
: this(new ForwardingLogger(logger), telemetry)
{
}
// 2. Compress (inner payload) /// <summary>
if (useCompression && payloadBytes.Length > CompressionHelper.THRESHOLD && type != MessageType.SecureEnv) /// Serializes and sends a protocol message to the provided stream.
{ /// </summary>
// Measure Compression Time /// <param name="stream">The destination stream.</param>
// using var _ = _telemetry?.StartMetric(MetricType.CompressionTime); // Oops, MetricType.CompressionTime not defined? Wait, user asked for "Compression Ratio". /// <param name="type">The protocol message type.</param>
// User asked for "performance della compressione brotli (% media di compressione)". /// <param name="message">The message payload to serialize.</param>
// That usually means ratio. But time is also good? /// <param name="useCompression">Whether payload compression should be attempted.</param>
// Plan said: "MetricType: CompressionRatio, EncryptionTime..." /// <param name="cipherState">Optional cipher state used to encrypt outgoing payloads.</param>
/// <param name="token">Cancellation token.</param>
// byte[] compressed; // Removed unused variable /// <returns>A task that represents the asynchronous send operation.</returns>
// using (_telemetry?.StartMetric(MetricType.CompressionTime)) // Let's stick to Time if relevant? NO, MetricType only has Ratio. public async Task SendMessageAsync(Stream stream, MessageType type, IMessage message, bool useCompression,
// Ah I see MetricType enum: CompressionRatio, EncryptionTime, DecryptionTime, RoundTripTime. CipherState? cipherState, CancellationToken token = default)
// So for compression we only record Ratio. {
if (stream == null) throw new ArgumentNullException(nameof(stream));
payloadBytes = CompressionHelper.Compress(payloadBytes);
compressionFlag = 0x01; // Brotli
if (_telemetry != null && originalSize > 0)
{
double ratio = (double)payloadBytes.Length / originalSize;
_telemetry.RecordValue(MetricType.CompressionRatio, ratio);
}
}
// 3. Encrypt // 1. Serialize
if (cipherState != null) byte[] payloadBytes = message.ToByteArray();
int originalSize = payloadBytes.Length;
byte compressionFlag = 0x00;
// 2. Compress (inner payload)
if (useCompression && payloadBytes.Length > CompressionHelper.THRESHOLD && type != MessageType.SecureEnv)
{
// Measure Compression Time
// using var _ = _telemetry?.StartMetric(MetricType.CompressionTime); // Oops, MetricType.CompressionTime not defined? Wait, user asked for "Compression Ratio".
// User asked for "performance della compressione brotli (% media di compressione)".
// That usually means ratio. But time is also good?
// Plan said: "MetricType: CompressionRatio, EncryptionTime..."
// byte[] compressed; // Removed unused variable
// using (_telemetry?.StartMetric(MetricType.CompressionTime)) // Let's stick to Time if relevant? NO, MetricType only has Ratio.
// Ah I see MetricType enum: CompressionRatio, EncryptionTime, DecryptionTime, RoundTripTime.
// So for compression we only record Ratio.
payloadBytes = CompressionHelper.Compress(payloadBytes);
compressionFlag = 0x01; // Brotli
if (_telemetry != null && originalSize > 0)
{ {
using (_telemetry?.StartMetric(MetricType.EncryptionTime)) double ratio = (double)payloadBytes.Length / originalSize;
{ _telemetry.RecordValue(MetricType.CompressionRatio, ratio);
// Inner data: [Type (1)] [Compression (1)] [Payload (N)]
var dataToEncrypt = new byte[2 + payloadBytes.Length];
dataToEncrypt[0] = (byte)type;
dataToEncrypt[1] = compressionFlag;
Buffer.BlockCopy(payloadBytes, 0, dataToEncrypt, 2, payloadBytes.Length);
var (ciphertext, iv, tag) = CryptoHelper.Encrypt(dataToEncrypt, cipherState.EncryptKey);
var env = new SecureEnvelope
{
Ciphertext = ByteString.CopyFrom(ciphertext),
Nonce = ByteString.CopyFrom(iv),
AuthTag = ByteString.CopyFrom(tag)
};
payloadBytes = env.ToByteArray();
type = MessageType.SecureEnv;
compressionFlag = 0x00; // Outer envelope is not compressed
}
}
// 4. Thread-Safe Write
await _writeLock.WaitAsync(token);
try
{
_logger.LogDebug("Sending Message {Type}, OrgSize: {Org}, WireSize: {Wire}", type, originalSize, payloadBytes.Length);
// Framing: [Length (4)] [Type (1)] [Compression (1)] [Payload (N)]
var lengthBytes = BitConverter.GetBytes(payloadBytes.Length);
await stream.WriteAsync(lengthBytes, 0, 4, token);
stream.WriteByte((byte)type);
stream.WriteByte(compressionFlag);
await stream.WriteAsync(payloadBytes, 0, payloadBytes.Length, token);
await stream.FlushAsync(token);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Reads and decodes the next protocol message from the provided stream.
/// </summary>
/// <param name="stream">The source stream.</param>
/// <param name="cipherState">Optional cipher state used to decrypt incoming payloads.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>A tuple containing the decoded message type and payload bytes.</returns>
public async Task<(MessageType, byte[])> ReadMessageAsync(Stream stream, CipherState? cipherState, CancellationToken token = default)
{
await _readLock.WaitAsync(token);
try
{
var lenBuf = new byte[4];
int read = await ReadExactAsync(stream, lenBuf, 0, 4, token);
if (read == 0) return (MessageType.Unknown, null!);
int length = BitConverter.ToInt32(lenBuf, 0);
int typeByte = stream.ReadByte();
if (typeByte == -1) throw new EndOfStreamException("Connection closed abruptly (type byte)");
int compByte = stream.ReadByte();
if (compByte == -1) throw new EndOfStreamException("Connection closed abruptly (comp byte)");
var payload = new byte[length];
await ReadExactAsync(stream, payload, 0, length, token);
var msgType = (MessageType)typeByte;
// Handle Secure Envelope
if (msgType == MessageType.SecureEnv)
{
if (cipherState == null) throw new InvalidOperationException("Received encrypted message but no cipher state established");
byte[] decrypted;
using (_telemetry?.StartMetric(MetricType.DecryptionTime))
{
var env = SecureEnvelope.Parser.ParseFrom(payload);
decrypted = CryptoHelper.Decrypt(
env.Ciphertext.ToByteArray(),
env.Nonce.ToByteArray(),
env.AuthTag.ToByteArray(),
cipherState.DecryptKey);
}
if (decrypted.Length < 2) throw new InvalidDataException("Decrypted payload too short");
msgType = (MessageType)decrypted[0];
int innerComp = decrypted[1];
var innerPayload = new byte[decrypted.Length - 2];
Buffer.BlockCopy(decrypted, 2, innerPayload, 0, innerPayload.Length);
if (innerComp == 0x01)
{
innerPayload = CompressionHelper.Decompress(innerPayload);
}
return (msgType, innerPayload);
}
// Handle Unencrypted Compression
if (compByte == 0x01)
{
payload = CompressionHelper.Decompress(payload);
}
_logger.LogDebug("Read Message {Type}, Size: {Size}", msgType, payload.Length);
return (msgType, payload);
}
finally
{
_readLock.Release();
} }
} }
private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken token) // 3. Encrypt
{ if (cipherState != null)
int total = 0; using (_telemetry?.StartMetric(MetricType.EncryptionTime))
while (total < count) {
{ // Inner data: [Type (1)] [Compression (1)] [Payload (N)]
int read = await stream.ReadAsync(buffer, offset + total, count - total, token); var dataToEncrypt = new byte[2 + payloadBytes.Length];
if (read == 0) return 0; // EOF dataToEncrypt[0] = (byte)type;
total += read; dataToEncrypt[1] = compressionFlag;
} Buffer.BlockCopy(payloadBytes, 0, dataToEncrypt, 2, payloadBytes.Length);
return total;
} (byte[] ciphertext, byte[] iv, byte[] tag) =
CryptoHelper.Encrypt(dataToEncrypt, cipherState.EncryptKey);
private sealed class ForwardingLogger : ILogger<ProtocolHandler>
{ var env = new SecureEnvelope
private readonly ILogger _inner; {
Ciphertext = ByteString.CopyFrom(ciphertext),
/// <summary> Nonce = ByteString.CopyFrom(iv),
/// Initializes a new instance of the <see cref="ForwardingLogger"/> class. AuthTag = ByteString.CopyFrom(tag)
/// </summary> };
/// <param name="inner">The underlying logger instance.</param>
public ForwardingLogger(ILogger inner) payloadBytes = env.ToByteArray();
{ type = MessageType.SecureEnv;
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); compressionFlag = 0x00; // Outer envelope is not compressed
} }
/// <inheritdoc /> // 4. Thread-Safe Write
public IDisposable? BeginScope<TState>(TState state) where TState : notnull await _writeLock.WaitAsync(token);
{ try
return _inner.BeginScope(state); {
} _logger.LogDebug("Sending Message {Type}, OrgSize: {Org}, WireSize: {Wire}", type, originalSize,
payloadBytes.Length);
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) // Framing: [Length (4)] [Type (1)] [Compression (1)] [Payload (N)]
{ byte[] lengthBytes = BitConverter.GetBytes(payloadBytes.Length);
return _inner.IsEnabled(logLevel); await stream.WriteAsync(lengthBytes, 0, 4, token);
} stream.WriteByte((byte)type);
stream.WriteByte(compressionFlag);
/// <inheritdoc /> await stream.WriteAsync(payloadBytes, 0, payloadBytes.Length, token);
public void Log<TState>( await stream.FlushAsync(token);
LogLevel logLevel, }
EventId eventId, finally
TState state, {
Exception? exception, _writeLock.Release();
Func<TState, Exception?, string> formatter) }
{ }
_inner.Log(logLevel, eventId, state, exception, formatter);
} /// <summary>
} /// Reads and decodes the next protocol message from the provided stream.
} /// </summary>
} /// <param name="stream">The source stream.</param>
/// <param name="cipherState">Optional cipher state used to decrypt incoming payloads.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>A tuple containing the decoded message type and payload bytes.</returns>
public async Task<(MessageType, byte[])> ReadMessageAsync(Stream stream, CipherState? cipherState,
CancellationToken token = default)
{
await _readLock.WaitAsync(token);
try
{
var lenBuf = new byte[4];
int read = await ReadExactAsync(stream, lenBuf, 0, 4, token);
if (read == 0) return (MessageType.Unknown, null!);
var length = BitConverter.ToInt32(lenBuf, 0);
int typeByte = stream.ReadByte();
if (typeByte == -1) throw new EndOfStreamException("Connection closed abruptly (type byte)");
int compByte = stream.ReadByte();
if (compByte == -1) throw new EndOfStreamException("Connection closed abruptly (comp byte)");
var payload = new byte[length];
await ReadExactAsync(stream, payload, 0, length, token);
var msgType = (MessageType)typeByte;
// Handle Secure Envelope
if (msgType == MessageType.SecureEnv)
{
if (cipherState == null)
throw new InvalidOperationException("Received encrypted message but no cipher state established");
byte[] decrypted;
using (_telemetry?.StartMetric(MetricType.DecryptionTime))
{
var env = SecureEnvelope.Parser.ParseFrom(payload);
decrypted = CryptoHelper.Decrypt(
env.Ciphertext.ToByteArray(),
env.Nonce.ToByteArray(),
env.AuthTag.ToByteArray(),
cipherState.DecryptKey);
}
if (decrypted.Length < 2) throw new InvalidDataException("Decrypted payload too short");
msgType = (MessageType)decrypted[0];
int innerComp = decrypted[1];
var innerPayload = new byte[decrypted.Length - 2];
Buffer.BlockCopy(decrypted, 2, innerPayload, 0, innerPayload.Length);
if (innerComp == 0x01) innerPayload = CompressionHelper.Decompress(innerPayload);
return (msgType, innerPayload);
}
// Handle Unencrypted Compression
if (compByte == 0x01) payload = CompressionHelper.Decompress(payload);
_logger.LogDebug("Read Message {Type}, Size: {Size}", msgType, payload.Length);
return (msgType, payload);
}
finally
{
_readLock.Release();
}
}
private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken token)
{
var total = 0;
while (total < count)
{
int read = await stream.ReadAsync(buffer, offset + total, count - total, token);
if (read == 0) return 0; // EOF
total += read;
}
return total;
}
private sealed class ForwardingLogger : ILogger<ProtocolHandler>
{
private readonly ILogger _inner;
/// <summary>
/// Initializes a new instance of the <see cref="ForwardingLogger" /> class.
/// </summary>
/// <param name="inner">The underlying logger instance.</param>
public ForwardingLogger(ILogger inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return _inner.BeginScope(state);
}
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel)
{
return _inner.IsEnabled(logLevel);
}
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
_inner.Log(logLevel, eventId, state, exception, formatter);
}
}
}

View File

@@ -48,12 +48,15 @@ node.Start();
## Features ## Features
### Automatic Discovery ### Automatic Discovery
Nodes broadcast their presence via UDP and automatically connect to peers on the same network. Nodes broadcast their presence via UDP and automatically connect to peers on the same network.
### Secure Synchronization ### Secure Synchronization
All nodes must share the same authentication token to sync data. All nodes must share the same authentication token to sync data.
### Scalable Gossip ### Scalable Gossip
Updates propagate exponentially - each node tells multiple peers, ensuring fast network-wide propagation. Updates propagate exponentially - each node tells multiple peers, ensuring fast network-wide propagation.
## Documentation ## Documentation

View File

@@ -1,20 +1,19 @@
using ZB.MOM.WW.CBDDC.Core.Network; using System.Security.Cryptography;
using System.Security.Cryptography; using System.Text;
using System.Text; using ZB.MOM.WW.CBDDC.Core.Network;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Network.Security; namespace ZB.MOM.WW.CBDDC.Network.Security;
/// <summary> /// <summary>
/// Authenticator implementation that uses a shared secret (pre-shared key) to validate nodes. /// Authenticator implementation that uses a shared secret (pre-shared key) to validate nodes.
/// Both nodes must possess the same key to successfully handshake. /// Both nodes must possess the same key to successfully handshake.
/// </summary> /// </summary>
public class ClusterKeyAuthenticator : IAuthenticator public class ClusterKeyAuthenticator : IAuthenticator
{ {
private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider; private readonly IPeerNodeConfigurationProvider _peerNodeConfigurationProvider;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ClusterKeyAuthenticator"/> class. /// Initializes a new instance of the <see cref="ClusterKeyAuthenticator" /> class.
/// </summary> /// </summary>
/// <param name="peerNodeConfigurationProvider">The provider for peer node configuration.</param> /// <param name="peerNodeConfigurationProvider">The provider for peer node configuration.</param>
public ClusterKeyAuthenticator(IPeerNodeConfigurationProvider peerNodeConfigurationProvider) public ClusterKeyAuthenticator(IPeerNodeConfigurationProvider peerNodeConfigurationProvider)
@@ -23,11 +22,11 @@ public class ClusterKeyAuthenticator : IAuthenticator
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> ValidateAsync(string nodeId, string token) public async Task<bool> ValidateAsync(string nodeId, string token)
{ {
var config = await _peerNodeConfigurationProvider.GetConfiguration(); var config = await _peerNodeConfigurationProvider.GetConfiguration();
var configuredHash = SHA256.HashData(Encoding.UTF8.GetBytes(config.AuthToken ?? string.Empty)); byte[] configuredHash = SHA256.HashData(Encoding.UTF8.GetBytes(config.AuthToken ?? string.Empty));
var presentedHash = SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty)); byte[] presentedHash = SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty));
return CryptographicOperations.FixedTimeEquals(configuredHash, presentedHash); return CryptographicOperations.FixedTimeEquals(configuredHash, presentedHash);
} }
} }

View File

@@ -1,30 +1,28 @@
using System;
using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace ZB.MOM.WW.CBDDC.Network.Security; namespace ZB.MOM.WW.CBDDC.Network.Security;
public static class CryptoHelper public static class CryptoHelper
{ {
private const int KeySize = 32; // 256 bits private const int KeySize = 32; // 256 bits
private const int BlockSize = 16; // 128 bits private const int BlockSize = 16; // 128 bits
private const int MacSize = 32; // 256 bits (HMACSHA256) private const int MacSize = 32; // 256 bits (HMACSHA256)
/// <summary> /// <summary>
/// Encrypts plaintext and computes an authentication tag. /// Encrypts plaintext and computes an authentication tag.
/// </summary> /// </summary>
/// <param name="plaintext">The plaintext bytes to encrypt.</param> /// <param name="plaintext">The plaintext bytes to encrypt.</param>
/// <param name="key">The encryption and HMAC key.</param> /// <param name="key">The encryption and HMAC key.</param>
/// <returns>The ciphertext, IV, and authentication tag.</returns> /// <returns>The ciphertext, IV, and authentication tag.</returns>
public static (byte[] ciphertext, byte[] iv, byte[] tag) Encrypt(byte[] plaintext, byte[] key) public static (byte[] ciphertext, byte[] iv, byte[] tag) Encrypt(byte[] plaintext, byte[] key)
{ {
using var aes = Aes.Create(); using var aes = Aes.Create();
aes.Key = key; aes.Key = key;
aes.GenerateIV(); aes.GenerateIV();
var iv = aes.IV; byte[] iv = aes.IV;
using var encryptor = aes.CreateEncryptor(); using var encryptor = aes.CreateEncryptor();
var ciphertext = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length); byte[] ciphertext = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);
// Compute HMAC // Compute HMAC
using var hmac = new HMACSHA256(key); using var hmac = new HMACSHA256(key);
@@ -32,32 +30,30 @@ public static class CryptoHelper
var toSign = new byte[iv.Length + ciphertext.Length]; var toSign = new byte[iv.Length + ciphertext.Length];
Buffer.BlockCopy(iv, 0, toSign, 0, iv.Length); Buffer.BlockCopy(iv, 0, toSign, 0, iv.Length);
Buffer.BlockCopy(ciphertext, 0, toSign, iv.Length, ciphertext.Length); Buffer.BlockCopy(ciphertext, 0, toSign, iv.Length, ciphertext.Length);
var tag = hmac.ComputeHash(toSign); byte[] tag = hmac.ComputeHash(toSign);
return (ciphertext, iv, tag); return (ciphertext, iv, tag);
} }
/// <summary> /// <summary>
/// Verifies and decrypts ciphertext. /// Verifies and decrypts ciphertext.
/// </summary> /// </summary>
/// <param name="ciphertext">The encrypted bytes.</param> /// <param name="ciphertext">The encrypted bytes.</param>
/// <param name="iv">The initialization vector used during encryption.</param> /// <param name="iv">The initialization vector used during encryption.</param>
/// <param name="tag">The authentication tag for integrity verification.</param> /// <param name="tag">The authentication tag for integrity verification.</param>
/// <param name="key">The encryption and HMAC key.</param> /// <param name="key">The encryption and HMAC key.</param>
/// <returns>The decrypted plaintext bytes.</returns> /// <returns>The decrypted plaintext bytes.</returns>
public static byte[] Decrypt(byte[] ciphertext, byte[] iv, byte[] tag, byte[] key) public static byte[] Decrypt(byte[] ciphertext, byte[] iv, byte[] tag, byte[] key)
{ {
// Verify HMAC // Verify HMAC
using var hmac = new HMACSHA256(key); using var hmac = new HMACSHA256(key);
var toVerify = new byte[iv.Length + ciphertext.Length]; var toVerify = new byte[iv.Length + ciphertext.Length];
Buffer.BlockCopy(iv, 0, toVerify, 0, iv.Length); Buffer.BlockCopy(iv, 0, toVerify, 0, iv.Length);
Buffer.BlockCopy(ciphertext, 0, toVerify, iv.Length, ciphertext.Length); Buffer.BlockCopy(ciphertext, 0, toVerify, iv.Length, ciphertext.Length);
var computedTag = hmac.ComputeHash(toVerify); byte[] computedTag = hmac.ComputeHash(toVerify);
if (!FixedTimeEquals(tag, computedTag)) if (!FixedTimeEquals(tag, computedTag))
{
throw new CryptographicException("Authentication failed (HMAC mismatch)"); throw new CryptographicException("Authentication failed (HMAC mismatch)");
}
using var aes = Aes.Create(); using var aes = Aes.Create();
aes.Key = key; aes.Key = key;
@@ -78,4 +74,4 @@ public static class CryptoHelper
return res == 0; return res == 0;
#endif #endif
} }
} }

View File

@@ -1,14 +1,12 @@
using System.Threading.Tasks; namespace ZB.MOM.WW.CBDDC.Network.Security;
namespace ZB.MOM.WW.CBDDC.Network.Security;
public interface IAuthenticator public interface IAuthenticator
{ {
/// <summary> /// <summary>
/// Validates an authentication token for a node identifier. /// Validates an authentication token for a node identifier.
/// </summary> /// </summary>
/// <param name="nodeId">The node identifier to validate.</param> /// <param name="nodeId">The node identifier to validate.</param>
/// <param name="token">The authentication token to validate.</param> /// <param name="token">The authentication token to validate.</param>
/// <returns><see langword="true"/> if the token is valid for the node; otherwise <see langword="false"/>.</returns> /// <returns><see langword="true" /> if the token is valid for the node; otherwise <see langword="false" />.</returns>
Task<bool> ValidateAsync(string nodeId, string token); Task<bool> ValidateAsync(string nodeId, string token);
} }

View File

@@ -1,36 +1,25 @@
using System.Threading; namespace ZB.MOM.WW.CBDDC.Network.Security;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Network.Security;
public interface IPeerHandshakeService public interface IPeerHandshakeService
{ {
/// <summary> /// <summary>
/// Performs a handshake to establishing identity and optional security context. /// Performs a handshake to establishing identity and optional security context.
/// </summary> /// </summary>
/// <param name="stream">The transport stream used for handshake message exchange.</param> /// <param name="stream">The transport stream used for handshake message exchange.</param>
/// <param name="isInitiator">A value indicating whether the caller initiated the connection.</param> /// <param name="isInitiator">A value indicating whether the caller initiated the connection.</param>
/// <param name="myNodeId">The local node identifier.</param> /// <param name="myNodeId">The local node identifier.</param>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>A CipherState if encryption is established, or null if plaintext.</returns> /// <returns>A CipherState if encryption is established, or null if plaintext.</returns>
Task<CipherState?> HandshakeAsync(System.IO.Stream stream, bool isInitiator, string myNodeId, CancellationToken token); Task<CipherState?> HandshakeAsync(Stream stream, bool isInitiator, string myNodeId, CancellationToken token);
} }
public class CipherState public class CipherState
{ {
/// <summary>
/// Gets the key used to encrypt outgoing messages.
/// </summary>
public byte[] EncryptKey { get; }
/// <summary>
/// Gets the key used to decrypt incoming messages.
/// </summary>
public byte[] DecryptKey { get; }
// For simplicity using IV chaining or explicit IVs. // For simplicity using IV chaining or explicit IVs.
// We'll store just the keys here and let the encryption helper handle IVs. // We'll store just the keys here and let the encryption helper handle IVs.
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CipherState"/> class. /// Initializes a new instance of the <see cref="CipherState" /> class.
/// </summary> /// </summary>
/// <param name="encryptKey">The key used for encrypting outgoing payloads.</param> /// <param name="encryptKey">The key used for encrypting outgoing payloads.</param>
/// <param name="decryptKey">The key used for decrypting incoming payloads.</param> /// <param name="decryptKey">The key used for decrypting incoming payloads.</param>
@@ -39,4 +28,14 @@ public class CipherState
EncryptKey = encryptKey; EncryptKey = encryptKey;
DecryptKey = decryptKey; DecryptKey = decryptKey;
} }
}
/// <summary>
/// Gets the key used to encrypt outgoing messages.
/// </summary>
public byte[] EncryptKey { get; }
/// <summary>
/// Gets the key used to decrypt incoming messages.
/// </summary>
public byte[] DecryptKey { get; }
}

View File

@@ -1,29 +1,32 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.CBDDC.Network.Security; namespace ZB.MOM.WW.CBDDC.Network.Security;
/// <summary> /// <summary>
/// Provides a no-operation implementation of the peer handshake service that performs no handshake and always returns /// Provides a no-operation implementation of the peer handshake service that performs no handshake and always returns
/// null. /// null.
/// </summary> /// </summary>
/// <remarks>This class can be used in scenarios where a handshake is not required or for testing purposes. All /// <remarks>
/// handshake attempts using this service will result in no cipher state being established.</remarks> /// This class can be used in scenarios where a handshake is not required or for testing purposes. All
/// handshake attempts using this service will result in no cipher state being established.
/// </remarks>
public class NoOpHandshakeService : IPeerHandshakeService public class NoOpHandshakeService : IPeerHandshakeService
{ {
/// <summary> /// <summary>
/// Performs a handshake over the specified stream to establish a secure communication channel between two nodes /// Performs a handshake over the specified stream to establish a secure communication channel between two nodes
/// asynchronously. /// asynchronously.
/// </summary> /// </summary>
/// <param name="stream">The stream used for exchanging handshake messages between nodes. Must be readable and writable.</param> /// <param name="stream">The stream used for exchanging handshake messages between nodes. Must be readable and writable.</param>
/// <param name="isInitiator">true to initiate the handshake as the local node; otherwise, false to respond as the remote node.</param> /// <param name="isInitiator">
/// true to initiate the handshake as the local node; otherwise, false to respond as the remote
/// node.
/// </param>
/// <param name="myNodeId">The unique identifier of the local node participating in the handshake. Cannot be null.</param> /// <param name="myNodeId">The unique identifier of the local node participating in the handshake. Cannot be null.</param>
/// <param name="token">A cancellation token that can be used to cancel the handshake operation.</param> /// <param name="token">A cancellation token that can be used to cancel the handshake operation.</param>
/// <returns>A task that represents the asynchronous handshake operation. The task result contains a CipherState if the /// <returns>
/// handshake succeeds; otherwise, null.</returns> /// A task that represents the asynchronous handshake operation. The task result contains a CipherState if the
/// handshake succeeds; otherwise, null.
/// </returns>
public Task<CipherState?> HandshakeAsync(Stream stream, bool isInitiator, string myNodeId, CancellationToken token) public Task<CipherState?> HandshakeAsync(Stream stream, bool isInitiator, string myNodeId, CancellationToken token)
{ {
return Task.FromResult<CipherState?>(null); return Task.FromResult<CipherState?>(null);
} }
} }

View File

@@ -1,8 +1,4 @@
using System;
using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.CBDDC.Network.Security; namespace ZB.MOM.WW.CBDDC.Network.Security;
@@ -12,7 +8,7 @@ public class SecureHandshakeService : IPeerHandshakeService
private readonly ILogger<SecureHandshakeService>? _logger; private readonly ILogger<SecureHandshakeService>? _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SecureHandshakeService"/> class. /// Initializes a new instance of the <see cref="SecureHandshakeService" /> class.
/// </summary> /// </summary>
/// <param name="logger">The optional logger instance.</param> /// <param name="logger">The optional logger instance.</param>
public SecureHandshakeService(ILogger<SecureHandshakeService>? logger = null) public SecureHandshakeService(ILogger<SecureHandshakeService>? logger = null)
@@ -26,44 +22,42 @@ public class SecureHandshakeService : IPeerHandshakeService
// Both derive shared secret -> Split into SendKey/RecvKey using HKDF // Both derive shared secret -> Split into SendKey/RecvKey using HKDF
/// <summary> /// <summary>
/// Performs a secure key exchange handshake over the provided stream. /// Performs a secure key exchange handshake over the provided stream.
/// </summary> /// </summary>
/// <param name="stream">The transport stream used for the handshake.</param> /// <param name="stream">The transport stream used for the handshake.</param>
/// <param name="isInitiator">A value indicating whether the local node initiated the handshake.</param> /// <param name="isInitiator">A value indicating whether the local node initiated the handshake.</param>
/// <param name="myNodeId">The local node identifier.</param> /// <param name="myNodeId">The local node identifier.</param>
/// <param name="token">A token used to cancel the handshake.</param> /// <param name="token">A token used to cancel the handshake.</param>
/// <returns> /// <returns>
/// A task that returns the negotiated <see cref="CipherState"/>, or <see langword="null"/> if unavailable. /// A task that returns the negotiated <see cref="CipherState" />, or <see langword="null" /> if unavailable.
/// </returns> /// </returns>
public async Task<CipherState?> HandshakeAsync(Stream stream, bool isInitiator, string myNodeId, CancellationToken token) public async Task<CipherState?> HandshakeAsync(Stream stream, bool isInitiator, string myNodeId,
CancellationToken token)
{ {
#if NET6_0_OR_GREATER #if NET6_0_OR_GREATER
using var ecdh = ECDiffieHellman.Create(); using var ecdh = ECDiffieHellman.Create();
ecdh.KeySize = 256; ecdh.KeySize = 256;
// 1. ExportAsync & Send Public Key // 1. ExportAsync & Send Public Key
var myPublicKey = ecdh.ExportSubjectPublicKeyInfo(); byte[] myPublicKey = ecdh.ExportSubjectPublicKeyInfo();
var lenBytes = BitConverter.GetBytes(myPublicKey.Length); byte[] lenBytes = BitConverter.GetBytes(myPublicKey.Length);
await stream.WriteAsync(lenBytes, 0, 4, token); await stream.WriteAsync(lenBytes, 0, 4, token);
await stream.WriteAsync(myPublicKey, 0, myPublicKey.Length, token); await stream.WriteAsync(myPublicKey, 0, myPublicKey.Length, token);
await stream.FlushAsync(token); // CRITICAL: Ensure data is sent immediately await stream.FlushAsync(token); // CRITICAL: Ensure data is sent immediately
// 2. Receive Peer Public Key // 2. Receive Peer Public Key
var peerLenBuf = new byte[4]; var peerLenBuf = new byte[4];
await ReadExactAsync(stream, peerLenBuf, 0, 4, token); await ReadExactAsync(stream, peerLenBuf, 0, 4, token);
int peerLen = BitConverter.ToInt32(peerLenBuf, 0); var peerLen = BitConverter.ToInt32(peerLenBuf, 0);
// Validate peer key length to prevent DoS // Validate peer key length to prevent DoS
if (peerLen <= 0 || peerLen > 10000) if (peerLen <= 0 || peerLen > 10000) throw new InvalidOperationException($"Invalid peer key length: {peerLen}");
{
throw new InvalidOperationException($"Invalid peer key length: {peerLen}"); var peerKeyBytes = new byte[peerLen];
} await ReadExactAsync(stream, peerKeyBytes, 0, peerLen, token);
var peerKeyBytes = new byte[peerLen]; // 3. Import Peer Key & Derive Shared Secret
await ReadExactAsync(stream, peerKeyBytes, 0, peerLen, token); using var peerEcdh = ECDiffieHellman.Create();
// 3. Import Peer Key & Derive Shared Secret
using var peerEcdh = ECDiffieHellman.Create();
peerEcdh.ImportSubjectPublicKeyInfo(peerKeyBytes, out _); peerEcdh.ImportSubjectPublicKeyInfo(peerKeyBytes, out _);
byte[] sharedSecret = ecdh.DeriveKeyMaterial(peerEcdh.PublicKey); byte[] sharedSecret = ecdh.DeriveKeyMaterial(peerEcdh.PublicKey);
@@ -74,39 +68,40 @@ public class SecureHandshakeService : IPeerHandshakeService
using var sha = SHA256.Create(); using var sha = SHA256.Create();
var k1Input = new byte[sharedSecret.Length + 1]; var k1Input = new byte[sharedSecret.Length + 1];
Buffer.BlockCopy(sharedSecret, 0, k1Input, 0, sharedSecret.Length); Buffer.BlockCopy(sharedSecret, 0, k1Input, 0, sharedSecret.Length);
k1Input[sharedSecret.Length] = 0; // "0" k1Input[sharedSecret.Length] = 0; // "0"
var key1 = sha.ComputeHash(k1Input); byte[] key1 = sha.ComputeHash(k1Input);
var k2Input = new byte[sharedSecret.Length + 1]; var k2Input = new byte[sharedSecret.Length + 1];
Buffer.BlockCopy(sharedSecret, 0, k2Input, 0, sharedSecret.Length); Buffer.BlockCopy(sharedSecret, 0, k2Input, 0, sharedSecret.Length);
k2Input[sharedSecret.Length] = 1; // "1" k2Input[sharedSecret.Length] = 1; // "1"
var key2 = sha.ComputeHash(k2Input); byte[] key2 = sha.ComputeHash(k2Input);
// If initiator: Encrypt with Key1, Decrypt with Key2 // If initiator: Encrypt with Key1, Decrypt with Key2
// If responder: Encrypt with Key2, Decrypt with Key1 // If responder: Encrypt with Key2, Decrypt with Key1
var encryptKey = isInitiator ? key1 : key2; byte[] encryptKey = isInitiator ? key1 : key2;
var decryptKey = isInitiator ? key2 : key1; byte[] decryptKey = isInitiator ? key2 : key1;
return new CipherState(encryptKey, decryptKey); return new CipherState(encryptKey, decryptKey);
#else #else
// For netstandard2.0, standard ECDH import is broken/hard without external libs. // For netstandard2.0, standard ECDH import is broken/hard without external libs.
// Returning null or throwing. // Returning null or throwing.
throw new PlatformNotSupportedException("Secure handshake requires .NET 6.0+"); throw new PlatformNotSupportedException("Secure handshake requires .NET 6.0+");
#endif #endif
} }
private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken token) private async Task<int> ReadExactAsync(Stream stream, byte[] buffer, int offset, int count, CancellationToken token)
{ {
int total = 0; var total = 0;
while (total < count) while (total < count)
{ {
int read = await stream.ReadAsync(buffer, offset + total, count - total, token); int read = await stream.ReadAsync(buffer, offset + total, count - total, token);
if (read == 0) throw new EndOfStreamException(); if (read == 0) throw new EndOfStreamException();
total += read; total += read;
} }
return total;
} return total;
} }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,60 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Sockets; using System.Net.Sockets;
using System.Threading; using System.Text.Json;
using System.Threading.Tasks;
using Google.Protobuf;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Network.Proto; using ZB.MOM.WW.CBDDC.Network.Proto;
using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Network.Protocol; using ZB.MOM.WW.CBDDC.Network.Protocol;
using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Network.Telemetry; using ZB.MOM.WW.CBDDC.Network.Telemetry;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Represents a TCP client connection to a remote peer for synchronization. /// Represents a TCP client connection to a remote peer for synchronization.
/// </summary> /// </summary>
public class TcpPeerClient : IDisposable public class TcpPeerClient : IDisposable
{ {
private readonly TcpClient _client;
private readonly string _peerAddress;
private readonly ILogger<TcpPeerClient> _logger;
private readonly IPeerHandshakeService? _handshakeService;
private NetworkStream? _stream;
private CipherState? _cipherState;
private readonly object _connectionLock = new object();
private bool _disposed = false;
private const int ConnectionTimeoutMs = 5000; private const int ConnectionTimeoutMs = 5000;
private const int OperationTimeoutMs = 30000; private const int OperationTimeoutMs = 30000;
private readonly TcpClient _client;
private readonly ProtocolHandler _protocol; private readonly object _connectionLock = new();
private readonly IPeerHandshakeService? _handshakeService;
/// <summary> private readonly ILogger<TcpPeerClient> _logger;
/// Gets a value indicating whether the client currently has an active connection. private readonly string _peerAddress;
/// </summary>
public bool IsConnected private readonly ProtocolHandler _protocol;
{
private readonly INetworkTelemetryService? _telemetry;
private CipherState? _cipherState;
private bool _disposed;
private List<string> _remoteInterests = new();
private NetworkStream? _stream;
private bool _useCompression; // Negotiated after handshake
/// <summary>
/// Initializes a new instance of the <see cref="TcpPeerClient" /> class.
/// </summary>
/// <param name="peerAddress">The remote peer address in <c>host:port</c> format.</param>
/// <param name="logger">The logger used for connection and protocol events.</param>
/// <param name="handshakeService">The optional handshake service used to establish secure sessions.</param>
/// <param name="telemetry">The optional telemetry service for network metrics.</param>
public TcpPeerClient(string peerAddress, ILogger<TcpPeerClient> logger,
IPeerHandshakeService? handshakeService = null, INetworkTelemetryService? telemetry = null)
{
_client = new TcpClient();
_peerAddress = peerAddress;
_logger = logger;
_handshakeService = handshakeService;
_telemetry = telemetry;
_protocol = new ProtocolHandler(logger, telemetry);
}
/// <summary>
/// Gets a value indicating whether the client currently has an active connection.
/// </summary>
public bool IsConnected
{
get get
{ {
lock (_connectionLock) lock (_connectionLock)
@@ -46,89 +62,97 @@ public class TcpPeerClient : IDisposable
return _client != null && _client.Connected && _stream != null && !_disposed; return _client != null && _client.Connected && _stream != null && !_disposed;
} }
} }
} }
/// <summary> /// <summary>
/// Gets a value indicating whether the handshake with the remote peer has completed successfully. /// Gets a value indicating whether the handshake with the remote peer has completed successfully.
/// </summary> /// </summary>
public bool HasHandshaked { get; private set; } public bool HasHandshaked { get; private set; }
private readonly INetworkTelemetryService? _telemetry; /// <summary>
/// Gets the list of collections the remote peer is interested in.
/// <summary> /// </summary>
/// Initializes a new instance of the <see cref="TcpPeerClient"/> class. public IReadOnlyList<string> RemoteInterests => _remoteInterests.AsReadOnly();
/// </summary>
/// <param name="peerAddress">The remote peer address in <c>host:port</c> format.</param> /// <summary>
/// <param name="logger">The logger used for connection and protocol events.</param> /// Releases resources used by the peer client.
/// <param name="handshakeService">The optional handshake service used to establish secure sessions.</param> /// </summary>
/// <param name="telemetry">The optional telemetry service for network metrics.</param> public void Dispose()
public TcpPeerClient(string peerAddress, ILogger<TcpPeerClient> logger, IPeerHandshakeService? handshakeService = null, INetworkTelemetryService? telemetry = null) {
{
_client = new TcpClient();
_peerAddress = peerAddress;
_logger = logger;
_handshakeService = handshakeService;
_telemetry = telemetry;
_protocol = new ProtocolHandler(logger, telemetry);
}
/// <summary>
/// Connects to the configured remote peer.
/// </summary>
/// <param name="token">A token used to cancel the connection attempt.</param>
/// <returns>A task that represents the asynchronous connect operation.</returns>
public async Task ConnectAsync(CancellationToken token)
{
lock (_connectionLock) lock (_connectionLock)
{ {
if (_disposed) if (_disposed) return;
{ _disposed = true;
throw new ObjectDisposedException(nameof(TcpPeerClient)); }
}
try
{
_stream?.Dispose();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error disposing network stream");
}
try
{
_client?.Dispose();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error disposing TCP client");
}
_logger.LogDebug("Disposed connection to peer: {Address}", _peerAddress);
}
/// <summary>
/// Connects to the configured remote peer.
/// </summary>
/// <param name="token">A token used to cancel the connection attempt.</param>
/// <returns>A task that represents the asynchronous connect operation.</returns>
public async Task ConnectAsync(CancellationToken token)
{
lock (_connectionLock)
{
if (_disposed) throw new ObjectDisposedException(nameof(TcpPeerClient));
if (IsConnected) return; if (IsConnected) return;
} }
var parts = _peerAddress.Split(':'); string[] parts = _peerAddress.Split(':');
if (parts.Length != 2) if (parts.Length != 2)
{
throw new ArgumentException($"Invalid address format: {_peerAddress}. Expected format: host:port"); throw new ArgumentException($"Invalid address format: {_peerAddress}. Expected format: host:port");
}
if (!int.TryParse(parts[1], out int port) || port <= 0 || port > 65535) if (!int.TryParse(parts[1], out int port) || port <= 0 || port > 65535)
{
throw new ArgumentException($"Invalid port number: {parts[1]}"); throw new ArgumentException($"Invalid port number: {parts[1]}");
}
// Connect with timeout // Connect with timeout
using var timeoutCts = new CancellationTokenSource(ConnectionTimeoutMs); using var timeoutCts = new CancellationTokenSource(ConnectionTimeoutMs);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token);
try try
{ {
await _client.ConnectAsync(parts[0], port); await _client.ConnectAsync(parts[0], port);
lock (_connectionLock) lock (_connectionLock)
{ {
if (_disposed) if (_disposed) throw new ObjectDisposedException(nameof(TcpPeerClient));
{
throw new ObjectDisposedException(nameof(TcpPeerClient)); _stream = _client.GetStream();
}
// CRITICAL for Android: Disable Nagle's algorithm to prevent buffering delays
_stream = _client.GetStream(); // This ensures immediate packet transmission for handshake data
_client.NoDelay = true;
// CRITICAL for Android: Disable Nagle's algorithm to prevent buffering delays
// This ensures immediate packet transmission for handshake data // Configure TCP keepalive
_client.NoDelay = true; _client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// Configure TCP keepalive // Set read/write timeouts
_client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// Set read/write timeouts
_stream.ReadTimeout = OperationTimeoutMs; _stream.ReadTimeout = OperationTimeoutMs;
_stream.WriteTimeout = OperationTimeoutMs; _stream.WriteTimeout = OperationTimeoutMs;
} }
_logger.LogDebug("Connected to peer: {Address} (NoDelay=true for immediate send)", _peerAddress); _logger.LogDebug("Connected to peer: {Address} (NoDelay=true for immediate send)", _peerAddress);
} }
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
@@ -138,13 +162,7 @@ public class TcpPeerClient : IDisposable
} }
/// <summary> /// <summary>
/// Gets the list of collections the remote peer is interested in. /// Performs authentication handshake with the remote peer.
/// </summary>
public System.Collections.Generic.IReadOnlyList<string> RemoteInterests => _remoteInterests.AsReadOnly();
private List<string> _remoteInterests = new();
/// <summary>
/// Performs authentication handshake with the remote peer.
/// </summary> /// </summary>
/// <param name="myNodeId">The local node identifier.</param> /// <param name="myNodeId">The local node identifier.</param>
/// <param name="authToken">The authentication token.</param> /// <param name="authToken">The authentication token.</param>
@@ -155,46 +173,38 @@ public class TcpPeerClient : IDisposable
return await HandshakeAsync(myNodeId, authToken, null, token); return await HandshakeAsync(myNodeId, authToken, null, token);
} }
/// <summary> /// <summary>
/// Performs authentication handshake with the remote peer, including collection interests. /// Performs authentication handshake with the remote peer, including collection interests.
/// </summary> /// </summary>
/// <param name="myNodeId">The local node identifier.</param> /// <param name="myNodeId">The local node identifier.</param>
/// <param name="authToken">The authentication token.</param> /// <param name="authToken">The authentication token.</param>
/// <param name="interestingCollections">Optional collection names this node is interested in receiving.</param> /// <param name="interestingCollections">Optional collection names this node is interested in receiving.</param>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns><see langword="true"/> if handshake was accepted; otherwise <see langword="false"/>.</returns> /// <returns><see langword="true" /> if handshake was accepted; otherwise <see langword="false" />.</returns>
public async Task<bool> HandshakeAsync(string myNodeId, string authToken, IEnumerable<string>? interestingCollections, CancellationToken token) public async Task<bool> HandshakeAsync(string myNodeId, string authToken,
{ IEnumerable<string>? interestingCollections, CancellationToken token)
{
if (HasHandshaked) return true; if (HasHandshaked) return true;
if (_handshakeService != null) if (_handshakeService != null)
{
// Perform secure handshake if service is available // Perform secure handshake if service is available
// We assume we are initiator here // We assume we are initiator here
_cipherState = await _handshakeService.HandshakeAsync(_stream!, true, myNodeId, token); _cipherState = await _handshakeService.HandshakeAsync(_stream!, true, myNodeId, token);
}
var req = new HandshakeRequest { NodeId = myNodeId, AuthToken = authToken ?? "" }; var req = new HandshakeRequest { NodeId = myNodeId, AuthToken = authToken ?? "" };
if (interestingCollections != null) if (interestingCollections != null)
{ foreach (string coll in interestingCollections)
foreach (var coll in interestingCollections)
{
req.InterestingCollections.Add(coll); req.InterestingCollections.Add(coll);
}
}
if (CompressionHelper.IsBrotliSupported) if (CompressionHelper.IsBrotliSupported) req.SupportedCompression.Add("brotli");
{
req.SupportedCompression.Add("brotli");
}
_logger.LogDebug("Sending HandshakeReq to {Address}", _peerAddress); _logger.LogDebug("Sending HandshakeReq to {Address}", _peerAddress);
await _protocol.SendMessageAsync(_stream!, MessageType.HandshakeReq, req, false, _cipherState, token); await _protocol.SendMessageAsync(_stream!, MessageType.HandshakeReq, req, false, _cipherState, token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); (var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
_logger.LogDebug("Received Handshake response type: {Type}", type); _logger.LogDebug("Received Handshake response type: {Type}", type);
if (type != MessageType.HandshakeRes) return false; if (type != MessageType.HandshakeRes) return false;
var res = HandshakeResponse.Parser.ParseFrom(payload); var res = HandshakeResponse.Parser.ParseFrom(payload);
@@ -213,18 +223,19 @@ public class TcpPeerClient : IDisposable
return res.Accepted; return res.Accepted;
} }
/// <summary> /// <summary>
/// Retrieves the remote peer's latest HLC timestamp. /// Retrieves the remote peer's latest HLC timestamp.
/// </summary> /// </summary>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>The latest remote hybrid logical clock timestamp.</returns> /// <returns>The latest remote hybrid logical clock timestamp.</returns>
public async Task<HlcTimestamp> GetClockAsync(CancellationToken token) public async Task<HlcTimestamp> GetClockAsync(CancellationToken token)
{ {
using (_telemetry?.StartMetric(MetricType.RoundTripTime)) using (_telemetry?.StartMetric(MetricType.RoundTripTime))
{ {
await _protocol.SendMessageAsync(_stream!, MessageType.GetClockReq, new GetClockRequest(), _useCompression, _cipherState, token); await _protocol.SendMessageAsync(_stream!, MessageType.GetClockReq, new GetClockRequest(), _useCompression,
_cipherState, token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); (var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.ClockRes) throw new Exception("Unexpected response"); if (type != MessageType.ClockRes) throw new Exception("Unexpected response");
var res = ClockResponse.Parser.ParseFrom(payload); var res = ClockResponse.Parser.ParseFrom(payload);
@@ -232,69 +243,67 @@ public class TcpPeerClient : IDisposable
} }
} }
/// <summary> /// <summary>
/// Retrieves the remote peer's vector clock (latest timestamp per node). /// Retrieves the remote peer's vector clock (latest timestamp per node).
/// </summary> /// </summary>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>The remote vector clock.</returns> /// <returns>The remote vector clock.</returns>
public async Task<VectorClock> GetVectorClockAsync(CancellationToken token) public async Task<VectorClock> GetVectorClockAsync(CancellationToken token)
{ {
using (_telemetry?.StartMetric(MetricType.RoundTripTime)) using (_telemetry?.StartMetric(MetricType.RoundTripTime))
{ {
await _protocol.SendMessageAsync(_stream!, MessageType.GetVectorClockReq, new GetVectorClockRequest(), _useCompression, _cipherState, token); await _protocol.SendMessageAsync(_stream!, MessageType.GetVectorClockReq, new GetVectorClockRequest(),
_useCompression, _cipherState, token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); (var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.VectorClockRes) throw new Exception("Unexpected response"); if (type != MessageType.VectorClockRes) throw new Exception("Unexpected response");
var res = VectorClockResponse.Parser.ParseFrom(payload); var res = VectorClockResponse.Parser.ParseFrom(payload);
var vectorClock = new VectorClock(); var vectorClock = new VectorClock();
foreach (var entry in res.Entries) foreach (var entry in res.Entries)
{
vectorClock.SetTimestamp(entry.NodeId, new HlcTimestamp(entry.HlcWall, entry.HlcLogic, entry.NodeId)); vectorClock.SetTimestamp(entry.NodeId, new HlcTimestamp(entry.HlcWall, entry.HlcLogic, entry.NodeId));
}
return vectorClock; return vectorClock;
} }
} }
/// <summary> /// <summary>
/// Pulls oplog changes from the remote peer since the specified timestamp. /// Pulls oplog changes from the remote peer since the specified timestamp.
/// </summary> /// </summary>
/// <param name="since">The starting timestamp for requested changes.</param> /// <param name="since">The starting timestamp for requested changes.</param>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>The list of oplog entries returned by the remote peer.</returns> /// <returns>The list of oplog entries returned by the remote peer.</returns>
public async Task<List<OplogEntry>> PullChangesAsync(HlcTimestamp since, CancellationToken token) public async Task<List<OplogEntry>> PullChangesAsync(HlcTimestamp since, CancellationToken token)
{ {
return await PullChangesAsync(since, null, token); return await PullChangesAsync(since, null, token);
} }
/// <summary>
/// Pulls oplog changes from the remote peer since the specified timestamp, filtered by collections.
/// </summary>
/// <param name="since">The starting timestamp for requested changes.</param>
/// <param name="collections">Optional collection names used to filter the returned entries.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>The list of oplog entries returned by the remote peer.</returns>
public async Task<List<OplogEntry>> PullChangesAsync(HlcTimestamp since, IEnumerable<string>? collections, CancellationToken token)
{
var req = new PullChangesRequest
{
SinceWall = since.PhysicalTime,
SinceLogic = since.LogicalCounter,
// Empty SinceNode indicates a global pull (not source-node filtered).
SinceNode = string.Empty
};
if (collections != null)
{
foreach (var coll in collections)
{
req.Collections.Add(coll);
}
}
await _protocol.SendMessageAsync(_stream!, MessageType.PullChangesReq, req, _useCompression, _cipherState, token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); /// <summary>
/// Pulls oplog changes from the remote peer since the specified timestamp, filtered by collections.
/// </summary>
/// <param name="since">The starting timestamp for requested changes.</param>
/// <param name="collections">Optional collection names used to filter the returned entries.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>The list of oplog entries returned by the remote peer.</returns>
public async Task<List<OplogEntry>> PullChangesAsync(HlcTimestamp since, IEnumerable<string>? collections,
CancellationToken token)
{
var req = new PullChangesRequest
{
SinceWall = since.PhysicalTime,
SinceLogic = since.LogicalCounter,
// Empty SinceNode indicates a global pull (not source-node filtered).
SinceNode = string.Empty
};
if (collections != null)
foreach (string coll in collections)
req.Collections.Add(coll);
await _protocol.SendMessageAsync(_stream!, MessageType.PullChangesReq, req, _useCompression, _cipherState,
token);
(var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.ChangeSetRes) throw new Exception("Unexpected response"); if (type != MessageType.ChangeSetRes) throw new Exception("Unexpected response");
var res = ChangeSetResponse.Parser.ParseFrom(payload); var res = ChangeSetResponse.Parser.ParseFrom(payload);
@@ -303,35 +312,38 @@ public class TcpPeerClient : IDisposable
e.Collection, e.Collection,
e.Key, e.Key,
ParseOp(e.Operation), ParseOp(e.Operation),
string.IsNullOrEmpty(e.JsonData) ? default : System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(e.JsonData), string.IsNullOrEmpty(e.JsonData) ? default : JsonSerializer.Deserialize<JsonElement>(e.JsonData),
new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode), new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode),
e.PreviousHash, e.PreviousHash,
e.Hash // Pass the received hash to preserve integrity reference e.Hash // Pass the received hash to preserve integrity reference
)).ToList(); )).ToList();
} }
/// <summary> /// <summary>
/// Pulls oplog changes for a specific node from the remote peer since the specified timestamp. /// Pulls oplog changes for a specific node from the remote peer since the specified timestamp.
/// </summary> /// </summary>
/// <param name="nodeId">The node identifier to filter changes by.</param> /// <param name="nodeId">The node identifier to filter changes by.</param>
/// <param name="since">The starting timestamp for requested changes.</param> /// <param name="since">The starting timestamp for requested changes.</param>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>The list of oplog entries returned by the remote peer.</returns> /// <returns>The list of oplog entries returned by the remote peer.</returns>
public async Task<List<OplogEntry>> PullChangesFromNodeAsync(string nodeId, HlcTimestamp since, CancellationToken token) public async Task<List<OplogEntry>> PullChangesFromNodeAsync(string nodeId, HlcTimestamp since,
{ CancellationToken token)
return await PullChangesFromNodeAsync(nodeId, since, null, token); {
} return await PullChangesFromNodeAsync(nodeId, since, null, token);
}
/// <summary>
/// Pulls oplog changes for a specific node from the remote peer since the specified timestamp, filtered by collections. /// <summary>
/// </summary> /// Pulls oplog changes for a specific node from the remote peer since the specified timestamp, filtered by
/// <param name="nodeId">The node identifier to filter changes by.</param> /// collections.
/// <param name="since">The starting timestamp for requested changes.</param> /// </summary>
/// <param name="collections">Optional collection names used to filter the returned entries.</param> /// <param name="nodeId">The node identifier to filter changes by.</param>
/// <param name="token">Cancellation token.</param> /// <param name="since">The starting timestamp for requested changes.</param>
/// <returns>The list of oplog entries returned by the remote peer.</returns> /// <param name="collections">Optional collection names used to filter the returned entries.</param>
public async Task<List<OplogEntry>> PullChangesFromNodeAsync(string nodeId, HlcTimestamp since, IEnumerable<string>? collections, CancellationToken token) /// <param name="token">Cancellation token.</param>
{ /// <returns>The list of oplog entries returned by the remote peer.</returns>
public async Task<List<OplogEntry>> PullChangesFromNodeAsync(string nodeId, HlcTimestamp since,
IEnumerable<string>? collections, CancellationToken token)
{
var req = new PullChangesRequest var req = new PullChangesRequest
{ {
SinceNode = nodeId, SinceNode = nodeId,
@@ -339,15 +351,13 @@ public class TcpPeerClient : IDisposable
SinceLogic = since.LogicalCounter SinceLogic = since.LogicalCounter
}; };
if (collections != null) if (collections != null)
{ foreach (string coll in collections)
foreach (var coll in collections)
{
req.Collections.Add(coll); req.Collections.Add(coll);
}
}
await _protocol.SendMessageAsync(_stream!, MessageType.PullChangesReq, req, _useCompression, _cipherState, token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); await _protocol.SendMessageAsync(_stream!, MessageType.PullChangesReq, req, _useCompression, _cipherState,
token);
(var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.ChangeSetRes) throw new Exception("Unexpected response"); if (type != MessageType.ChangeSetRes) throw new Exception("Unexpected response");
var res = ChangeSetResponse.Parser.ParseFrom(payload); var res = ChangeSetResponse.Parser.ParseFrom(payload);
@@ -356,26 +366,28 @@ public class TcpPeerClient : IDisposable
e.Collection, e.Collection,
e.Key, e.Key,
ParseOp(e.Operation), ParseOp(e.Operation),
string.IsNullOrEmpty(e.JsonData) ? default : System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(e.JsonData), string.IsNullOrEmpty(e.JsonData) ? default : JsonSerializer.Deserialize<JsonElement>(e.JsonData),
new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode), new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode),
e.PreviousHash, e.PreviousHash,
e.Hash e.Hash
)).ToList(); )).ToList();
} }
/// <summary> /// <summary>
/// Retrieves a range of oplog entries connecting two hashes (Gap Recovery). /// Retrieves a range of oplog entries connecting two hashes (Gap Recovery).
/// </summary> /// </summary>
/// <param name="startHash">The starting hash in the chain.</param> /// <param name="startHash">The starting hash in the chain.</param>
/// <param name="endHash">The ending hash in the chain.</param> /// <param name="endHash">The ending hash in the chain.</param>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>The chain entries connecting the requested hash range.</returns> /// <returns>The chain entries connecting the requested hash range.</returns>
public virtual async Task<List<OplogEntry>> GetChainRangeAsync(string startHash, string endHash, CancellationToken token) public virtual async Task<List<OplogEntry>> GetChainRangeAsync(string startHash, string endHash,
{ CancellationToken token)
{
var req = new GetChainRangeRequest { StartHash = startHash, EndHash = endHash }; var req = new GetChainRangeRequest { StartHash = startHash, EndHash = endHash };
await _protocol.SendMessageAsync(_stream!, MessageType.GetChainRangeReq, req, _useCompression, _cipherState, token); await _protocol.SendMessageAsync(_stream!, MessageType.GetChainRangeReq, req, _useCompression, _cipherState,
token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); (var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.ChainRangeRes) throw new Exception($"Unexpected response for ChainRange: {type}"); if (type != MessageType.ChainRangeRes) throw new Exception($"Unexpected response for ChainRange: {type}");
var res = ChainRangeResponse.Parser.ParseFrom(payload); var res = ChainRangeResponse.Parser.ParseFrom(payload);
@@ -386,27 +398,26 @@ public class TcpPeerClient : IDisposable
e.Collection, e.Collection,
e.Key, e.Key,
ParseOp(e.Operation), ParseOp(e.Operation),
string.IsNullOrEmpty(e.JsonData) ? default : System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(e.JsonData), string.IsNullOrEmpty(e.JsonData) ? default : JsonSerializer.Deserialize<JsonElement>(e.JsonData),
new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode), new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode),
e.PreviousHash, e.PreviousHash,
e.Hash e.Hash
)).ToList(); )).ToList();
} }
/// <summary> /// <summary>
/// Pushes local oplog changes to the remote peer. /// Pushes local oplog changes to the remote peer.
/// </summary> /// </summary>
/// <param name="entries">The oplog entries to push.</param> /// <param name="entries">The oplog entries to push.</param>
/// <param name="token">Cancellation token.</param> /// <param name="token">Cancellation token.</param>
/// <returns>A task that represents the asynchronous push operation.</returns> /// <returns>A task that represents the asynchronous push operation.</returns>
public async Task PushChangesAsync(IEnumerable<OplogEntry> entries, CancellationToken token) public async Task PushChangesAsync(IEnumerable<OplogEntry> entries, CancellationToken token)
{ {
var req = new PushChangesRequest(); var req = new PushChangesRequest();
var entryList = entries.ToList(); var entryList = entries.ToList();
if (entryList.Count == 0) return; if (entryList.Count == 0) return;
foreach (var e in entryList) foreach (var e in entryList)
{
req.Entries.Add(new ProtoOplogEntry req.Entries.Add(new ProtoOplogEntry
{ {
Collection = e.Collection, Collection = e.Collection,
@@ -419,11 +430,11 @@ public class TcpPeerClient : IDisposable
Hash = e.Hash, Hash = e.Hash,
PreviousHash = e.PreviousHash PreviousHash = e.PreviousHash
}); });
}
await _protocol.SendMessageAsync(_stream!, MessageType.PushChangesReq, req, _useCompression, _cipherState, token); await _protocol.SendMessageAsync(_stream!, MessageType.PushChangesReq, req, _useCompression, _cipherState,
token);
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); (var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.AckRes) throw new Exception("Push failed"); if (type != MessageType.AckRes) throw new Exception("Push failed");
var res = AckResponse.Parser.ParseFrom(payload); var res = AckResponse.Parser.ParseFrom(payload);
@@ -431,72 +442,43 @@ public class TcpPeerClient : IDisposable
if (!res.Success) throw new Exception("Push failed"); if (!res.Success) throw new Exception("Push failed");
} }
private bool _useCompression = false; // Negotiated after handshake private OperationType ParseOp(string op)
{
private OperationType ParseOp(string op) => Enum.TryParse<OperationType>(op, out var val) ? val : OperationType.Put; return Enum.TryParse<OperationType>(op, out var val) ? val : OperationType.Put;
}
/// <summary>
/// Downloads a full snapshot from the remote peer to the provided destination stream. /// <summary>
/// </summary> /// Downloads a full snapshot from the remote peer to the provided destination stream.
/// <param name="destination">The stream that receives snapshot bytes.</param> /// </summary>
/// <param name="token">Cancellation token.</param> /// <param name="destination">The stream that receives snapshot bytes.</param>
/// <returns>A task that represents the asynchronous snapshot transfer operation.</returns> /// <param name="token">Cancellation token.</param>
public async Task GetSnapshotAsync(Stream destination, CancellationToken token) /// <returns>A task that represents the asynchronous snapshot transfer operation.</returns>
{ public async Task GetSnapshotAsync(Stream destination, CancellationToken token)
await _protocol.SendMessageAsync(_stream!, MessageType.GetSnapshotReq, new GetSnapshotRequest(), _useCompression, _cipherState, token); {
await _protocol.SendMessageAsync(_stream!, MessageType.GetSnapshotReq, new GetSnapshotRequest(),
_useCompression, _cipherState, token);
while (true) while (true)
{ {
var (type, payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token); (var type, byte[] payload) = await _protocol.ReadMessageAsync(_stream!, _cipherState, token);
if (type != MessageType.SnapshotChunkMsg) throw new Exception($"Unexpected message type during snapshot: {type}"); if (type != MessageType.SnapshotChunkMsg)
throw new Exception($"Unexpected message type during snapshot: {type}");
var chunk = SnapshotChunk.Parser.ParseFrom(payload);
if (chunk.Data.Length > 0) var chunk = SnapshotChunk.Parser.ParseFrom(payload);
{ if (chunk.Data.Length > 0)
await destination.WriteAsync(chunk.Data.ToByteArray(), 0, chunk.Data.Length, token); await destination.WriteAsync(chunk.Data.ToByteArray(), 0, chunk.Data.Length, token);
}
if (chunk.IsLast) break; if (chunk.IsLast) break;
}
}
/// <summary>
/// Releases resources used by the peer client.
/// </summary>
public void Dispose()
{
lock (_connectionLock)
{
if (_disposed) return;
_disposed = true;
}
try
{
_stream?.Dispose();
} }
catch (Exception ex)
{
_logger.LogWarning(ex, "Error disposing network stream");
}
try
{
_client?.Dispose();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error disposing TCP client");
}
_logger.LogDebug("Disposed connection to peer: {Address}", _peerAddress);
} }
} }
public class SnapshotRequiredException : Exception public class SnapshotRequiredException : Exception
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SnapshotRequiredException"/> class. /// Initializes a new instance of the <see cref="SnapshotRequiredException" /> class.
/// </summary> /// </summary>
public SnapshotRequiredException() : base("Peer requires a full snapshot sync.") { } public SnapshotRequiredException() : base("Peer requires a full snapshot sync.")
} {
}
}

View File

@@ -1,69 +1,67 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Google.Protobuf;
using Microsoft.Extensions.Logging;
using Serilog.Context;
using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Network.Proto; using ZB.MOM.WW.CBDDC.Network.Proto;
using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Network.Protocol; using ZB.MOM.WW.CBDDC.Network.Protocol;
using ZB.MOM.WW.CBDDC.Network.Security;
using ZB.MOM.WW.CBDDC.Network.Telemetry; using ZB.MOM.WW.CBDDC.Network.Telemetry;
using Google.Protobuf;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Serilog.Context;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// TCP server that handles incoming synchronization requests from remote peers. /// TCP server that handles incoming synchronization requests from remote peers.
/// </summary> /// </summary>
internal class TcpSyncServer : ISyncServer internal class TcpSyncServer : ISyncServer
{ {
private readonly IOplogStore _oplogStore;
private readonly IDocumentStore _documentStore;
private readonly ISnapshotService _snapshotStore;
private readonly ILogger<TcpSyncServer> _logger;
private readonly IPeerNodeConfigurationProvider _configProvider;
private CancellationTokenSource? _cts;
private TcpListener? _listener;
private readonly object _startStopLock = new object();
private int _activeConnections = 0;
internal int MaxConnections = 100;
private const int ClientOperationTimeoutMs = 60000; private const int ClientOperationTimeoutMs = 60000;
private readonly IAuthenticator _authenticator; private readonly IAuthenticator _authenticator;
private readonly IPeerNodeConfigurationProvider _configProvider;
private readonly IDocumentStore _documentStore;
private readonly IPeerHandshakeService _handshakeService; private readonly IPeerHandshakeService _handshakeService;
private readonly ILogger<TcpSyncServer> _logger;
private readonly IOplogStore _oplogStore;
private readonly ISnapshotService _snapshotStore;
private readonly object _startStopLock = new();
private readonly INetworkTelemetryService? _telemetry; private readonly INetworkTelemetryService? _telemetry;
private int _activeConnections;
private CancellationTokenSource? _cts;
private TcpListener? _listener;
internal int MaxConnections = 100;
/// <summary> /// <summary>
/// Initializes a new instance of the TcpSyncServer class with the specified peer oplogStore, configuration provider, /// Initializes a new instance of the TcpSyncServer class with the specified peer oplogStore, configuration provider,
/// logger, and authenticator. /// logger, and authenticator.
/// </summary> /// </summary>
/// <remarks>The server automatically restarts when the configuration provided by /// <remarks>
/// peerNodeConfigurationProvider changes. This ensures that configuration updates are applied without requiring /// The server automatically restarts when the configuration provided by
/// manual intervention.</remarks> /// peerNodeConfigurationProvider changes. This ensures that configuration updates are applied without requiring
/// <param name="oplogStore">The peer oplogStore used to manage and persist peer information for the server.</param> /// manual intervention.
/// <param name="documentStore">The document store used to read and apply synchronized documents.</param> /// </remarks>
/// <param name="snapshotStore">The snapshot store used to create and manage database snapshots for synchronization.</param> /// <param name="oplogStore">The peer oplogStore used to manage and persist peer information for the server.</param>
/// <param name="peerNodeConfigurationProvider">The provider that supplies configuration settings for the peer node and notifies the server of configuration /// <param name="documentStore">The document store used to read and apply synchronized documents.</param>
/// changes.</param> /// <param name="snapshotStore">The snapshot store used to create and manage database snapshots for synchronization.</param>
/// <param name="logger">The logger used to record informational and error messages for the server instance.</param> /// <param name="peerNodeConfigurationProvider">
/// <param name="authenticator">The authenticator responsible for validating peer connections to the server.</param> /// The provider that supplies configuration settings for the peer node and notifies the server of configuration
/// <param name="handshakeService">The service used to perform secure handshake (optional).</param> /// changes.
/// <param name="telemetry">The optional telemetry service used to record network performance metrics.</param> /// </param>
public TcpSyncServer( /// <param name="logger">The logger used to record informational and error messages for the server instance.</param>
IOplogStore oplogStore, /// <param name="authenticator">The authenticator responsible for validating peer connections to the server.</param>
IDocumentStore documentStore, /// <param name="handshakeService">The service used to perform secure handshake (optional).</param>
/// <param name="telemetry">The optional telemetry service used to record network performance metrics.</param>
public TcpSyncServer(
IOplogStore oplogStore,
IDocumentStore documentStore,
ISnapshotService snapshotStore, ISnapshotService snapshotStore,
IPeerNodeConfigurationProvider peerNodeConfigurationProvider, IPeerNodeConfigurationProvider peerNodeConfigurationProvider,
ILogger<TcpSyncServer> logger, ILogger<TcpSyncServer> logger,
IAuthenticator authenticator, IAuthenticator authenticator,
IPeerHandshakeService handshakeService, IPeerHandshakeService handshakeService,
INetworkTelemetryService? telemetry = null) INetworkTelemetryService? telemetry = null)
@@ -85,10 +83,17 @@ internal class TcpSyncServer : ISyncServer
} }
/// <summary> /// <summary>
/// Starts the TCP synchronization server and begins listening for incoming connections asynchronously. /// Gets the port on which the server is listening.
/// </summary> /// </summary>
/// <remarks>If the server is already running, this method returns immediately without starting a new public int? ListeningPort => ListeningEndpoint?.Port;
/// listener. The server will listen on the TCP port specified in the current configuration.</remarks>
/// <summary>
/// Starts the TCP synchronization server and begins listening for incoming connections asynchronously.
/// </summary>
/// <remarks>
/// If the server is already running, this method returns immediately without starting a new
/// listener. The server will listen on the TCP port specified in the current configuration.
/// </remarks>
/// <returns>A task that represents the asynchronous start operation.</returns> /// <returns>A task that represents the asynchronous start operation.</returns>
public async Task Start() public async Task Start()
{ {
@@ -101,6 +106,7 @@ internal class TcpSyncServer : ISyncServer
_logger.LogWarning("TCP Sync Server already started"); _logger.LogWarning("TCP Sync Server already started");
return; return;
} }
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
} }
@@ -126,31 +132,33 @@ internal class TcpSyncServer : ISyncServer
} }
/// <summary> /// <summary>
/// Stops the listener and cancels any pending operations. /// Stops the listener and cancels any pending operations.
/// </summary> /// </summary>
/// <remarks>After calling this method, the listener will no longer accept new connections or process /// <remarks>
/// requests. This method is safe to call multiple times; subsequent calls have no effect if the listener is already /// After calling this method, the listener will no longer accept new connections or process
/// stopped.</remarks> /// requests. This method is safe to call multiple times; subsequent calls have no effect if the listener is already
/// stopped.
/// </remarks>
/// <returns>A task that represents the asynchronous stop operation.</returns> /// <returns>A task that represents the asynchronous stop operation.</returns>
public async Task Stop() public async Task Stop()
{ {
CancellationTokenSource? ctsToDispose = null; CancellationTokenSource? ctsToDispose = null;
TcpListener? listenerToStop = null; TcpListener? listenerToStop = null;
lock (_startStopLock) lock (_startStopLock)
{ {
if (_cts == null) if (_cts == null)
{ {
_logger.LogWarning("TCP Sync Server already stopped or never started"); _logger.LogWarning("TCP Sync Server already stopped or never started");
return; return;
} }
ctsToDispose = _cts; ctsToDispose = _cts;
listenerToStop = _listener; listenerToStop = _listener;
_cts = null; _cts = null;
_listener = null; _listener = null;
} }
try try
{ {
ctsToDispose.Cancel(); ctsToDispose.Cancel();
@@ -162,32 +170,26 @@ internal class TcpSyncServer : ISyncServer
finally finally
{ {
ctsToDispose.Dispose(); ctsToDispose.Dispose();
} }
listenerToStop?.Stop(); listenerToStop?.Stop();
await Task.CompletedTask; await Task.CompletedTask;
} }
/// <summary> /// <summary>
/// Gets the full local endpoint on which the server is listening. /// Gets the full local endpoint on which the server is listening.
/// </summary> /// </summary>
public IPEndPoint? ListeningEndpoint => _listener?.LocalEndpoint as IPEndPoint; public IPEndPoint? ListeningEndpoint => _listener?.LocalEndpoint as IPEndPoint;
/// <summary>
/// Gets the port on which the server is listening.
/// </summary>
public int? ListeningPort => ListeningEndpoint?.Port;
private async Task ListenAsync(CancellationToken token) private async Task ListenAsync(CancellationToken token)
{ {
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{
try try
{ {
if (_listener == null) break; if (_listener == null) break;
var client = await _listener.AcceptTcpClientAsync(); var client = await _listener.AcceptTcpClientAsync();
if (_activeConnections >= MaxConnections) if (_activeConnections >= MaxConnections)
{ {
_logger.LogWarning("Max connections reached ({Max}). Rejecting client.", MaxConnections); _logger.LogWarning("Max connections reached ({Max}). Rejecting client.", MaxConnections);
@@ -197,7 +199,7 @@ internal class TcpSyncServer : ISyncServer
Interlocked.Increment(ref _activeConnections); Interlocked.Increment(ref _activeConnections);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
@@ -209,46 +211,47 @@ internal class TcpSyncServer : ISyncServer
} }
}, token); }, token);
} }
catch (ObjectDisposedException) { break; } catch (ObjectDisposedException)
{
break;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "TCP Accept Error"); _logger.LogError(ex, "TCP Accept Error");
} }
}
} }
private async Task HandleClientAsync(TcpClient client, CancellationToken token) private async Task HandleClientAsync(TcpClient client, CancellationToken token)
{ {
var remoteEp = client.Client.RemoteEndPoint; var remoteEp = client.Client.RemoteEndPoint;
using var operationContext = LogContext.PushProperty("OperationId", Guid.NewGuid().ToString("N")); using var operationContext = LogContext.PushProperty("OperationId", Guid.NewGuid().ToString("N"));
using var endpointContext = LogContext.PushProperty("RemoteEndpoint", remoteEp?.ToString() ?? "unknown"); using var endpointContext = LogContext.PushProperty("RemoteEndpoint", remoteEp?.ToString() ?? "unknown");
_logger.LogDebug("Client Connected: {Endpoint}", remoteEp); _logger.LogDebug("Client Connected: {Endpoint}", remoteEp);
try try
{ {
using (client) using (client)
using (var stream = client.GetStream()) using (var stream = client.GetStream())
{ {
// CRITICAL for Android: Disable Nagle's algorithm for immediate packet send // CRITICAL for Android: Disable Nagle's algorithm for immediate packet send
client.NoDelay = true; client.NoDelay = true;
// Configure TCP keepalive // Configure TCP keepalive
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// Set stream timeouts // Set stream timeouts
stream.ReadTimeout = ClientOperationTimeoutMs; stream.ReadTimeout = ClientOperationTimeoutMs;
stream.WriteTimeout = ClientOperationTimeoutMs; stream.WriteTimeout = ClientOperationTimeoutMs;
var protocol = new ProtocolHandler(_logger, _telemetry); var protocol = new ProtocolHandler(_logger, _telemetry);
bool useCompression = false; var useCompression = false;
CipherState? cipherState = null; CipherState? cipherState = null;
List<string> remoteInterests = new(); List<string> remoteInterests = new();
// Perform Secure Handshake (if service is available) // Perform Secure Handshake (if service is available)
var config = await _configProvider.GetConfiguration(); var config = await _configProvider.GetConfiguration();
if (_handshakeService != null) if (_handshakeService != null)
{
try try
{ {
// We are NOT initiator // We are NOT initiator
@@ -261,40 +264,39 @@ internal class TcpSyncServer : ISyncServer
_logger.LogError(ex, "Secure Handshake failed check logic"); _logger.LogError(ex, "Secure Handshake failed check logic");
return; return;
} }
}
while (client.Connected && !token.IsCancellationRequested) while (client.Connected && !token.IsCancellationRequested)
{ {
// Re-fetch config if needed, though usually stable // Re-fetch config if needed, though usually stable
config = await _configProvider.GetConfiguration(); config = await _configProvider.GetConfiguration();
var (type, payload) = await protocol.ReadMessageAsync(stream, cipherState, token); (var type, byte[] payload) = await protocol.ReadMessageAsync(stream, cipherState, token);
if (type == MessageType.Unknown) break; // EOF or Error if (type == MessageType.Unknown) break; // EOF or Error
// Handshake Loop // Handshake Loop
if (type == MessageType.HandshakeReq) if (type == MessageType.HandshakeReq)
{ {
var hReq = HandshakeRequest.Parser.ParseFrom(payload); var hReq = HandshakeRequest.Parser.ParseFrom(payload);
_logger.LogDebug("Received HandshakeReq from Node {NodeId}", hReq.NodeId); _logger.LogDebug("Received HandshakeReq from Node {NodeId}", hReq.NodeId);
// Track remote peer interests // Track remote peer interests
remoteInterests = hReq.InterestingCollections.ToList(); remoteInterests = hReq.InterestingCollections.ToList();
bool valid = await _authenticator.ValidateAsync(hReq.NodeId, hReq.AuthToken); bool valid = await _authenticator.ValidateAsync(hReq.NodeId, hReq.AuthToken);
if (!valid) if (!valid)
{ {
_logger.LogWarning("Authentication failed for Node {NodeId}", hReq.NodeId); _logger.LogWarning("Authentication failed for Node {NodeId}", hReq.NodeId);
await protocol.SendMessageAsync(stream, MessageType.HandshakeRes, new HandshakeResponse { NodeId = config.NodeId, Accepted = false }, false, cipherState, token); await protocol.SendMessageAsync(stream, MessageType.HandshakeRes,
new HandshakeResponse { NodeId = config.NodeId, Accepted = false }, false, cipherState,
token);
return; return;
} }
var hRes = new HandshakeResponse { NodeId = config.NodeId, Accepted = true }; var hRes = new HandshakeResponse { NodeId = config.NodeId, Accepted = true };
// Include local interests from IDocumentStore in response for push filtering // Include local interests from IDocumentStore in response for push filtering
foreach (var coll in _documentStore.InterestedCollection) foreach (string coll in _documentStore.InterestedCollection)
{
hRes.InterestingCollections.Add(coll); hRes.InterestingCollections.Add(coll);
}
if (CompressionHelper.IsBrotliSupported && hReq.SupportedCompression.Contains("brotli")) if (CompressionHelper.IsBrotliSupported && hReq.SupportedCompression.Contains("brotli"))
{ {
@@ -302,12 +304,13 @@ internal class TcpSyncServer : ISyncServer
useCompression = true; useCompression = true;
} }
await protocol.SendMessageAsync(stream, MessageType.HandshakeRes, hRes, false, cipherState, token); await protocol.SendMessageAsync(stream, MessageType.HandshakeRes, hRes, false, cipherState,
token);
continue; continue;
} }
IMessage? response = null; IMessage? response = null;
MessageType resType = MessageType.Unknown; var resType = MessageType.Unknown;
switch (type) switch (type)
{ {
@@ -325,7 +328,7 @@ internal class TcpSyncServer : ISyncServer
case MessageType.GetVectorClockReq: case MessageType.GetVectorClockReq:
var vectorClock = await _oplogStore.GetVectorClockAsync(token); var vectorClock = await _oplogStore.GetVectorClockAsync(token);
var vcRes = new VectorClockResponse(); var vcRes = new VectorClockResponse();
foreach (var nodeId in vectorClock.NodeIds) foreach (string nodeId in vectorClock.NodeIds)
{ {
var ts = vectorClock.GetTimestamp(nodeId); var ts = vectorClock.GetTimestamp(nodeId);
vcRes.Entries.Add(new VectorClockEntry vcRes.Entries.Add(new VectorClockEntry
@@ -335,23 +338,23 @@ internal class TcpSyncServer : ISyncServer
HlcLogic = ts.LogicalCounter HlcLogic = ts.LogicalCounter
}); });
} }
response = vcRes; response = vcRes;
resType = MessageType.VectorClockRes; resType = MessageType.VectorClockRes;
break; break;
case MessageType.PullChangesReq: case MessageType.PullChangesReq:
var pReq = PullChangesRequest.Parser.ParseFrom(payload); var pReq = PullChangesRequest.Parser.ParseFrom(payload);
var since = new HlcTimestamp(pReq.SinceWall, pReq.SinceLogic, pReq.SinceNode); var since = new HlcTimestamp(pReq.SinceWall, pReq.SinceLogic, pReq.SinceNode);
// Use collection filter from request // Use collection filter from request
var filter = pReq.Collections.Any() ? pReq.Collections : null; var filter = pReq.Collections.Any() ? pReq.Collections : null;
var oplog = string.IsNullOrWhiteSpace(pReq.SinceNode) var oplog = string.IsNullOrWhiteSpace(pReq.SinceNode)
? await _oplogStore.GetOplogAfterAsync(since, filter, token) ? await _oplogStore.GetOplogAfterAsync(since, filter, token)
: await _oplogStore.GetOplogForNodeAfterAsync(pReq.SinceNode, since, filter, token); : await _oplogStore.GetOplogForNodeAfterAsync(pReq.SinceNode, since, filter, token);
var csRes = new ChangeSetResponse(); var csRes = new ChangeSetResponse();
foreach (var e in oplog) foreach (var e in oplog)
{
csRes.Entries.Add(new ProtoOplogEntry csRes.Entries.Add(new ProtoOplogEntry
{ {
Collection = e.Collection, Collection = e.Collection,
@@ -364,7 +367,6 @@ internal class TcpSyncServer : ISyncServer
Hash = e.Hash, Hash = e.Hash,
PreviousHash = e.PreviousHash PreviousHash = e.PreviousHash
}); });
}
response = csRes; response = csRes;
resType = MessageType.ChangeSetRes; resType = MessageType.ChangeSetRes;
break; break;
@@ -375,10 +377,12 @@ internal class TcpSyncServer : ISyncServer
e.Collection, e.Collection,
e.Key, e.Key,
(OperationType)Enum.Parse(typeof(OperationType), e.Operation), (OperationType)Enum.Parse(typeof(OperationType), e.Operation),
string.IsNullOrEmpty(e.JsonData) ? (System.Text.Json.JsonElement?)null : System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(e.JsonData), string.IsNullOrEmpty(e.JsonData)
? null
: JsonSerializer.Deserialize<JsonElement>(e.JsonData),
new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode), new HlcTimestamp(e.HlcWall, e.HlcLogic, e.HlcNode),
e.PreviousHash, // Restore PreviousHash e.PreviousHash, // Restore PreviousHash
e.Hash // Restore Hash e.Hash // Restore Hash
)); ));
await _oplogStore.ApplyBatchAsync(entries, token); await _oplogStore.ApplyBatchAsync(entries, token);
@@ -389,18 +393,15 @@ internal class TcpSyncServer : ISyncServer
case MessageType.GetChainRangeReq: case MessageType.GetChainRangeReq:
var rangeReq = GetChainRangeRequest.Parser.ParseFrom(payload); var rangeReq = GetChainRangeRequest.Parser.ParseFrom(payload);
var rangeEntries = await _oplogStore.GetChainRangeAsync(rangeReq.StartHash, rangeReq.EndHash, token); var rangeEntries =
var rangeRes = new ChainRangeResponse(); await _oplogStore.GetChainRangeAsync(rangeReq.StartHash, rangeReq.EndHash, token);
var rangeRes = new ChainRangeResponse();
if (!rangeEntries.Any() && rangeReq.StartHash != rangeReq.EndHash) if (!rangeEntries.Any() && rangeReq.StartHash != rangeReq.EndHash)
{
// Gap cannot be filled (likely pruned or unknown branch) // Gap cannot be filled (likely pruned or unknown branch)
rangeRes.SnapshotRequired = true; rangeRes.SnapshotRequired = true;
}
else else
{
foreach (var e in rangeEntries) foreach (var e in rangeEntries)
{
rangeRes.Entries.Add(new ProtoOplogEntry rangeRes.Entries.Add(new ProtoOplogEntry
{ {
Collection = e.Collection, Collection = e.Collection,
@@ -413,52 +414,52 @@ internal class TcpSyncServer : ISyncServer
Hash = e.Hash, Hash = e.Hash,
PreviousHash = e.PreviousHash PreviousHash = e.PreviousHash
}); });
}
}
response = rangeRes; response = rangeRes;
resType = MessageType.ChainRangeRes; resType = MessageType.ChainRangeRes;
break; break;
case MessageType.GetSnapshotReq: case MessageType.GetSnapshotReq:
_logger.LogInformation("Processing GetSnapshotReq from {Endpoint}", remoteEp); _logger.LogInformation("Processing GetSnapshotReq from {Endpoint}", remoteEp);
var tempFile = Path.GetTempFileName(); string tempFile = Path.GetTempFileName();
try try
{ {
// Create backup // Create backup
using (var fs = File.Create(tempFile)) using (var fs = File.Create(tempFile))
{ {
await _snapshotStore.CreateSnapshotAsync(fs, token); await _snapshotStore.CreateSnapshotAsync(fs, token);
} }
using (var fs = File.OpenRead(tempFile)) using (var fs = File.OpenRead(tempFile))
{ {
byte[] buffer = new byte[80 * 1024]; // 80KB chunks var buffer = new byte[80 * 1024]; // 80KB chunks
int bytesRead; int bytesRead;
while ((bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length, token)) > 0) while ((bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length, token)) > 0)
{ {
var chunk = new SnapshotChunk var chunk = new SnapshotChunk
{ {
Data = ByteString.CopyFrom(buffer, 0, bytesRead), Data = ByteString.CopyFrom(buffer, 0, bytesRead),
IsLast = false IsLast = false
}; };
await protocol.SendMessageAsync(stream, MessageType.SnapshotChunkMsg, chunk, false, cipherState, token); await protocol.SendMessageAsync(stream, MessageType.SnapshotChunkMsg, chunk,
} false, cipherState, token);
}
// Send End of Snapshot
await protocol.SendMessageAsync(stream, MessageType.SnapshotChunkMsg, new SnapshotChunk { IsLast = true }, false, cipherState, token); // Send End of Snapshot
await protocol.SendMessageAsync(stream, MessageType.SnapshotChunkMsg,
new SnapshotChunk { IsLast = true }, false, cipherState, token);
} }
} }
finally finally
{ {
if (File.Exists(tempFile)) File.Delete(tempFile); if (File.Exists(tempFile)) File.Delete(tempFile);
} }
break; break;
} }
if (response != null) if (response != null)
{
await protocol.SendMessageAsync(stream, resType, response, useCompression, cipherState, token); await protocol.SendMessageAsync(stream, resType, response, useCompression, cipherState, token);
}
} }
} }
} }
@@ -471,4 +472,4 @@ internal class TcpSyncServer : ISyncServer
_logger.LogDebug("Client Disconnected: {Endpoint}", remoteEp); _logger.LogDebug("Client Disconnected: {Endpoint}", remoteEp);
} }
} }
} }

View File

@@ -1,39 +1,38 @@
using System; using System.Diagnostics;
using System.Diagnostics;
namespace ZB.MOM.WW.CBDDC.Network.Telemetry;
namespace ZB.MOM.WW.CBDDC.Network.Telemetry;
public interface INetworkTelemetryService public interface INetworkTelemetryService
{ {
/// <summary> /// <summary>
/// Records a metric value for the specified metric type. /// Records a metric value for the specified metric type.
/// </summary> /// </summary>
/// <param name="type">The metric type to record.</param> /// <param name="type">The metric type to record.</param>
/// <param name="value">The metric value.</param> /// <param name="value">The metric value.</param>
void RecordValue(MetricType type, double value); void RecordValue(MetricType type, double value);
/// <summary> /// <summary>
/// Starts timing a metric for the specified metric type. /// Starts timing a metric for the specified metric type.
/// </summary> /// </summary>
/// <param name="type">The metric type to time.</param> /// <param name="type">The metric type to time.</param>
/// <returns>A timer that records elapsed time when disposed.</returns> /// <returns>A timer that records elapsed time when disposed.</returns>
MetricTimer StartMetric(MetricType type); MetricTimer StartMetric(MetricType type);
/// <summary> /// <summary>
/// Gets a snapshot of all recorded metric values. /// Gets a snapshot of all recorded metric values.
/// </summary> /// </summary>
/// <returns>A dictionary of metric values grouped by metric type and bucket.</returns> /// <returns>A dictionary of metric values grouped by metric type and bucket.</returns>
System.Collections.Generic.Dictionary<MetricType, System.Collections.Generic.Dictionary<int, double>> GetSnapshot(); Dictionary<MetricType, Dictionary<int, double>> GetSnapshot();
} }
public readonly struct MetricTimer : IDisposable public readonly struct MetricTimer : IDisposable
{ {
private readonly INetworkTelemetryService _service; private readonly INetworkTelemetryService _service;
private readonly MetricType _type; private readonly MetricType _type;
private readonly long _startTimestamp; private readonly long _startTimestamp;
/// <summary> /// <summary>
/// Initializes a new metric timer. /// Initializes a new metric timer.
/// </summary> /// </summary>
/// <param name="service">The telemetry service that receives the recorded value.</param> /// <param name="service">The telemetry service that receives the recorded value.</param>
/// <param name="type">The metric type being timed.</param> /// <param name="type">The metric type being timed.</param>
@@ -45,16 +44,16 @@ public readonly struct MetricTimer : IDisposable
} }
/// <summary> /// <summary>
/// Stops timing and records the elapsed duration. /// Stops timing and records the elapsed duration.
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
var elapsed = Stopwatch.GetTimestamp() - _startTimestamp; long elapsed = Stopwatch.GetTimestamp() - _startTimestamp;
// Convert ticks to milliseconds? Or keep as ticks? // Convert ticks to milliseconds? Or keep as ticks?
// Plan said "latency", usually ms. // Plan said "latency", usually ms.
// Stopwatch.Frequency depends on hardware. // Stopwatch.Frequency depends on hardware.
// Let's store MS representation. // Let's store MS representation.
double ms = (double)elapsed * 1000 / Stopwatch.Frequency; double ms = (double)elapsed * 1000 / Stopwatch.Frequency;
_service.RecordValue(_type, ms); _service.RecordValue(_type, ms);
} }
} }

View File

@@ -6,4 +6,4 @@ public enum MetricType
EncryptionTime = 1, EncryptionTime = 1,
DecryptionTime = 2, DecryptionTime = 2,
RoundTripTime = 3 RoundTripTime = 3
} }

View File

@@ -1,101 +1,99 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.CBDDC.Network.Telemetry; namespace ZB.MOM.WW.CBDDC.Network.Telemetry;
public class NetworkTelemetryService : INetworkTelemetryService, IDisposable public class NetworkTelemetryService : INetworkTelemetryService, IDisposable
{ {
private readonly Channel<(MetricType Type, double Value)> _metricChannel; // Aggregation State
private readonly CancellationTokenSource _cts; // We keep 30m of history with 1s resolution = 1800 buckets.
private readonly ILogger<NetworkTelemetryService> _logger; private const int MaxHistorySeconds = 1800;
private readonly string _persistencePath;
// Aggregation State
// We keep 30m of history with 1s resolution = 1800 buckets.
private const int MaxHistorySeconds = 1800;
private readonly object _lock = new object();
private readonly MetricBucket[] _history;
private int _headIndex = 0; // Points to current second
private long _currentSecondTimestamp; // Unix timestamp of current bucket
// Rolling Averages (Last calculated) // Rolling Averages (Last calculated)
private readonly Dictionary<string, double> _averages = new Dictionary<string, double>(); private readonly Dictionary<string, double> _averages = new();
private readonly CancellationTokenSource _cts;
private readonly MetricBucket[] _history;
/// <summary> private readonly object _lock = new();
/// Initializes a new instance of the <see cref="NetworkTelemetryService"/> class. private readonly ILogger<NetworkTelemetryService> _logger;
/// </summary> private readonly Channel<(MetricType Type, double Value)> _metricChannel;
/// <param name="logger">The logger used to report telemetry processing and persistence errors.</param> private readonly string _persistencePath;
/// <param name="persistencePath">The file path where persisted telemetry snapshots are written.</param> private long _currentSecondTimestamp; // Unix timestamp of current bucket
public NetworkTelemetryService(ILogger<NetworkTelemetryService> logger, string persistencePath) private int _headIndex; // Points to current second
{
/// <summary>
/// Initializes a new instance of the <see cref="NetworkTelemetryService" /> class.
/// </summary>
/// <param name="logger">The logger used to report telemetry processing and persistence errors.</param>
/// <param name="persistencePath">The file path where persisted telemetry snapshots are written.</param>
public NetworkTelemetryService(ILogger<NetworkTelemetryService> logger, string persistencePath)
{
_logger = logger; _logger = logger;
_persistencePath = persistencePath; _persistencePath = persistencePath;
_metricChannel = Channel.CreateUnbounded<(MetricType, double)>(new UnboundedChannelOptions _metricChannel = Channel.CreateUnbounded<(MetricType, double)>(new UnboundedChannelOptions
{ {
SingleReader = true, SingleReader = true,
SingleWriter = false SingleWriter = false
}); });
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
_history = new MetricBucket[MaxHistorySeconds]; _history = new MetricBucket[MaxHistorySeconds];
for (int i = 0; i < MaxHistorySeconds; i++) _history[i] = new MetricBucket(); for (var i = 0; i < MaxHistorySeconds; i++) _history[i] = new MetricBucket();
_currentSecondTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
_currentSecondTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
_ = Task.Run(ProcessMetricsLoop); _ = Task.Run(ProcessMetricsLoop);
_ = Task.Run(PersistenceLoop); _ = Task.Run(PersistenceLoop);
} }
/// <summary> /// <summary>
/// Records a metric value for the specified metric type. /// Releases resources used by the telemetry service.
/// </summary> /// </summary>
/// <param name="type">The metric category to update.</param> public void Dispose()
/// <param name="value">The metric value to record.</param> {
public void RecordValue(MetricType type, double value) _cts.Cancel();
{ _cts.Dispose();
_metricChannel.Writer.TryWrite((type, value)); }
}
/// <summary>
/// <summary> /// Records a metric value for the specified metric type.
/// Starts a timer for the specified metric type. /// </summary>
/// </summary> /// <param name="type">The metric category to update.</param>
/// <param name="type">The metric category to time.</param> /// <param name="value">The metric value to record.</param>
/// <returns>A metric timer that records elapsed time when disposed.</returns> public void RecordValue(MetricType type, double value)
public MetricTimer StartMetric(MetricType type) {
{ _metricChannel.Writer.TryWrite((type, value));
return new MetricTimer(this, type); }
}
/// <summary>
/// <summary> /// Starts a timer for the specified metric type.
/// Gets a point-in-time snapshot of rolling averages for each metric type. /// </summary>
/// </summary> /// <param name="type">The metric category to time.</param>
/// <returns>A dictionary keyed by metric type containing average values by window size in seconds.</returns> /// <returns>A metric timer that records elapsed time when disposed.</returns>
public Dictionary<MetricType, Dictionary<int, double>> GetSnapshot() public MetricTimer StartMetric(MetricType type)
{ {
return new MetricTimer(this, type);
}
/// <summary>
/// Gets a point-in-time snapshot of rolling averages for each metric type.
/// </summary>
/// <returns>A dictionary keyed by metric type containing average values by window size in seconds.</returns>
public Dictionary<MetricType, Dictionary<int, double>> GetSnapshot()
{
var snapshot = new Dictionary<MetricType, Dictionary<int, double>>(); var snapshot = new Dictionary<MetricType, Dictionary<int, double>>();
var windows = new[] { 60, 300, 600, 1800 }; var windows = new[] { 60, 300, 600, 1800 };
lock (_lock) lock (_lock)
{ {
foreach (var type in Enum.GetValues(typeof(MetricType)).Cast<MetricType>()) foreach (var type in Enum.GetValues(typeof(MetricType)).Cast<MetricType>())
{ {
var typeDict = new Dictionary<int, double>(); var typeDict = new Dictionary<int, double>();
foreach (var w in windows) foreach (int w in windows) typeDict[w] = CalculateAverage(type, w);
{
typeDict[w] = CalculateAverage(type, w);
}
snapshot[type] = typeDict; snapshot[type] = typeDict;
} }
} }
return snapshot; return snapshot;
} }
@@ -103,29 +101,26 @@ public class NetworkTelemetryService : INetworkTelemetryService, IDisposable
{ {
var reader = _metricChannel.Reader; var reader = _metricChannel.Reader;
while (!_cts.IsCancellationRequested) while (!_cts.IsCancellationRequested)
{
try try
{ {
if (await reader.WaitToReadAsync(_cts.Token)) if (await reader.WaitToReadAsync(_cts.Token))
{
while (reader.TryRead(out var item)) while (reader.TryRead(out var item))
{
AddMetricToCurrentBucket(item.Type, item.Value); AddMetricToCurrentBucket(item.Type, item.Value);
}
}
} }
catch (OperationCanceledException) { break; } catch (OperationCanceledException)
{
break;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error processing metrics"); _logger.LogError(ex, "Error processing metrics");
} }
}
} }
private void AddMetricToCurrentBucket(MetricType type, double value) private void AddMetricToCurrentBucket(MetricType type, double value)
{ {
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
lock (_lock) lock (_lock)
{ {
// Rotate bucket if second changed // Rotate bucket if second changed
@@ -133,11 +128,12 @@ public class NetworkTelemetryService : INetworkTelemetryService, IDisposable
{ {
long diff = now - _currentSecondTimestamp; long diff = now - _currentSecondTimestamp;
// Move head forward, clearing buckets in between if gap > 1s // Move head forward, clearing buckets in between if gap > 1s
for (int i = 0; i < diff && i < MaxHistorySeconds; i++) for (var i = 0; i < diff && i < MaxHistorySeconds; i++)
{ {
_headIndex = (_headIndex + 1) % MaxHistorySeconds; _headIndex = (_headIndex + 1) % MaxHistorySeconds;
_history[_headIndex].Reset(); _history[_headIndex].Reset();
} }
_currentSecondTimestamp = now; _currentSecondTimestamp = now;
} }
@@ -148,18 +144,19 @@ public class NetworkTelemetryService : INetworkTelemetryService, IDisposable
private async Task PersistenceLoop() private async Task PersistenceLoop()
{ {
while (!_cts.IsCancellationRequested) while (!_cts.IsCancellationRequested)
{
try try
{ {
await Task.Delay(TimeSpan.FromMinutes(1), _cts.Token); await Task.Delay(TimeSpan.FromMinutes(1), _cts.Token);
CalculateAndPersist(); CalculateAndPersist();
} }
catch (OperationCanceledException) { break; } catch (OperationCanceledException)
{
break;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error persisting metrics"); _logger.LogError(ex, "Error persisting metrics");
} }
}
} }
private void CalculateAndPersist() private void CalculateAndPersist()
@@ -167,117 +164,116 @@ public class NetworkTelemetryService : INetworkTelemetryService, IDisposable
lock (_lock) lock (_lock)
{ {
// Calculate averages // Calculate averages
var windows = new[] { 60, 300, 600, 1800 }; // 1m, 5m, 10m, 30m var windows = new[] { 60, 300, 600, 1800 }; // 1m, 5m, 10m, 30m
using var fs = new FileStream(_persistencePath, FileMode.Create, FileAccess.Write); using var fs = new FileStream(_persistencePath, FileMode.Create, FileAccess.Write);
using var bw = new BinaryWriter(fs); using var bw = new BinaryWriter(fs);
// Header // Header
bw.Write((byte)1); // Version bw.Write((byte)1); // Version
bw.Write(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); // Timestamp bw.Write(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); // Timestamp
foreach (var type in Enum.GetValues(typeof(MetricType)).Cast<MetricType>()) foreach (var type in Enum.GetValues(typeof(MetricType)).Cast<MetricType>())
{ {
bw.Write((int)type); bw.Write((int)type);
foreach (var w in windows) foreach (int w in windows)
{ {
double avg = CalculateAverage(type, w); double avg = CalculateAverage(type, w);
bw.Write(w); // Window Seconds bw.Write(w); // Window Seconds
bw.Write(avg); // Average Value bw.Write(avg); // Average Value
} }
} }
} }
} }
/// <summary> /// <summary>
/// Forces immediate calculation and persistence of telemetry data. /// Forces immediate calculation and persistence of telemetry data.
/// </summary> /// </summary>
internal void ForcePersist() internal void ForcePersist()
{ {
CalculateAndPersist(); CalculateAndPersist();
} }
private double CalculateAverage(MetricType type, int seconds) private double CalculateAverage(MetricType type, int seconds)
{ {
// Go backwards from head // Go backwards from head
double sum = 0; double sum = 0;
int count = 0; var count = 0;
int scanned = 0; var scanned = 0;
int idx = _headIndex; int idx = _headIndex;
while (scanned < seconds && scanned < MaxHistorySeconds) while (scanned < seconds && scanned < MaxHistorySeconds)
{ {
var bucket = _history[idx]; var bucket = _history[idx];
sum += bucket.GetSum(type); sum += bucket.GetSum(type);
count += bucket.GetCount(type); count += bucket.GetCount(type);
idx--; idx--;
if (idx < 0) idx = MaxHistorySeconds - 1; if (idx < 0) idx = MaxHistorySeconds - 1;
scanned++; scanned++;
} }
return count == 0 ? 0 : sum / count;
}
/// <summary> return count == 0 ? 0 : sum / count;
/// Releases resources used by the telemetry service.
/// </summary>
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
} }
} }
internal class MetricBucket internal class MetricBucket
{ {
private readonly int[] _counts;
// Simple lock-free or locked accumulation? Global lock handles it for now. // Simple lock-free or locked accumulation? Global lock handles it for now.
// Storing Sum and Count for each type // Storing Sum and Count for each type
private readonly double[] _sums; private readonly double[] _sums;
private readonly int[] _counts;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MetricBucket"/> class. /// Initializes a new instance of the <see cref="MetricBucket" /> class.
/// </summary> /// </summary>
public MetricBucket() public MetricBucket()
{ {
var typeCount = Enum.GetValues(typeof(MetricType)).Length; int typeCount = Enum.GetValues(typeof(MetricType)).Length;
_sums = new double[typeCount]; _sums = new double[typeCount];
_counts = new int[typeCount]; _counts = new int[typeCount];
} }
/// <summary> /// <summary>
/// Clears all accumulated metric sums and counts in this bucket. /// Clears all accumulated metric sums and counts in this bucket.
/// </summary> /// </summary>
public void Reset() public void Reset()
{ {
Array.Clear(_sums, 0, _sums.Length); Array.Clear(_sums, 0, _sums.Length);
Array.Clear(_counts, 0, _counts.Length); Array.Clear(_counts, 0, _counts.Length);
} }
/// <summary> /// <summary>
/// Adds a metric value to the bucket. /// Adds a metric value to the bucket.
/// </summary> /// </summary>
/// <param name="type">The metric category to update.</param> /// <param name="type">The metric category to update.</param>
/// <param name="value">The value to accumulate.</param> /// <param name="value">The value to accumulate.</param>
public void Add(MetricType type, double value) public void Add(MetricType type, double value)
{ {
int idx = (int)type; var idx = (int)type;
_sums[idx] += value; _sums[idx] += value;
_counts[idx]++; _counts[idx]++;
} }
/// <summary> /// <summary>
/// Gets the accumulated sum for a metric type. /// Gets the accumulated sum for a metric type.
/// </summary> /// </summary>
/// <param name="type">The metric category to read.</param> /// <param name="type">The metric category to read.</param>
/// <returns>The accumulated sum for the specified metric type.</returns> /// <returns>The accumulated sum for the specified metric type.</returns>
public double GetSum(MetricType type) => _sums[(int)type]; public double GetSum(MetricType type)
/// <summary> {
/// Gets the accumulated count for a metric type. return _sums[(int)type];
/// </summary> }
/// <param name="type">The metric category to read.</param>
/// <returns>The accumulated sample count for the specified metric type.</returns> /// <summary>
public int GetCount(MetricType type) => _counts[(int)type]; /// Gets the accumulated count for a metric type.
} /// </summary>
/// <param name="type">The metric category to read.</param>
/// <returns>The accumulated sample count for the specified metric type.</returns>
public int GetCount(MetricType type)
{
return _counts[(int)type];
}
}

View File

@@ -1,52 +1,49 @@
using System; using System.Collections.Concurrent;
using System.Collections.Concurrent; using System.Net;
using System.Collections.Generic; using System.Net.Sockets;
using System.Linq; using System.Security.Cryptography;
using System.Net; using System.Text;
using System.Net.Sockets; using System.Text.Json;
using System.Text; using System.Text.Json.Serialization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.CBDDC.Core.Storage;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core.Storage;
namespace ZB.MOM.WW.CBDDC.Network; namespace ZB.MOM.WW.CBDDC.Network;
/// <summary> /// <summary>
/// Provides UDP-based peer discovery for the CBDDC network. /// Provides UDP-based peer discovery for the CBDDC network.
/// Broadcasts presence beacons and listens for other nodes on the local network. /// Broadcasts presence beacons and listens for other nodes on the local network.
/// </summary> /// </summary>
internal class UdpDiscoveryService : IDiscoveryService internal class UdpDiscoveryService : IDiscoveryService
{ {
private const int DiscoveryPort = 25000; private const int DiscoveryPort = 25000;
private readonly ILogger<UdpDiscoveryService> _logger; private readonly ConcurrentDictionary<string, PeerNode> _activePeers = new();
private readonly IPeerNodeConfigurationProvider _configProvider; private readonly IPeerNodeConfigurationProvider _configProvider;
private readonly IDocumentStore _documentStore; private readonly IDocumentStore _documentStore;
private CancellationTokenSource? _cts; private readonly ILogger<UdpDiscoveryService> _logger;
private readonly ConcurrentDictionary<string, PeerNode> _activePeers = new(); private readonly object _startStopLock = new();
private readonly object _startStopLock = new object(); private CancellationTokenSource? _cts;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UdpDiscoveryService"/> class. /// Initializes a new instance of the <see cref="UdpDiscoveryService" /> class.
/// </summary> /// </summary>
/// <param name="peerNodeConfigurationProvider">Provider for peer node configuration.</param> /// <param name="peerNodeConfigurationProvider">Provider for peer node configuration.</param>
/// <param name="documentStore">Document store used to obtain collection interests.</param> /// <param name="documentStore">Document store used to obtain collection interests.</param>
/// <param name="logger">Logger for discovery service events.</param> /// <param name="logger">Logger for discovery service events.</param>
public UdpDiscoveryService( public UdpDiscoveryService(
IPeerNodeConfigurationProvider peerNodeConfigurationProvider, IPeerNodeConfigurationProvider peerNodeConfigurationProvider,
IDocumentStore documentStore, IDocumentStore documentStore,
ILogger<UdpDiscoveryService> logger) ILogger<UdpDiscoveryService> logger)
{ {
_configProvider = peerNodeConfigurationProvider ?? throw new ArgumentNullException(nameof(peerNodeConfigurationProvider)); _configProvider = peerNodeConfigurationProvider ??
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); throw new ArgumentNullException(nameof(peerNodeConfigurationProvider));
_logger = logger; _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
_logger = logger;
} }
/// <summary> /// <summary>
/// Starts the discovery service, initiating listener, broadcaster, and cleanup tasks. /// Starts the discovery service, initiating listener, broadcaster, and cleanup tasks.
/// </summary> /// </summary>
public async Task Start() public async Task Start()
{ {
@@ -57,11 +54,12 @@ internal class UdpDiscoveryService : IDiscoveryService
_logger.LogWarning("UDP Discovery Service already started"); _logger.LogWarning("UDP Discovery Service already started");
return; return;
} }
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
} }
var token = _cts.Token; var token = _cts.Token;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
@@ -72,8 +70,8 @@ internal class UdpDiscoveryService : IDiscoveryService
{ {
_logger.LogError(ex, "UDP Listen task failed"); _logger.LogError(ex, "UDP Listen task failed");
} }
}, token); }, token);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
@@ -84,8 +82,8 @@ internal class UdpDiscoveryService : IDiscoveryService
{ {
_logger.LogError(ex, "UDP Broadcast task failed"); _logger.LogError(ex, "UDP Broadcast task failed");
} }
}, token); }, token);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
@@ -101,75 +99,26 @@ internal class UdpDiscoveryService : IDiscoveryService
await Task.CompletedTask; await Task.CompletedTask;
} }
// ... Stop ... /// <summary>
/// Stops the discovery service.
private async Task CleanupAsync(CancellationToken token) /// </summary>
/// <returns>A task that completes when stop processing has finished.</returns>
public async Task Stop()
{ {
while (!token.IsCancellationRequested) CancellationTokenSource? ctsToDispose = null;
{
try
{
await Task.Delay(10000, token); // Check every 10s
var now = DateTimeOffset.UtcNow;
var expired = new List<string>();
foreach (var pair in _activePeers)
{
// Expiry: 15 seconds (broadcast is every 5s, so 3 missed beats = dead)
if ((now - pair.Value.LastSeen).TotalSeconds > 15)
{
expired.Add(pair.Key);
}
}
foreach (var id in expired)
{
if (_activePeers.TryRemove(id, out var removed))
{
_logger.LogInformation("Peer Expired: {NodeId} at {Endpoint}", removed.NodeId, removed.Address);
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup Loop Error");
}
}
}
// ... Listen ...
private void HandleBeacon(DiscoveryBeacon beacon, IPAddress address)
{
var peerId = beacon.NodeId;
var endpoint = $"{address}:{beacon.TcpPort}";
var peer = new PeerNode(peerId, endpoint, DateTimeOffset.UtcNow, interestingCollections: beacon.InterestingCollections);
_activePeers.AddOrUpdate(peerId, peer, (key, old) => peer);
}
/// <summary>
/// Stops the discovery service.
/// </summary>
/// <returns>A task that completes when stop processing has finished.</returns>
public async Task Stop()
{
CancellationTokenSource? ctsToDispose = null;
lock (_startStopLock) lock (_startStopLock)
{ {
if (_cts == null) if (_cts == null)
{ {
_logger.LogWarning("UDP Discovery Service already stopped or never started"); _logger.LogWarning("UDP Discovery Service already stopped or never started");
return; return;
} }
ctsToDispose = _cts; ctsToDispose = _cts;
_cts = null; _cts = null;
} }
try try
{ {
ctsToDispose.Cancel(); ctsToDispose.Cancel();
@@ -181,16 +130,62 @@ internal class UdpDiscoveryService : IDiscoveryService
finally finally
{ {
ctsToDispose.Dispose(); ctsToDispose.Dispose();
} }
await Task.CompletedTask; await Task.CompletedTask;
} }
/// <summary> /// <summary>
/// Gets the currently active peers discovered on the network. /// Gets the currently active peers discovered on the network.
/// </summary> /// </summary>
/// <returns>The collection of active peers.</returns> /// <returns>The collection of active peers.</returns>
public IEnumerable<PeerNode> GetActivePeers() => _activePeers.Values; public IEnumerable<PeerNode> GetActivePeers()
{
return _activePeers.Values;
}
// ... Stop ...
private async Task CleanupAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
try
{
await Task.Delay(10000, token); // Check every 10s
var now = DateTimeOffset.UtcNow;
var expired = new List<string>();
foreach (var pair in _activePeers)
// Expiry: 15 seconds (broadcast is every 5s, so 3 missed beats = dead)
if ((now - pair.Value.LastSeen).TotalSeconds > 15)
expired.Add(pair.Key);
foreach (string id in expired)
if (_activePeers.TryRemove(id, out var removed))
_logger.LogInformation("Peer Expired: {NodeId} at {Endpoint}", removed.NodeId, removed.Address);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup Loop Error");
}
}
// ... Listen ...
private void HandleBeacon(DiscoveryBeacon beacon, IPAddress address)
{
string peerId = beacon.NodeId;
var endpoint = $"{address}:{beacon.TcpPort}";
var peer = new PeerNode(peerId, endpoint, DateTimeOffset.UtcNow,
interestingCollections: beacon.InterestingCollections);
_activePeers.AddOrUpdate(peerId, peer, (key, old) => peer);
}
private async Task ListenAsync(CancellationToken token) private async Task ListenAsync(CancellationToken token)
{ {
@@ -201,28 +196,25 @@ internal class UdpDiscoveryService : IDiscoveryService
_logger.LogInformation("UDP Discovery Listening on port {Port}", DiscoveryPort); _logger.LogInformation("UDP Discovery Listening on port {Port}", DiscoveryPort);
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{
try try
{ {
var result = await udp.ReceiveAsync(); var result = await udp.ReceiveAsync();
var json = Encoding.UTF8.GetString(result.Buffer); string json = Encoding.UTF8.GetString(result.Buffer);
try try
{ {
var config = await _configProvider.GetConfiguration(); var config = await _configProvider.GetConfiguration();
var _nodeId = config.NodeId; string _nodeId = config.NodeId;
var localClusterHash = ComputeClusterHash(config.AuthToken); string localClusterHash = ComputeClusterHash(config.AuthToken);
var beacon = JsonSerializer.Deserialize<DiscoveryBeacon>(json);
var beacon = JsonSerializer.Deserialize<DiscoveryBeacon>(json);
if (beacon != null && beacon.NodeId != _nodeId) if (beacon != null && beacon.NodeId != _nodeId)
{ {
// Filter by ClusterHash to reduce congestion from different clusters // Filter by ClusterHash to reduce congestion from different clusters
if (!string.Equals(beacon.ClusterHash, localClusterHash, StringComparison.Ordinal)) if (!string.Equals(beacon.ClusterHash, localClusterHash, StringComparison.Ordinal))
{
// Optional: Log trace if needed, but keeping it silent avoids flooding logs during congestion // Optional: Log trace if needed, but keeping it silent avoids flooding logs during congestion
continue; continue;
}
HandleBeacon(beacon, result.RemoteEndPoint.Address); HandleBeacon(beacon, result.RemoteEndPoint.Address);
} }
@@ -232,12 +224,14 @@ internal class UdpDiscoveryService : IDiscoveryService
_logger.LogWarning(ex, "Failed to parse beacon from {Address}", result.RemoteEndPoint.Address); _logger.LogWarning(ex, "Failed to parse beacon from {Address}", result.RemoteEndPoint.Address);
} }
} }
catch (ObjectDisposedException) { break; } catch (ObjectDisposedException)
{
break;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "UDP Listener Error"); _logger.LogError(ex, "UDP Listener Error");
} }
}
} }
private async Task BroadcastAsync(CancellationToken token) private async Task BroadcastAsync(CancellationToken token)
@@ -252,18 +246,18 @@ internal class UdpDiscoveryService : IDiscoveryService
try try
{ {
// Re-fetch config each time in case it changes (though usually static) // Re-fetch config each time in case it changes (though usually static)
var conf = await _configProvider.GetConfiguration(); var conf = await _configProvider.GetConfiguration();
var beacon = new DiscoveryBeacon var beacon = new DiscoveryBeacon
{ {
NodeId = conf.NodeId, NodeId = conf.NodeId,
TcpPort = conf.TcpPort, TcpPort = conf.TcpPort,
ClusterHash = ComputeClusterHash(conf.AuthToken), ClusterHash = ComputeClusterHash(conf.AuthToken),
InterestingCollections = _documentStore.InterestedCollection.ToList() InterestingCollections = _documentStore.InterestedCollection.ToList()
}; };
var json = JsonSerializer.Serialize(beacon); string json = JsonSerializer.Serialize(beacon);
var bytes = Encoding.UTF8.GetBytes(json); byte[] bytes = Encoding.UTF8.GetBytes(json);
await udp.SendAsync(bytes, bytes.Length, endpoint); await udp.SendAsync(bytes, bytes.Length, endpoint);
} }
@@ -279,39 +273,38 @@ internal class UdpDiscoveryService : IDiscoveryService
private string ComputeClusterHash(string authToken) private string ComputeClusterHash(string authToken)
{ {
if (string.IsNullOrEmpty(authToken)) return ""; if (string.IsNullOrEmpty(authToken)) return "";
using var sha256 = System.Security.Cryptography.SHA256.Create(); using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(authToken); byte[] bytes = Encoding.UTF8.GetBytes(authToken);
var hash = sha256.ComputeHash(bytes); byte[] hash = sha256.ComputeHash(bytes);
// Return first 8 chars (4 bytes hex) is enough for filtering // Return first 8 chars (4 bytes hex) is enough for filtering
return BitConverter.ToString(hash).Replace("-", "").Substring(0, 8); return BitConverter.ToString(hash).Replace("-", "").Substring(0, 8);
} }
private class DiscoveryBeacon
{
/// <summary>
/// Gets or sets the broadcasting node identifier.
/// </summary>
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
private class DiscoveryBeacon /// <summary>
{ /// Gets or sets the TCP port used by the broadcasting node.
/// <summary> /// </summary>
/// Gets or sets the broadcasting node identifier. [JsonPropertyName("tcp_port")]
/// </summary> public int TcpPort { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("node_id")]
public string NodeId { get; set; } = ""; /// <summary>
/// Gets or sets the cluster hash used for discovery filtering.
/// <summary> /// </summary>
/// Gets or sets the TCP port used by the broadcasting node. [JsonPropertyName("cluster_hash")]
/// </summary> public string ClusterHash { get; set; } = "";
[System.Text.Json.Serialization.JsonPropertyName("tcp_port")]
public int TcpPort { get; set; } /// <summary>
/// Gets or sets the collections the node is interested in.
/// <summary> /// </summary>
/// Gets or sets the cluster hash used for discovery filtering. [JsonPropertyName("interests")]
/// </summary> public List<string> InterestingCollections { get; set; } = new();
[System.Text.Json.Serialization.JsonPropertyName("cluster_hash")] }
public string ClusterHash { get; set; } = ""; }
/// <summary>
/// Gets or sets the collections the node is interested in.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("interests")]
public List<string> InterestingCollections { get; set; } = new();
}
}

View File

@@ -1,52 +1,52 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.CBDDC.Core\ZB.MOM.WW.CBDDC.Core.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.CBDDC.Core\ZB.MOM.WW.CBDDC.Core.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.25.1" /> <PackageReference Include="Google.Protobuf" Version="3.25.1"/>
<PackageReference Include="Grpc.Tools" Version="2.76.0"> <PackageReference Include="Grpc.Tools" Version="2.76.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0"/>
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Protobuf Include="sync.proto" GrpcServices="None" /> <Protobuf Include="sync.proto" GrpcServices="None"/>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<AssemblyName>ZB.MOM.WW.CBDDC.Network</AssemblyName> <AssemblyName>ZB.MOM.WW.CBDDC.Network</AssemblyName>
<RootNamespace>ZB.MOM.WW.CBDDC.Network</RootNamespace> <RootNamespace>ZB.MOM.WW.CBDDC.Network</RootNamespace>
<PackageId>ZB.MOM.WW.CBDDC.Network</PackageId> <PackageId>ZB.MOM.WW.CBDDC.Network</PackageId>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>1.0.3</Version> <Version>1.0.3</Version>
<Authors>MrDevRobot</Authors> <Authors>MrDevRobot</Authors>
<Description>Networking layer (TCP/UDP/Gossip) for CBDDC.</Description> <Description>Networking layer (TCP/UDP/Gossip) for CBDDC.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>p2p;mesh;network;gossip;lan;udp;tcp;discovery</PackageTags> <PackageTags>p2p;mesh;network;gossip;lan;udp;tcp;discovery</PackageTags>
<PackageProjectUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</PackageProjectUrl> <PackageProjectUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</PackageProjectUrl>
<RepositoryUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</RepositoryUrl> <RepositoryUrl>https://github.com/CBDDC/ZB.MOM.WW.CBDDC.Net</RepositoryUrl>
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" /> <None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.CBDDC.Network.Tests</_Parameter1> <_Parameter1>ZB.MOM.WW.CBDDC.Network.Tests</_Parameter1>
</AssemblyAttribute> </AssemblyAttribute>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,13 +1,13 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Persistence.BLite.Entities; using ZB.MOM.WW.CBDDC.Persistence.BLite.Entities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.CBDDC.Persistence.BLite; namespace ZB.MOM.WW.CBDDC.Persistence.BLite;
/// <summary> /// <summary>
/// BLite implementation of document metadata storage for sync tracking. /// BLite implementation of document metadata storage for sync tracking.
/// </summary> /// </summary>
/// <typeparam name="TDbContext">The type of CBDDCDocumentDbContext.</typeparam> /// <typeparam name="TDbContext">The type of CBDDCDocumentDbContext.</typeparam>
public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore where TDbContext : CBDDCDocumentDbContext public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore where TDbContext : CBDDCDocumentDbContext
@@ -16,18 +16,20 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
private readonly ILogger<BLiteDocumentMetadataStore<TDbContext>> _logger; private readonly ILogger<BLiteDocumentMetadataStore<TDbContext>> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="BLiteDocumentMetadataStore{TDbContext}"/> class. /// Initializes a new instance of the <see cref="BLiteDocumentMetadataStore{TDbContext}" /> class.
/// </summary> /// </summary>
/// <param name="context">The BLite document database context.</param> /// <param name="context">The BLite document database context.</param>
/// <param name="logger">The optional logger instance.</param> /// <param name="logger">The optional logger instance.</param>
public BLiteDocumentMetadataStore(TDbContext context, ILogger<BLiteDocumentMetadataStore<TDbContext>>? logger = null) public BLiteDocumentMetadataStore(TDbContext context,
ILogger<BLiteDocumentMetadataStore<TDbContext>>? logger = null)
{ {
_context = context ?? throw new ArgumentNullException(nameof(context)); _context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<BLiteDocumentMetadataStore<TDbContext>>.Instance; _logger = logger ?? NullLogger<BLiteDocumentMetadataStore<TDbContext>>.Instance;
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<DocumentMetadata?> GetMetadataAsync(string collection, string key, CancellationToken cancellationToken = default) public override async Task<DocumentMetadata?> GetMetadataAsync(string collection, string key,
CancellationToken cancellationToken = default)
{ {
var entity = _context.DocumentMetadatas var entity = _context.DocumentMetadatas
.Find(m => m.Collection == collection && m.Key == key) .Find(m => m.Collection == collection && m.Key == key)
@@ -37,7 +39,8 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<IEnumerable<DocumentMetadata>> GetMetadataByCollectionAsync(string collection, CancellationToken cancellationToken = default) public override async Task<IEnumerable<DocumentMetadata>> GetMetadataByCollectionAsync(string collection,
CancellationToken cancellationToken = default)
{ {
return _context.DocumentMetadatas return _context.DocumentMetadatas
.Find(m => m.Collection == collection) .Find(m => m.Collection == collection)
@@ -46,7 +49,8 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task UpsertMetadataAsync(DocumentMetadata metadata, CancellationToken cancellationToken = default) public override async Task UpsertMetadataAsync(DocumentMetadata metadata,
CancellationToken cancellationToken = default)
{ {
var existing = _context.DocumentMetadatas var existing = _context.DocumentMetadatas
.Find(m => m.Collection == metadata.Collection && m.Key == metadata.Key) .Find(m => m.Collection == metadata.Collection && m.Key == metadata.Key)
@@ -69,7 +73,8 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task UpsertMetadataBatchAsync(IEnumerable<DocumentMetadata> metadatas, CancellationToken cancellationToken = default) public override async Task UpsertMetadataBatchAsync(IEnumerable<DocumentMetadata> metadatas,
CancellationToken cancellationToken = default)
{ {
foreach (var metadata in metadatas) foreach (var metadata in metadatas)
{ {
@@ -95,7 +100,8 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task MarkDeletedAsync(string collection, string key, HlcTimestamp timestamp, CancellationToken cancellationToken = default) public override async Task MarkDeletedAsync(string collection, string key, HlcTimestamp timestamp,
CancellationToken cancellationToken = default)
{ {
var existing = _context.DocumentMetadatas var existing = _context.DocumentMetadatas
.Find(m => m.Collection == collection && m.Key == key) .Find(m => m.Collection == collection && m.Key == key)
@@ -127,11 +133,12 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<IEnumerable<DocumentMetadata>> GetMetadataAfterAsync(HlcTimestamp since, IEnumerable<string>? collections = null, CancellationToken cancellationToken = default) public override async Task<IEnumerable<DocumentMetadata>> GetMetadataAfterAsync(HlcTimestamp since,
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
{ {
var query = _context.DocumentMetadatas.AsQueryable() var query = _context.DocumentMetadatas.AsQueryable()
.Where(m => (m.HlcPhysicalTime > since.PhysicalTime) || .Where(m => m.HlcPhysicalTime > since.PhysicalTime ||
(m.HlcPhysicalTime == since.PhysicalTime && m.HlcLogicalCounter > since.LogicalCounter)); (m.HlcPhysicalTime == since.PhysicalTime && m.HlcLogicalCounter > since.LogicalCounter));
if (collections != null) if (collections != null)
{ {
@@ -161,17 +168,16 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task ImportAsync(IEnumerable<DocumentMetadata> items, CancellationToken cancellationToken = default) public override async Task ImportAsync(IEnumerable<DocumentMetadata> items,
CancellationToken cancellationToken = default)
{ {
foreach (var item in items) foreach (var item in items) await _context.DocumentMetadatas.InsertAsync(ToEntity(item));
{
await _context.DocumentMetadatas.InsertAsync(ToEntity(item));
}
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task MergeAsync(IEnumerable<DocumentMetadata> items, CancellationToken cancellationToken = default) public override async Task MergeAsync(IEnumerable<DocumentMetadata> items,
CancellationToken cancellationToken = default)
{ {
foreach (var item in items) foreach (var item in items)
{ {
@@ -186,7 +192,8 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
else else
{ {
// Update only if incoming is newer // Update only if incoming is newer
var existingTs = new HlcTimestamp(existing.HlcPhysicalTime, existing.HlcLogicalCounter, existing.HlcNodeId); var existingTs = new HlcTimestamp(existing.HlcPhysicalTime, existing.HlcLogicalCounter,
existing.HlcNodeId);
if (item.UpdatedAt.CompareTo(existingTs) > 0) if (item.UpdatedAt.CompareTo(existingTs) > 0)
{ {
existing.HlcPhysicalTime = item.UpdatedAt.PhysicalTime; existing.HlcPhysicalTime = item.UpdatedAt.PhysicalTime;
@@ -197,6 +204,7 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
} }
} }
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
@@ -227,4 +235,4 @@ public class BLiteDocumentMetadataStore<TDbContext> : DocumentMetadataStore wher
} }
#endregion #endregion
} }

View File

@@ -2,7 +2,8 @@
## Overview ## Overview
`BLiteDocumentStore<TDbContext>` is an abstract base class that simplifies creating document stores for CBDDC with BLite persistence. It handles all Oplog management internally, so you only need to implement entity-to-JSON mapping methods. `BLiteDocumentStore<TDbContext>` is an abstract base class that simplifies creating document stores for CBDDC with BLite
persistence. It handles all Oplog management internally, so you only need to implement entity-to-JSON mapping methods.
## Key Features ## Key Features
@@ -11,23 +12,24 @@
- ? **No CDC Events Needed** - Direct Oplog management eliminates event loops - ? **No CDC Events Needed** - Direct Oplog management eliminates event loops
- ? **Simple API** - Only 4 abstract methods to implement - ? **Simple API** - Only 4 abstract methods to implement
## Architecture ## Architecture
``` ```
User Code ? SampleDocumentStore (extends BLiteDocumentStore) User Code ? SampleDocumentStore (extends BLiteDocumentStore)
? ?
BLiteDocumentStore BLiteDocumentStore
??? _context.Users / TodoLists (read/write entities) ??? _context.Users / TodoLists (read/write entities)
??? _context.OplogEntries (write oplog directly) ??? _context.OplogEntries (write oplog directly)
Remote Sync ? OplogStore.ApplyBatchAsync() Remote Sync ? OplogStore.ApplyBatchAsync()
? ?
BLiteDocumentStore.PutDocumentAsync(fromSync=true) BLiteDocumentStore.PutDocumentAsync(fromSync=true)
??? _context.Users / TodoLists (write only) ??? _context.Users / TodoLists (write only)
??? _context.OplogEntries (skip - already exists) ??? _context.OplogEntries (skip - already exists)
``` ```
**Key Advantage**: No circular dependency! `BLiteDocumentStore` writes directly to `CBDDCDocumentDbContext.OplogEntries` collection. **Key Advantage**: No circular dependency! `BLiteDocumentStore` writes directly to `CBDDCDocumentDbContext.OplogEntries`
collection.
## Implementation Example ## Implementation Example
@@ -129,15 +131,15 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
## Usage in Application ## Usage in Application
### Setup (DI Container) ### Setup (DI Container)
```csharp ```csharp
services.AddSingleton<SampleDbContext>(sp => services.AddSingleton<SampleDbContext>(sp =>
new SampleDbContext("data/sample.blite")); new SampleDbContext("data/sample.blite"));
// No OplogStore dependency needed! // No OplogStore dependency needed!
services.AddSingleton<IDocumentStore, SampleDocumentStore>(); services.AddSingleton<IDocumentStore, SampleDocumentStore>();
services.AddSingleton<IOplogStore, BLiteOplogStore<SampleDbContext>>(); services.AddSingleton<IOplogStore, BLiteOplogStore<SampleDbContext>>();
``` ```
### Local Changes (User operations) ### Local Changes (User operations)
@@ -180,6 +182,7 @@ using (documentStore.BeginRemoteSync()) // ? Suppresses Oplog creation
## Migration from Old CDC-based Approach ## Migration from Old CDC-based Approach
### Before (with CDC Events) ### Before (with CDC Events)
```csharp ```csharp
// SampleDocumentStore subscribes to BLite CDC // SampleDocumentStore subscribes to BLite CDC
// CDC emits events ? OplogCoordinator creates Oplog // CDC emits events ? OplogCoordinator creates Oplog
@@ -187,6 +190,7 @@ using (documentStore.BeginRemoteSync()) // ? Suppresses Oplog creation
``` ```
### After (with BLiteDocumentStore) ### After (with BLiteDocumentStore)
```csharp ```csharp
// Direct Oplog management in DocumentStore // Direct Oplog management in DocumentStore
// AsyncLocal flag prevents duplicates during sync // AsyncLocal flag prevents duplicates during sync
@@ -203,6 +207,7 @@ using (documentStore.BeginRemoteSync()) // ? Suppresses Oplog creation
## Next Steps ## Next Steps
After implementing your DocumentStore: After implementing your DocumentStore:
1. Remove CDC subscriptions from your code 1. Remove CDC subscriptions from your code
2. Remove `OplogCoordinator` from DI (no longer needed) 2. Remove `OplogCoordinator` from DI (no longer needed)
3. Test local operations create Oplog entries 3. Test local operations create Oplog entries

View File

@@ -1,56 +1,50 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using BLite.Core.CDC; using BLite.Core.CDC;
using BLite.Core.Collections; using BLite.Core.Collections;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core;
using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Storage;
using ZB.MOM.WW.CBDDC.Core.Sync; using ZB.MOM.WW.CBDDC.Core.Sync;
using ZB.MOM.WW.CBDDC.Persistence.BLite.Entities; using ZB.MOM.WW.CBDDC.Persistence.BLite.Entities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using BLiteOperationType = BLite.Core.Transactions.OperationType; using BLiteOperationType = BLite.Core.Transactions.OperationType;
namespace ZB.MOM.WW.CBDDC.Persistence.BLite; namespace ZB.MOM.WW.CBDDC.Persistence.BLite;
/// <summary> /// <summary>
/// Abstract base class for BLite-based document stores. /// Abstract base class for BLite-based document stores.
/// Handles Oplog creation internally - subclasses only implement entity mapping. /// Handles Oplog creation internally - subclasses only implement entity mapping.
/// </summary> /// </summary>
/// <typeparam name="TDbContext">The BLite DbContext type.</typeparam> /// <typeparam name="TDbContext">The BLite DbContext type.</typeparam>
public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposable public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposable
where TDbContext : CBDDCDocumentDbContext where TDbContext : CBDDCDocumentDbContext
{ {
protected readonly TDbContext _context; private readonly List<IDisposable> _cdcWatchers = new();
private readonly object _clockLock = new();
protected readonly IPeerNodeConfigurationProvider _configProvider; protected readonly IPeerNodeConfigurationProvider _configProvider;
protected readonly IConflictResolver _conflictResolver; protected readonly IConflictResolver _conflictResolver;
protected readonly IVectorClockService _vectorClock; protected readonly TDbContext _context;
protected readonly ILogger<BLiteDocumentStore<TDbContext>> _logger; protected readonly ILogger<BLiteDocumentStore<TDbContext>> _logger;
private readonly HashSet<string> _registeredCollections = new();
/// <summary> /// <summary>
/// Semaphore used to suppress CDC-triggered OplogEntry creation during remote sync. /// Semaphore used to suppress CDC-triggered OplogEntry creation during remote sync.
/// CurrentCount == 0 ? sync in progress, CDC must skip. /// CurrentCount == 0 ? sync in progress, CDC must skip.
/// CurrentCount == 1 ? no sync, CDC creates OplogEntry. /// CurrentCount == 1 ? no sync, CDC creates OplogEntry.
/// </summary> /// </summary>
private readonly SemaphoreSlim _remoteSyncGuard = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _remoteSyncGuard = new(1, 1);
private readonly ConcurrentDictionary<string, int> _suppressedCdcEvents = new(StringComparer.Ordinal);
private readonly List<IDisposable> _cdcWatchers = new(); private readonly ConcurrentDictionary<string, int> _suppressedCdcEvents = new(StringComparer.Ordinal);
private readonly HashSet<string> _registeredCollections = new(); protected readonly IVectorClockService _vectorClock;
// HLC state for generating timestamps for local changes // HLC state for generating timestamps for local changes
private long _lastPhysicalTime; private long _lastPhysicalTime;
private int _logicalCounter; private int _logicalCounter;
private readonly object _clockLock = new object();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="BLiteDocumentStore{TDbContext}"/> class. /// Initializes a new instance of the <see cref="BLiteDocumentStore{TDbContext}" /> class.
/// </summary> /// </summary>
/// <param name="context">The BLite database context.</param> /// <param name="context">The BLite database context.</param>
/// <param name="configProvider">The peer node configuration provider.</param> /// <param name="configProvider">The peer node configuration provider.</param>
@@ -74,17 +68,29 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
_logicalCounter = 0; _logicalCounter = 0;
} }
/// <summary>
/// Releases managed resources used by this document store.
/// </summary>
public virtual void Dispose()
{
foreach (var watcher in _cdcWatchers)
try
{
watcher.Dispose();
}
catch
{
}
_cdcWatchers.Clear();
_remoteSyncGuard.Dispose();
}
private static ILogger<BLiteDocumentStore<TDbContext>> CreateTypedLogger(ILogger? logger) private static ILogger<BLiteDocumentStore<TDbContext>> CreateTypedLogger(ILogger? logger)
{ {
if (logger is null) if (logger is null) return NullLogger<BLiteDocumentStore<TDbContext>>.Instance;
{
return NullLogger<BLiteDocumentStore<TDbContext>>.Instance;
}
if (logger is ILogger<BLiteDocumentStore<TDbContext>> typedLogger) if (logger is ILogger<BLiteDocumentStore<TDbContext>> typedLogger) return typedLogger;
{
return typedLogger;
}
return new ForwardingLogger(logger); return new ForwardingLogger(logger);
} }
@@ -94,7 +100,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
private readonly ILogger _inner; private readonly ILogger _inner;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ForwardingLogger"/> class. /// Initializes a new instance of the <see cref="ForwardingLogger" /> class.
/// </summary> /// </summary>
/// <param name="inner">The underlying logger instance.</param> /// <param name="inner">The underlying logger instance.</param>
public ForwardingLogger(ILogger inner) public ForwardingLogger(ILogger inner)
@@ -135,35 +141,26 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
private void RegisterSuppressedCdcEvent(string collection, string key, OperationType operationType) private void RegisterSuppressedCdcEvent(string collection, string key, OperationType operationType)
{ {
var suppressionKey = BuildSuppressionKey(collection, key, operationType); string suppressionKey = BuildSuppressionKey(collection, key, operationType);
_suppressedCdcEvents.AddOrUpdate(suppressionKey, 1, (_, current) => current + 1); _suppressedCdcEvents.AddOrUpdate(suppressionKey, 1, (_, current) => current + 1);
} }
private bool TryConsumeSuppressedCdcEvent(string collection, string key, OperationType operationType) private bool TryConsumeSuppressedCdcEvent(string collection, string key, OperationType operationType)
{ {
var suppressionKey = BuildSuppressionKey(collection, key, operationType); string suppressionKey = BuildSuppressionKey(collection, key, operationType);
while (true) while (true)
{ {
if (!_suppressedCdcEvents.TryGetValue(suppressionKey, out var current)) if (!_suppressedCdcEvents.TryGetValue(suppressionKey, out int current)) return false;
{
return false;
}
if (current <= 1) if (current <= 1) return _suppressedCdcEvents.TryRemove(suppressionKey, out _);
{
return _suppressedCdcEvents.TryRemove(suppressionKey, out _);
}
if (_suppressedCdcEvents.TryUpdate(suppressionKey, current - 1, current)) if (_suppressedCdcEvents.TryUpdate(suppressionKey, current - 1, current)) return true;
{
return true;
}
} }
} }
/// <summary> /// <summary>
/// Registers a BLite collection for CDC tracking. /// Registers a BLite collection for CDC tracking.
/// Call in subclass constructor for each collection to sync. /// Call in subclass constructor for each collection to sync.
/// </summary> /// </summary>
/// <typeparam name="TEntity">The entity type.</typeparam> /// <typeparam name="TEntity">The entity type.</typeparam>
/// <param name="collectionName">The logical collection name used in Oplog.</param> /// <param name="collectionName">The logical collection name used in Oplog.</param>
@@ -177,14 +174,14 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
_registeredCollections.Add(collectionName); _registeredCollections.Add(collectionName);
var watcher = collection.Watch(capturePayload: true) var watcher = collection.Watch(true)
.Subscribe(new CdcObserver<TEntity>(collectionName, keySelector, this)); .Subscribe(new CdcObserver<TEntity>(collectionName, keySelector, this));
_cdcWatchers.Add(watcher); _cdcWatchers.Add(watcher);
} }
/// <summary> /// <summary>
/// Generic CDC observer. Forwards BLite change events to OnLocalChangeDetectedAsync. /// Generic CDC observer. Forwards BLite change events to OnLocalChangeDetectedAsync.
/// Automatically skips events when remote sync is in progress. /// Automatically skips events when remote sync is in progress.
/// </summary> /// </summary>
private class CdcObserver<TEntity> : IObserver<ChangeStreamEvent<string, TEntity>> private class CdcObserver<TEntity> : IObserver<ChangeStreamEvent<string, TEntity>>
where TEntity : class where TEntity : class
@@ -194,7 +191,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
private readonly BLiteDocumentStore<TDbContext> _store; private readonly BLiteDocumentStore<TDbContext> _store;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CdcObserver{TEntity}"/> class. /// Initializes a new instance of the <see cref="CdcObserver{TEntity}" /> class.
/// </summary> /// </summary>
/// <param name="collectionName">The logical collection name.</param> /// <param name="collectionName">The logical collection name.</param>
/// <param name="keySelector">The key selector for observed entities.</param> /// <param name="keySelector">The key selector for observed entities.</param>
@@ -210,23 +207,20 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
/// <summary> /// <summary>
/// Handles a change stream event from BLite CDC. /// Handles a change stream event from BLite CDC.
/// </summary> /// </summary>
/// <param name="changeEvent">The change event payload.</param> /// <param name="changeEvent">The change event payload.</param>
public void OnNext(ChangeStreamEvent<string, TEntity> changeEvent) public void OnNext(ChangeStreamEvent<string, TEntity> changeEvent)
{ {
var operationType = changeEvent.Type == BLiteOperationType.Delete ? OperationType.Delete : OperationType.Put; var operationType = changeEvent.Type == BLiteOperationType.Delete
? OperationType.Delete
: OperationType.Put;
var entityId = changeEvent.DocumentId?.ToString() ?? ""; string entityId = changeEvent.DocumentId ?? "";
if (operationType == OperationType.Put && changeEvent.Entity != null) if (operationType == OperationType.Put && changeEvent.Entity != null)
{
entityId = _keySelector(changeEvent.Entity); entityId = _keySelector(changeEvent.Entity);
}
if (_store.TryConsumeSuppressedCdcEvent(_collectionName, entityId, operationType)) if (_store.TryConsumeSuppressedCdcEvent(_collectionName, entityId, operationType)) return;
{
return;
}
if (_store._remoteSyncGuard.CurrentCount == 0) return; if (_store._remoteSyncGuard.CurrentCount == 0) return;
@@ -238,22 +232,26 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
else if (changeEvent.Entity != null) else if (changeEvent.Entity != null)
{ {
var content = JsonSerializer.SerializeToElement(changeEvent.Entity); var content = JsonSerializer.SerializeToElement(changeEvent.Entity);
var key = _keySelector(changeEvent.Entity); string key = _keySelector(changeEvent.Entity);
_store.OnLocalChangeDetectedAsync(_collectionName, key, OperationType.Put, content) _store.OnLocalChangeDetectedAsync(_collectionName, key, OperationType.Put, content)
.GetAwaiter().GetResult(); .GetAwaiter().GetResult();
} }
} }
/// <summary> /// <summary>
/// Handles CDC observer errors. /// Handles CDC observer errors.
/// </summary> /// </summary>
/// <param name="error">The observed exception.</param> /// <param name="error">The observed exception.</param>
public void OnError(Exception error) { } public void OnError(Exception error)
{
}
/// <summary> /// <summary>
/// Handles completion of the CDC stream. /// Handles completion of the CDC stream.
/// </summary> /// </summary>
public void OnCompleted() { } public void OnCompleted()
{
}
} }
#endregion #endregion
@@ -261,8 +259,8 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
#region Abstract Methods - Implemented by subclass #region Abstract Methods - Implemented by subclass
/// <summary> /// <summary>
/// Applies JSON content to a single entity (insert or update) and commits changes. /// Applies JSON content to a single entity (insert or update) and commits changes.
/// Called for single-document operations. /// Called for single-document operations.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
@@ -272,16 +270,17 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
string collection, string key, JsonElement content, CancellationToken cancellationToken); string collection, string key, JsonElement content, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Applies JSON content to multiple entities (insert or update) with a single commit. /// Applies JSON content to multiple entities (insert or update) with a single commit.
/// Called for batch operations. Must commit all changes in a single SaveChanges. /// Called for batch operations. Must commit all changes in a single SaveChanges.
/// </summary> /// </summary>
/// <param name="documents">The documents to apply in one batch.</param> /// <param name="documents">The documents to apply in one batch.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
protected abstract Task ApplyContentToEntitiesBatchAsync( protected abstract Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents, CancellationToken cancellationToken); IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Reads an entity from the DbContext and returns it as JsonElement. /// Reads an entity from the DbContext and returns it as JsonElement.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
@@ -290,7 +289,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
string collection, string key, CancellationToken cancellationToken); string collection, string key, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Removes a single entity from the DbContext and commits changes. /// Removes a single entity from the DbContext and commits changes.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
@@ -299,7 +298,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
string collection, string key, CancellationToken cancellationToken); string collection, string key, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Removes multiple entities from the DbContext with a single commit. /// Removes multiple entities from the DbContext with a single commit.
/// </summary> /// </summary>
/// <param name="documents">The documents to remove in one batch.</param> /// <param name="documents">The documents to remove in one batch.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
@@ -307,45 +306,47 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken); IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Reads all entities from a collection as JsonElements. /// Reads all entities from a collection as JsonElements.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
protected abstract Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync( protected abstract Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
string collection, CancellationToken cancellationToken); string collection, CancellationToken cancellationToken);
#endregion #endregion
#region IDocumentStore Implementation #region IDocumentStore Implementation
/// <summary> /// <summary>
/// Returns the collections registered via WatchCollection. /// Returns the collections registered via WatchCollection.
/// </summary> /// </summary>
public IEnumerable<string> InterestedCollection => _registeredCollections; public IEnumerable<string> InterestedCollection => _registeredCollections;
/// <summary> /// <summary>
/// Gets a document by collection and key. /// Gets a document by collection and key.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The matching document, or <see langword="null"/> when not found.</returns> /// <returns>The matching document, or <see langword="null" /> when not found.</returns>
public async Task<Document?> GetDocumentAsync(string collection, string key, CancellationToken cancellationToken = default) public async Task<Document?> GetDocumentAsync(string collection, string key,
CancellationToken cancellationToken = default)
{ {
var content = await GetEntityAsJsonAsync(collection, key, cancellationToken); var content = await GetEntityAsJsonAsync(collection, key, cancellationToken);
if (content == null) return null; if (content == null) return null;
var timestamp = new HlcTimestamp(0, 0, ""); // Will be populated from metadata if needed var timestamp = new HlcTimestamp(0, 0, ""); // Will be populated from metadata if needed
return new Document(collection, key, content.Value, timestamp, false); return new Document(collection, key, content.Value, timestamp, false);
} }
/// <summary> /// <summary>
/// Gets all documents for a collection. /// Gets all documents for a collection.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The documents in the specified collection.</returns> /// <returns>The documents in the specified collection.</returns>
public async Task<IEnumerable<Document>> GetDocumentsByCollectionAsync(string collection, CancellationToken cancellationToken = default) public async Task<IEnumerable<Document>> GetDocumentsByCollectionAsync(string collection,
CancellationToken cancellationToken = default)
{ {
var entities = await GetAllEntitiesAsJsonAsync(collection, cancellationToken); var entities = await GetAllEntitiesAsJsonAsync(collection, cancellationToken);
var timestamp = new HlcTimestamp(0, 0, ""); var timestamp = new HlcTimestamp(0, 0, "");
@@ -353,31 +354,30 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
/// <summary> /// <summary>
/// Gets documents for the specified collection and key pairs. /// Gets documents for the specified collection and key pairs.
/// </summary> /// </summary>
/// <param name="documentKeys">The collection and key pairs to resolve.</param> /// <param name="documentKeys">The collection and key pairs to resolve.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The documents that were found.</returns> /// <returns>The documents that were found.</returns>
public async Task<IEnumerable<Document>> GetDocumentsAsync(List<(string Collection, string Key)> documentKeys, CancellationToken cancellationToken) public async Task<IEnumerable<Document>> GetDocumentsAsync(List<(string Collection, string Key)> documentKeys,
CancellationToken cancellationToken)
{ {
var documents = new List<Document>(); var documents = new List<Document>();
foreach (var (collection, key) in documentKeys) foreach ((string collection, string key) in documentKeys)
{ {
var doc = await GetDocumentAsync(collection, key, cancellationToken); var doc = await GetDocumentAsync(collection, key, cancellationToken);
if (doc != null) if (doc != null) documents.Add(doc);
{
documents.Add(doc);
}
} }
return documents; return documents;
} }
/// <summary> /// <summary>
/// Inserts or updates a single document. /// Inserts or updates a single document.
/// </summary> /// </summary>
/// <param name="document">The document to persist.</param> /// <param name="document">The document to persist.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when the operation succeeds.</returns> /// <returns><see langword="true" /> when the operation succeeds.</returns>
public async Task<bool> PutDocumentAsync(Document document, CancellationToken cancellationToken = default) public async Task<bool> PutDocumentAsync(Document document, CancellationToken cancellationToken = default)
{ {
await _remoteSyncGuard.WaitAsync(cancellationToken); await _remoteSyncGuard.WaitAsync(cancellationToken);
@@ -389,6 +389,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
_remoteSyncGuard.Release(); _remoteSyncGuard.Release();
} }
return true; return true;
} }
@@ -399,21 +400,20 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
/// <summary> /// <summary>
/// Updates a batch of documents. /// Updates a batch of documents.
/// </summary> /// </summary>
/// <param name="documents">The documents to update.</param> /// <param name="documents">The documents to update.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when the operation succeeds.</returns> /// <returns><see langword="true" /> when the operation succeeds.</returns>
public async Task<bool> UpdateBatchDocumentsAsync(IEnumerable<Document> documents, CancellationToken cancellationToken = default) public async Task<bool> UpdateBatchDocumentsAsync(IEnumerable<Document> documents,
CancellationToken cancellationToken = default)
{ {
var documentList = documents.ToList(); var documentList = documents.ToList();
await _remoteSyncGuard.WaitAsync(cancellationToken); await _remoteSyncGuard.WaitAsync(cancellationToken);
try try
{ {
foreach (var document in documentList) foreach (var document in documentList)
{
RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put);
}
await ApplyContentToEntitiesBatchAsync( await ApplyContentToEntitiesBatchAsync(
documentList.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken); documentList.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken);
@@ -422,25 +422,25 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
_remoteSyncGuard.Release(); _remoteSyncGuard.Release();
} }
return true; return true;
} }
/// <summary> /// <summary>
/// Inserts a batch of documents. /// Inserts a batch of documents.
/// </summary> /// </summary>
/// <param name="documents">The documents to insert.</param> /// <param name="documents">The documents to insert.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when the operation succeeds.</returns> /// <returns><see langword="true" /> when the operation succeeds.</returns>
public async Task<bool> InsertBatchDocumentsAsync(IEnumerable<Document> documents, CancellationToken cancellationToken = default) public async Task<bool> InsertBatchDocumentsAsync(IEnumerable<Document> documents,
CancellationToken cancellationToken = default)
{ {
var documentList = documents.ToList(); var documentList = documents.ToList();
await _remoteSyncGuard.WaitAsync(cancellationToken); await _remoteSyncGuard.WaitAsync(cancellationToken);
try try
{ {
foreach (var document in documentList) foreach (var document in documentList)
{
RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put);
}
await ApplyContentToEntitiesBatchAsync( await ApplyContentToEntitiesBatchAsync(
documentList.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken); documentList.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken);
@@ -449,17 +449,19 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
_remoteSyncGuard.Release(); _remoteSyncGuard.Release();
} }
return true; return true;
} }
/// <summary> /// <summary>
/// Deletes a single document. /// Deletes a single document.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when the operation succeeds.</returns> /// <returns><see langword="true" /> when the operation succeeds.</returns>
public async Task<bool> DeleteDocumentAsync(string collection, string key, CancellationToken cancellationToken = default) public async Task<bool> DeleteDocumentAsync(string collection, string key,
CancellationToken cancellationToken = default)
{ {
await _remoteSyncGuard.WaitAsync(cancellationToken); await _remoteSyncGuard.WaitAsync(cancellationToken);
try try
@@ -470,6 +472,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
_remoteSyncGuard.Release(); _remoteSyncGuard.Release();
} }
return true; return true;
} }
@@ -480,25 +483,22 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
/// <summary> /// <summary>
/// Deletes a batch of documents by composite keys. /// Deletes a batch of documents by composite keys.
/// </summary> /// </summary>
/// <param name="documentKeys">The document keys in collection/key format.</param> /// <param name="documentKeys">The document keys in collection/key format.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns><see langword="true"/> when the operation succeeds.</returns> /// <returns><see langword="true" /> when the operation succeeds.</returns>
public async Task<bool> DeleteBatchDocumentsAsync(IEnumerable<string> documentKeys, CancellationToken cancellationToken = default) public async Task<bool> DeleteBatchDocumentsAsync(IEnumerable<string> documentKeys,
CancellationToken cancellationToken = default)
{ {
var parsedKeys = new List<(string Collection, string Key)>(); var parsedKeys = new List<(string Collection, string Key)>();
foreach (var key in documentKeys) foreach (string key in documentKeys)
{ {
var parts = key.Split('/'); string[] parts = key.Split('/');
if (parts.Length == 2) if (parts.Length == 2)
{
parsedKeys.Add((parts[0], parts[1])); parsedKeys.Add((parts[0], parts[1]));
}
else else
{
_logger.LogWarning("Invalid document key format: {Key}", key); _logger.LogWarning("Invalid document key format: {Key}", key);
}
} }
if (parsedKeys.Count == 0) return true; if (parsedKeys.Count == 0) return true;
@@ -506,10 +506,8 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
await _remoteSyncGuard.WaitAsync(cancellationToken); await _remoteSyncGuard.WaitAsync(cancellationToken);
try try
{ {
foreach (var (collection, key) in parsedKeys) foreach ((string collection, string key) in parsedKeys)
{
RegisterSuppressedCdcEvent(collection, key, OperationType.Delete); RegisterSuppressedCdcEvent(collection, key, OperationType.Delete);
}
await RemoveEntitiesBatchAsync(parsedKeys, cancellationToken); await RemoveEntitiesBatchAsync(parsedKeys, cancellationToken);
} }
@@ -517,11 +515,12 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
_remoteSyncGuard.Release(); _remoteSyncGuard.Release();
} }
return true; return true;
} }
/// <summary> /// <summary>
/// Merges an incoming document with the current stored document. /// Merges an incoming document with the current stored document.
/// </summary> /// </summary>
/// <param name="incoming">The incoming document.</param> /// <param name="incoming">The incoming document.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
@@ -553,46 +552,44 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
return existing; return existing;
} }
#endregion #endregion
#region ISnapshotable Implementation #region ISnapshotable Implementation
/// <summary> /// <summary>
/// Removes all tracked documents from registered collections. /// Removes all tracked documents from registered collections.
/// </summary> /// </summary>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
public async Task DropAsync(CancellationToken cancellationToken = default) public async Task DropAsync(CancellationToken cancellationToken = default)
{ {
foreach (var collection in InterestedCollection) foreach (string collection in InterestedCollection)
{ {
var entities = await GetAllEntitiesAsJsonAsync(collection, cancellationToken); var entities = await GetAllEntitiesAsJsonAsync(collection, cancellationToken);
foreach (var (key, _) in entities) foreach ((string key, var _) in entities) await RemoveEntityAsync(collection, key, cancellationToken);
{
await RemoveEntityAsync(collection, key, cancellationToken);
}
} }
} }
/// <summary> /// <summary>
/// Exports all tracked documents from registered collections. /// Exports all tracked documents from registered collections.
/// </summary> /// </summary>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The exported documents.</returns> /// <returns>The exported documents.</returns>
public async Task<IEnumerable<Document>> ExportAsync(CancellationToken cancellationToken = default) public async Task<IEnumerable<Document>> ExportAsync(CancellationToken cancellationToken = default)
{ {
var documents = new List<Document>(); var documents = new List<Document>();
foreach (var collection in InterestedCollection) foreach (string collection in InterestedCollection)
{ {
var collectionDocs = await GetDocumentsByCollectionAsync(collection, cancellationToken); var collectionDocs = await GetDocumentsByCollectionAsync(collection, cancellationToken);
documents.AddRange(collectionDocs); documents.AddRange(collectionDocs);
} }
return documents; return documents;
} }
/// <summary> /// <summary>
/// Imports a batch of documents. /// Imports a batch of documents.
/// </summary> /// </summary>
/// <param name="items">The documents to import.</param> /// <param name="items">The documents to import.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
@@ -603,9 +600,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
try try
{ {
foreach (var document in documents) foreach (var document in documents)
{
RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put);
}
await ApplyContentToEntitiesBatchAsync( await ApplyContentToEntitiesBatchAsync(
documents.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken); documents.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken);
@@ -617,7 +612,7 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
/// <summary> /// <summary>
/// Merges a batch of incoming documents. /// Merges a batch of incoming documents.
/// </summary> /// </summary>
/// <param name="items">The incoming documents.</param> /// <param name="items">The incoming documents.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
@@ -625,32 +620,29 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
{ {
// Acquire guard to prevent Oplog creation during merge // Acquire guard to prevent Oplog creation during merge
await _remoteSyncGuard.WaitAsync(cancellationToken); await _remoteSyncGuard.WaitAsync(cancellationToken);
try try
{ {
foreach (var document in items) foreach (var document in items) await MergeAsync(document, cancellationToken);
{ }
await MergeAsync(document, cancellationToken); finally
} {
} _remoteSyncGuard.Release();
finally }
{ }
_remoteSyncGuard.Release();
} #endregion
}
#region Oplog Management
#endregion
#region Oplog Management
/// <summary>
/// Returns true if a remote sync operation is in progress (guard acquired).
/// CDC listeners should check this before creating OplogEntry.
/// </summary>
protected bool IsRemoteSyncInProgress => _remoteSyncGuard.CurrentCount == 0;
/// <summary> /// <summary>
/// Called by subclass CDC listeners when a local change is detected. /// Returns true if a remote sync operation is in progress (guard acquired).
/// Creates OplogEntry + DocumentMetadata only if no remote sync is in progress. /// CDC listeners should check this before creating OplogEntry.
/// </summary>
protected bool IsRemoteSyncInProgress => _remoteSyncGuard.CurrentCount == 0;
/// <summary>
/// Called by subclass CDC listeners when a local change is detected.
/// Creates OplogEntry + DocumentMetadata only if no remote sync is in progress.
/// </summary> /// </summary>
/// <param name="collection">The logical collection name.</param> /// <param name="collection">The logical collection name.</param>
/// <param name="key">The document key.</param> /// <param name="key">The document key.</param>
@@ -661,90 +653,90 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
string collection, string collection,
string key, string key,
OperationType operationType, OperationType operationType,
JsonElement? content, JsonElement? content,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (IsRemoteSyncInProgress) return; if (IsRemoteSyncInProgress) return;
await CreateOplogEntryAsync(collection, key, operationType, content, cancellationToken);
}
private HlcTimestamp GenerateTimestamp(string nodeId)
{
lock (_clockLock)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now > _lastPhysicalTime) await CreateOplogEntryAsync(collection, key, operationType, content, cancellationToken);
{ }
_lastPhysicalTime = now;
_logicalCounter = 0; private HlcTimestamp GenerateTimestamp(string nodeId)
} {
else lock (_clockLock)
{ {
_logicalCounter++; long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now > _lastPhysicalTime)
{
_lastPhysicalTime = now;
_logicalCounter = 0;
}
else
{
_logicalCounter++;
} }
return new HlcTimestamp(_lastPhysicalTime, _logicalCounter, nodeId); return new HlcTimestamp(_lastPhysicalTime, _logicalCounter, nodeId);
} }
} }
private async Task CreateOplogEntryAsync( private async Task CreateOplogEntryAsync(
string collection, string collection,
string key, string key,
OperationType operationType, OperationType operationType,
JsonElement? content, JsonElement? content,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var config = await _configProvider.GetConfiguration(); var config = await _configProvider.GetConfiguration();
var nodeId = config.NodeId; string nodeId = config.NodeId;
// Get last hash from OplogEntries collection directly // Get last hash from OplogEntries collection directly
var lastEntry = _context.OplogEntries var lastEntry = _context.OplogEntries
.Find(e => e.TimestampNodeId == nodeId) .Find(e => e.TimestampNodeId == nodeId)
.OrderByDescending(e => e.TimestampPhysicalTime) .OrderByDescending(e => e.TimestampPhysicalTime)
.ThenByDescending(e => e.TimestampLogicalCounter) .ThenByDescending(e => e.TimestampLogicalCounter)
.FirstOrDefault(); .FirstOrDefault();
var previousHash = lastEntry?.Hash ?? string.Empty; string previousHash = lastEntry?.Hash ?? string.Empty;
var timestamp = GenerateTimestamp(nodeId); var timestamp = GenerateTimestamp(nodeId);
var oplogEntry = new OplogEntry( var oplogEntry = new OplogEntry(
collection, collection,
key, key,
operationType, operationType,
content, content,
timestamp, timestamp,
previousHash); previousHash);
// Write directly to OplogEntries collection // Write directly to OplogEntries collection
await _context.OplogEntries.InsertAsync(oplogEntry.ToEntity()); await _context.OplogEntries.InsertAsync(oplogEntry.ToEntity());
// Write DocumentMetadata for sync tracking // Write DocumentMetadata for sync tracking
var docMetadata = EntityMappers.CreateDocumentMetadata( var docMetadata = EntityMappers.CreateDocumentMetadata(
collection, collection,
key, key,
timestamp, timestamp,
isDeleted: operationType == OperationType.Delete); operationType == OperationType.Delete);
var existingMetadata = _context.DocumentMetadatas var existingMetadata = _context.DocumentMetadatas
.Find(m => m.Collection == collection && m.Key == key) .Find(m => m.Collection == collection && m.Key == key)
.FirstOrDefault(); .FirstOrDefault();
if (existingMetadata != null) if (existingMetadata != null)
{ {
// Update existing metadata // Update existing metadata
existingMetadata.HlcPhysicalTime = timestamp.PhysicalTime; existingMetadata.HlcPhysicalTime = timestamp.PhysicalTime;
existingMetadata.HlcLogicalCounter = timestamp.LogicalCounter; existingMetadata.HlcLogicalCounter = timestamp.LogicalCounter;
existingMetadata.HlcNodeId = timestamp.NodeId; existingMetadata.HlcNodeId = timestamp.NodeId;
existingMetadata.IsDeleted = operationType == OperationType.Delete; existingMetadata.IsDeleted = operationType == OperationType.Delete;
await _context.DocumentMetadatas.UpdateAsync(existingMetadata); await _context.DocumentMetadatas.UpdateAsync(existingMetadata);
} }
else else
{ {
await _context.DocumentMetadatas.InsertAsync(docMetadata); await _context.DocumentMetadatas.InsertAsync(docMetadata);
} }
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Notify VectorClockService so sync sees local changes // Notify VectorClockService so sync sees local changes
@@ -753,24 +745,24 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
_logger.LogDebug( _logger.LogDebug(
"Created Oplog entry: {Operation} {Collection}/{Key} at {Timestamp} (hash: {Hash})", "Created Oplog entry: {Operation} {Collection}/{Key} at {Timestamp} (hash: {Hash})",
operationType, collection, key, timestamp, oplogEntry.Hash); operationType, collection, key, timestamp, oplogEntry.Hash);
} }
/// <summary> /// <summary>
/// Marks the start of remote sync operations (suppresses CDC-triggered Oplog creation). /// Marks the start of remote sync operations (suppresses CDC-triggered Oplog creation).
/// Use in using statement: using (store.BeginRemoteSync()) { ... } /// Use in using statement: using (store.BeginRemoteSync()) { ... }
/// </summary> /// </summary>
public IDisposable BeginRemoteSync() public IDisposable BeginRemoteSync()
{ {
_remoteSyncGuard.Wait(); _remoteSyncGuard.Wait();
return new RemoteSyncScope(_remoteSyncGuard); return new RemoteSyncScope(_remoteSyncGuard);
} }
private class RemoteSyncScope : IDisposable private class RemoteSyncScope : IDisposable
{ {
private readonly SemaphoreSlim _guard; private readonly SemaphoreSlim _guard;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="RemoteSyncScope"/> class. /// Initializes a new instance of the <see cref="RemoteSyncScope" /> class.
/// </summary> /// </summary>
/// <param name="guard">The semaphore guarding remote sync operations.</param> /// <param name="guard">The semaphore guarding remote sync operations.</param>
public RemoteSyncScope(SemaphoreSlim guard) public RemoteSyncScope(SemaphoreSlim guard)
@@ -779,26 +771,13 @@ public abstract class BLiteDocumentStore<TDbContext> : IDocumentStore, IDisposab
} }
/// <summary> /// <summary>
/// Releases the remote sync guard. /// Releases the remote sync guard.
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
_guard.Release(); _guard.Release();
} }
} }
#endregion #endregion
}
/// <summary>
/// Releases managed resources used by this document store.
/// </summary>
public virtual void Dispose()
{
foreach (var watcher in _cdcWatchers)
{
try { watcher.Dispose(); } catch { }
}
_cdcWatchers.Clear();
_remoteSyncGuard.Dispose();
}
}

Some files were not shown because too many files have changed in this diff Show More