Initial import of the CBDDC codebase with docs and tests. Add a .NET-focused gitignore to keep generated artifacts out of source control.
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
348
samples/ZB.MOM.WW.CBDDC.Sample.Console/ConsoleInteractiveService.cs
Executable file
348
samples/ZB.MOM.WW.CBDDC.Sample.Console/ConsoleInteractiveService.cs
Executable file
@@ -0,0 +1,348 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Cache;
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
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
|
||||
private readonly IDocumentCache _cache;
|
||||
private readonly IOfflineQueue _queue;
|
||||
private readonly ICBDDCHealthCheck _healthCheck;
|
||||
private readonly ISyncStatusTracker _syncTracker;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IPeerNodeConfigurationProvider _configProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConsoleInteractiveService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger used by the interactive service.</param>
|
||||
/// <param name="db">The sample database context.</param>
|
||||
/// <param name="node">The active CBDDC node instance.</param>
|
||||
/// <param name="lifetime">The application lifetime controller.</param>
|
||||
/// <param name="cache">The document cache service.</param>
|
||||
/// <param name="queue">The offline queue service.</param>
|
||||
/// <param name="healthCheck">The health check service.</param>
|
||||
/// <param name="syncTracker">The sync status tracker.</param>
|
||||
/// <param name="serviceProvider">The service provider for resolving optional services.</param>
|
||||
/// <param name="peerNodeConfigurationProvider">The provider for peer node configuration.</param>
|
||||
public ConsoleInteractiveService(
|
||||
ILogger<ConsoleInteractiveService> logger,
|
||||
SampleDbContext db,
|
||||
ICBDDCNode node,
|
||||
IHostApplicationLifetime lifetime,
|
||||
IDocumentCache cache,
|
||||
IOfflineQueue queue,
|
||||
ICBDDCHealthCheck healthCheck,
|
||||
ISyncStatusTracker syncTracker,
|
||||
IServiceProvider serviceProvider,
|
||||
IPeerNodeConfigurationProvider peerNodeConfigurationProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_db = db;
|
||||
_node = node;
|
||||
_lifetime = lifetime;
|
||||
_cache = cache;
|
||||
_queue = queue;
|
||||
_healthCheck = healthCheck;
|
||||
_syncTracker = syncTracker;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configProvider = peerNodeConfigurationProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = await _configProvider.GetConfiguration();
|
||||
|
||||
System.Console.WriteLine($"--- Interactive Console ---");
|
||||
System.Console.WriteLine($"Node ID: {config.NodeId}");
|
||||
PrintHelp();
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Non-blocking read to allow cancellation check
|
||||
if (!System.Console.KeyAvailable)
|
||||
{
|
||||
await Task.Delay(100, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var input = System.Console.ReadLine();
|
||||
if (string.IsNullOrEmpty(input)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
using (LogContext.PushProperty("NodeId", config.NodeId))
|
||||
using (LogContext.PushProperty("Command", input))
|
||||
using (LogContext.PushProperty("OperationId", Guid.NewGuid().ToString("N")))
|
||||
{
|
||||
_logger.LogInformation("Handling interactive command {Command}", input);
|
||||
await HandleInput(input);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (input == "q" || input == "quit")
|
||||
{
|
||||
_lifetime.StopApplication();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PrintHelp()
|
||||
{
|
||||
System.Console.WriteLine("Commands:");
|
||||
System.Console.WriteLine(" [p]ut, [g]et, [d]elete, [f]ind, [l]ist peers, [q]uit");
|
||||
System.Console.WriteLine(" [n]ew (auto), [s]pam (5x), [c]ount, [t]odos");
|
||||
System.Console.WriteLine(" [h]ealth, cac[h]e");
|
||||
System.Console.WriteLine(" [r]esolver [lww|merge], [demo] conflict");
|
||||
}
|
||||
|
||||
private async Task HandleInput(string input)
|
||||
{
|
||||
var config = await _configProvider.GetConfiguration();
|
||||
if (input.StartsWith("n"))
|
||||
{
|
||||
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" } };
|
||||
await _db.Users.InsertAsync(user);
|
||||
await _db.SaveChangesAsync();
|
||||
System.Console.WriteLine($"[+] Created {user.Name} with Id: {user.Id}...");
|
||||
}
|
||||
else if (input.StartsWith("s"))
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
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" } };
|
||||
await _db.Users.InsertAsync(user);
|
||||
System.Console.WriteLine($"[+] Created {user.Name} with Id: {user.Id}...");
|
||||
await Task.Delay(100);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else if (input.StartsWith("c"))
|
||||
{
|
||||
var userCount = _db.Users.FindAll().Count();
|
||||
var todoCount = _db.TodoLists.FindAll().Count();
|
||||
System.Console.WriteLine($"Collection 'Users': {userCount} documents");
|
||||
System.Console.WriteLine($"Collection 'TodoLists': {todoCount} documents");
|
||||
}
|
||||
else if (input.StartsWith("p"))
|
||||
{
|
||||
var alice = new User { 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(bob);
|
||||
await _db.SaveChangesAsync();
|
||||
System.Console.WriteLine($"Put Alice ({alice.Id}) and Bob ({bob.Id})");
|
||||
}
|
||||
else if (input.StartsWith("g"))
|
||||
{
|
||||
System.Console.Write("Enter user Id: ");
|
||||
var id = System.Console.ReadLine();
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
var u = _db.Users.FindById(id);
|
||||
System.Console.WriteLine(u != null ? $"Got: {u.Name}, Age {u.Age}, City: {u.Address?.City}" : "Not found");
|
||||
}
|
||||
}
|
||||
else if (input.StartsWith("d"))
|
||||
{
|
||||
System.Console.Write("Enter user Id to delete: ");
|
||||
var id = System.Console.ReadLine();
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
await _db.Users.DeleteAsync(id);
|
||||
await _db.SaveChangesAsync();
|
||||
System.Console.WriteLine($"Deleted user {id}");
|
||||
}
|
||||
}
|
||||
else if (input.StartsWith("l"))
|
||||
{
|
||||
var peers = _node.Discovery.GetActivePeers();
|
||||
var handshakeSvc = _serviceProvider.GetService<ZB.MOM.WW.CBDDC.Network.Security.IPeerHandshakeService>();
|
||||
var secureIcon = handshakeSvc != null ? "🔒" : "🔓";
|
||||
|
||||
System.Console.WriteLine($"Active Peers ({secureIcon}):");
|
||||
foreach (var p in peers)
|
||||
System.Console.WriteLine($" - {p.NodeId} at {p.Address}");
|
||||
|
||||
if (handshakeSvc != null)
|
||||
System.Console.WriteLine("\nℹ️ Secure mode: Connections use ECDH + AES-256");
|
||||
}
|
||||
else if (input.StartsWith("f"))
|
||||
{
|
||||
System.Console.WriteLine("Query: Age > 28");
|
||||
var results = _db.Users.Find(u => u.Age > 28);
|
||||
foreach (var u in results) System.Console.WriteLine($"Found: {u.Name} ({u.Age})");
|
||||
}
|
||||
else if (input.StartsWith("h"))
|
||||
{
|
||||
var health = await _healthCheck.CheckAsync();
|
||||
var syncStatus = _syncTracker.GetStatus();
|
||||
var handshakeSvc = _serviceProvider.GetService<ZB.MOM.WW.CBDDC.Network.Security.IPeerHandshakeService>();
|
||||
|
||||
System.Console.WriteLine("=== Health Check ===");
|
||||
System.Console.WriteLine($"Database: {(health.DatabaseHealthy ? "✓" : "✗")}");
|
||||
System.Console.WriteLine($"Network: {(health.NetworkHealthy ? "✓" : "✗")}");
|
||||
System.Console.WriteLine($"Security: {(handshakeSvc != null ? "🔒 Encrypted" : "🔓 Plaintext")}");
|
||||
System.Console.WriteLine($"Connected Peers: {health.ConnectedPeers}");
|
||||
System.Console.WriteLine($"Last Sync: {health.LastSyncTime?.ToString("HH:mm:ss") ?? "Never"}");
|
||||
System.Console.WriteLine($"Total Synced: {syncStatus.TotalDocumentsSynced} docs");
|
||||
|
||||
if (health.Errors.Any())
|
||||
{
|
||||
System.Console.WriteLine("Errors:");
|
||||
foreach (var err in health.Errors.Take(3)) System.Console.WriteLine($" - {err}");
|
||||
}
|
||||
}
|
||||
else if (input.StartsWith("ch") || input == "cache")
|
||||
{
|
||||
var stats = _cache.GetStatistics();
|
||||
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"))
|
||||
{
|
||||
var parts = input.Split(' ');
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
var newResolver = parts[1].ToLower() switch
|
||||
{
|
||||
"lww" => (IConflictResolver)new LastWriteWinsConflictResolver(),
|
||||
"merge" => new RecursiveNodeMergeConflictResolver(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (newResolver != null)
|
||||
{
|
||||
// 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($" (Current session continues with previous resolver)");
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.WriteLine("Usage: resolver [lww|merge]");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (input == "demo")
|
||||
{
|
||||
await RunConflictDemo();
|
||||
}
|
||||
else if (input == "todos")
|
||||
{
|
||||
var lists = _db.TodoLists.FindAll();
|
||||
|
||||
System.Console.WriteLine("=== Todo Lists ===");
|
||||
foreach (var list in lists)
|
||||
{
|
||||
System.Console.WriteLine($"📋 {list.Name} ({list.Items.Count} items)");
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
var status = item.Completed ? "✓" : " ";
|
||||
System.Console.WriteLine($" [{status}] {item.Task}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunConflictDemo()
|
||||
{
|
||||
System.Console.WriteLine("\n=== Conflict Resolution Demo ===");
|
||||
System.Console.WriteLine("Simulating concurrent edits to a TodoList...\n");
|
||||
|
||||
// Create initial list
|
||||
var list = new TodoList
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "Shopping List",
|
||||
Items = new List<TodoItem>
|
||||
{
|
||||
new TodoItem { Task = "Buy milk", Completed = false },
|
||||
new TodoItem { Task = "Buy bread", Completed = false }
|
||||
}
|
||||
};
|
||||
|
||||
await _db.TodoLists.InsertAsync(list);
|
||||
await _db.SaveChangesAsync();
|
||||
System.Console.WriteLine($"✓ Created list '{list.Name}' with {list.Items.Count} items");
|
||||
await Task.Delay(100);
|
||||
|
||||
// Simulate Node A edit: Mark item as completed, add new item
|
||||
var listA = _db.TodoLists.FindById(list.Id);
|
||||
if (listA != null)
|
||||
{
|
||||
listA.Items[0].Completed = true; // Mark milk as done
|
||||
listA.Items.Add(new TodoItem { Task = "Buy eggs", Completed = false });
|
||||
await _db.TodoLists.UpdateAsync(listA);
|
||||
await _db.SaveChangesAsync();
|
||||
System.Console.WriteLine("📝 Node A: Marked 'Buy milk' complete, added 'Buy eggs'");
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Simulate Node B edit: Mark different item, add different item
|
||||
var listB = _db.TodoLists.FindById(list.Id);
|
||||
if (listB != null)
|
||||
{
|
||||
listB.Items[1].Completed = true; // Mark bread as done
|
||||
listB.Items.Add(new TodoItem { Task = "Buy cheese", Completed = false });
|
||||
await _db.TodoLists.UpdateAsync(listB);
|
||||
await _db.SaveChangesAsync();
|
||||
System.Console.WriteLine("📝 Node B: Marked 'Buy bread' complete, added 'Buy cheese'");
|
||||
}
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
// Show final merged state
|
||||
var merged = _db.TodoLists.FindById(list.Id);
|
||||
if (merged != null)
|
||||
{
|
||||
System.Console.WriteLine("\n🔀 Merged Result:");
|
||||
System.Console.WriteLine($" List: {merged.Name}");
|
||||
foreach (var item in merged.Items)
|
||||
{
|
||||
var status = item.Completed ? "✓" : " ";
|
||||
System.Console.WriteLine($" [{status}] {item.Task}");
|
||||
}
|
||||
|
||||
var resolver = _serviceProvider.GetRequiredService<IConflictResolver>();
|
||||
var resolverType = resolver.GetType().Name;
|
||||
System.Console.WriteLine($"\nℹ️ Resolution Strategy: {resolverType}");
|
||||
|
||||
if (resolverType.Contains("Recursive"))
|
||||
{
|
||||
System.Console.WriteLine(" → Items merged by 'id', both edits preserved");
|
||||
}
|
||||
else
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
126
samples/ZB.MOM.WW.CBDDC.Sample.Console/Program.cs
Executable file
126
samples/ZB.MOM.WW.CBDDC.Sample.Console/Program.cs
Executable file
@@ -0,0 +1,126 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
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 Serilog;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
// Local User/Address classes removed in favor of Shared project
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// Configuration
|
||||
builder.Configuration.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
|
||||
|
||||
// Logging
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddSerilog((_, loggerConfiguration) =>
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "CBDDC.Sample.Console")
|
||||
.WriteTo.Console());
|
||||
|
||||
var randomPort = new Random().Next(1000, 9999);
|
||||
// Node ID
|
||||
string nodeId = args.Length > 0 ? args[0] : ("node-" + randomPort);
|
||||
int tcpPort = args.Length > 1 ? int.Parse(args[1]) : randomPort;
|
||||
|
||||
|
||||
// Conflict Resolution Strategy (can be switched at runtime via service replacement)
|
||||
var useRecursiveMerge = args.Contains("--merge");
|
||||
if (useRecursiveMerge)
|
||||
{
|
||||
builder.Services.AddSingleton<IConflictResolver, RecursiveNodeMergeConflictResolver>();
|
||||
}
|
||||
|
||||
IPeerNodeConfigurationProvider peerNodeConfigurationProvider = new StaticPeerNodeConfigurationProvider(
|
||||
new PeerNodeConfiguration
|
||||
{
|
||||
NodeId = nodeId,
|
||||
TcpPort = tcpPort,
|
||||
AuthToken = "Test-Cluster-Key",
|
||||
//KnownPeers = builder.Configuration.GetSection("CBDDC:KnownPeers").Get<List<KnownPeerConfiguration>>() ?? new()
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IPeerNodeConfigurationProvider>(peerNodeConfigurationProvider);
|
||||
|
||||
// Database path
|
||||
var dataPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data");
|
||||
Directory.CreateDirectory(dataPath);
|
||||
var databasePath = Path.Combine(dataPath, $"{nodeId}.blite");
|
||||
|
||||
// Register CBDDC Services using Fluent Extensions with BLite, SampleDbContext, and SampleDocumentStore
|
||||
builder.Services.AddCBDDCCore()
|
||||
.AddCBDDCBLite<SampleDbContext, SampleDocumentStore>(sp => new SampleDbContext(databasePath))
|
||||
.AddCBDDCNetwork<StaticPeerNodeConfigurationProvider>(); // useHostedService = true by default
|
||||
|
||||
builder.Services.AddHostedService<ConsoleInteractiveService>(); // Runs the Input Loop
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
System.Console.WriteLine($"? Node {nodeId} initialized on port {tcpPort}");
|
||||
System.Console.WriteLine($"? Database: {databasePath}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
await host.RunAsync();
|
||||
}
|
||||
|
||||
private class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current peer node configuration.
|
||||
/// </summary>
|
||||
public PeerNodeConfiguration Configuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StaticPeerNodeConfigurationProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The initial peer node configuration.</param>
|
||||
public StaticPeerNodeConfigurationProvider(PeerNodeConfiguration configuration)
|
||||
{
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the peer node configuration changes.
|
||||
/// </summary>
|
||||
public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current peer node configuration.
|
||||
/// </summary>
|
||||
/// <returns>A task that returns the current configuration.</returns>
|
||||
public Task<PeerNodeConfiguration> GetConfiguration()
|
||||
{
|
||||
return Task.FromResult(Configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the configuration changed event.
|
||||
/// </summary>
|
||||
/// <param name="newConfig">The new configuration value.</param>
|
||||
protected virtual void OnConfigurationChanged(PeerNodeConfiguration newConfig)
|
||||
{
|
||||
ConfigurationChanged?.Invoke(this, newConfig);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
samples/ZB.MOM.WW.CBDDC.Sample.Console/Properties/launchSettings.json
Executable file
11
samples/ZB.MOM.WW.CBDDC.Sample.Console/Properties/launchSettings.json
Executable file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"profiles": {
|
||||
"WSL": {
|
||||
"commandName": "WSL2",
|
||||
"distributionName": ""
|
||||
},
|
||||
"ZB.MOM.WW.CBDDC.Sample.Console": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
154
samples/ZB.MOM.WW.CBDDC.Sample.Console/README.md
Executable file
154
samples/ZB.MOM.WW.CBDDC.Sample.Console/README.md
Executable file
@@ -0,0 +1,154 @@
|
||||
# CBDDC Sample Console Application
|
||||
|
||||
This sample demonstrates the core features of CBDDC, a distributed peer-to-peer database with automatic synchronization.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### 🔑 Primary Keys & Auto-Generation
|
||||
- Automatic GUID generation for entities
|
||||
- Convention-based key detection (`Id` property)
|
||||
- `[PrimaryKey]` attribute support
|
||||
|
||||
### 🎯 Generic Type-Safe API
|
||||
- `Collection<T>()` for compile-time type safety
|
||||
- Keyless `Put(entity)` with auto-key extraction
|
||||
- IntelliSense-friendly operations
|
||||
|
||||
### 🔍 LINQ Query Support
|
||||
- Expression-based queries
|
||||
- Paging and sorting
|
||||
- Complex predicates (>, >=, ==, !=, nested properties)
|
||||
|
||||
### 🌐 Network Synchronization
|
||||
- UDP peer discovery
|
||||
- TCP synchronization
|
||||
- Automatic conflict resolution (Last-Write-Wins)
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Single Node
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Multi-Node (Peer-to-Peer)
|
||||
|
||||
Terminal 1:
|
||||
```bash
|
||||
dotnet run -- --node-id node1 --tcp-port 5001 --udp-port 6001
|
||||
```
|
||||
|
||||
Terminal 2:
|
||||
```bash
|
||||
dotnet run -- --node-id node2 --tcp-port 5002 --udp-port 6002
|
||||
```
|
||||
|
||||
Terminal 3:
|
||||
```bash
|
||||
dotnet run -- --node-id node3 --tcp-port 5003 --udp-port 6003
|
||||
```
|
||||
|
||||
Changes made on any node will automatically sync to all peers!
|
||||
|
||||
## Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `p` | Put Alice and Bob (auto-generated IDs) |
|
||||
| `g` | Get user by ID (prompts for ID) |
|
||||
| `d` | Delete user by ID (prompts for ID) |
|
||||
| `n` | Create new user with auto-generated ID |
|
||||
| `s` | Spam 5 users with auto-generated IDs |
|
||||
| `c` | Count total documents |
|
||||
| `f` | Demo various Find queries |
|
||||
| `f2` | Demo Find with paging (skip/take) |
|
||||
| `a` | Demo auto-generated primary keys |
|
||||
| `t` | Demo generic typed API |
|
||||
| `l` | List active peers |
|
||||
| `q` | Quit |
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
--- Started node1 on Port 5001 ---
|
||||
Commands: [p]ut, [g]et, [d]elete, [l]ist peers, [q]uit, [f]ind
|
||||
[n]ew (auto-generate), [s]pam (5x auto), [c]ount
|
||||
[a]uto-keys (demo), [t]yped (demo generic API)
|
||||
|
||||
> p
|
||||
Put Alice (Id: 3fa85f64...) and Bob (Id: 7c9e6679...)
|
||||
|
||||
> c
|
||||
Total Documents: 2
|
||||
|
||||
> f
|
||||
Query: Age > 28
|
||||
Found: Alice (30)
|
||||
|
||||
> a
|
||||
=== Auto-Generated Primary Keys Demo ===
|
||||
Created: AutoUser1 with auto-generated Id: 9b2c3d4e...
|
||||
Created: AutoUser2 with auto-generated Id: 1a2b3c4d...
|
||||
Retrieved: AutoUser1 (Age: 25)
|
||||
|
||||
> l
|
||||
Active Peers:
|
||||
- node2 at 127.0.0.1:5002
|
||||
- node3 at 127.0.0.1:5003
|
||||
```
|
||||
|
||||
## Code Highlights
|
||||
|
||||
### Entity Definition
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.CBDDC.Core.Metadata;
|
||||
|
||||
public class User
|
||||
{
|
||||
[PrimaryKey(AutoGenerate = true)]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
[Indexed]
|
||||
public int Age { get; set; }
|
||||
|
||||
public Address? Address { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Using the API
|
||||
|
||||
```csharp
|
||||
// Get typed collection
|
||||
var users = db.Collection<User>();
|
||||
|
||||
// Auto-generates Id
|
||||
var user = new User { Name = "Alice", Age = 30 };
|
||||
await users.Put(user);
|
||||
Console.WriteLine(user.Id); // "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
|
||||
// Retrieve by ID
|
||||
var retrieved = await users.Get(user.Id);
|
||||
|
||||
// Query with LINQ
|
||||
var results = await users.Find(u => u.Age > 30);
|
||||
|
||||
// Paging
|
||||
var page = await users.Find(u => true, skip: 10, take: 5);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Storage**: SQLite with HLC timestamps
|
||||
- **Sync**: TCP for data transfer, UDP for discovery
|
||||
- **Conflict Resolution**: Last-Write-Wins based on Hybrid Logical Clocks
|
||||
- **Serialization**: System.Text.Json
|
||||
|
||||
## Learn More
|
||||
|
||||
- [API Reference](../../docs/api-reference.md)
|
||||
- [Architecture](../../docs/architecture.md)
|
||||
- [Getting Started](../../docs/getting-started.md)
|
||||
55
samples/ZB.MOM.WW.CBDDC.Sample.Console/SampleDbContext.cs
Executable file
55
samples/ZB.MOM.WW.CBDDC.Sample.Console/SampleDbContext.cs
Executable file
@@ -0,0 +1,55 @@
|
||||
using BLite.Core.Collections;
|
||||
using BLite.Core.Metadata;
|
||||
using BLite.Core.Storage;
|
||||
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;
|
||||
|
||||
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!;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SampleDbContext class using the specified database file path.
|
||||
/// </summary>
|
||||
/// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param>
|
||||
public SampleDbContext(string databasePath) : base(databasePath)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SampleDbContext class using the specified database file path and page file
|
||||
/// configuration.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public SampleDbContext(string databasePath, PageFileConfig config) : base(databasePath, config)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
modelBuilder.Entity<User>()
|
||||
.ToCollection("Users")
|
||||
.HasKey(u => u.Id);
|
||||
|
||||
modelBuilder.Entity<TodoList>()
|
||||
.ToCollection("TodoLists")
|
||||
.HasKey(t => t.Id);
|
||||
}
|
||||
}
|
||||
164
samples/ZB.MOM.WW.CBDDC.Sample.Console/SampleDocumentStore.cs
Executable file
164
samples/ZB.MOM.WW.CBDDC.Sample.Console/SampleDocumentStore.cs
Executable file
@@ -0,0 +1,164 @@
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
using ZB.MOM.WW.CBDDC.Core.Storage;
|
||||
using ZB.MOM.WW.CBDDC.Core.Sync;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.BLite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Document store implementation for CBDDC Sample using BLite persistence.
|
||||
/// Extends BLiteDocumentStore to automatically handle Oplog creation via CDC.
|
||||
/// </summary>
|
||||
public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
|
||||
{
|
||||
private const string UsersCollection = "Users";
|
||||
private const string TodoListsCollection = "TodoLists";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SampleDocumentStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The sample database context.</param>
|
||||
/// <param name="configProvider">The peer node configuration provider.</param>
|
||||
/// <param name="vectorClockService">The vector clock service.</param>
|
||||
/// <param name="logger">The optional logger instance.</param>
|
||||
public SampleDocumentStore(
|
||||
SampleDbContext context,
|
||||
IPeerNodeConfigurationProvider configProvider,
|
||||
IVectorClockService vectorClockService,
|
||||
ILogger<SampleDocumentStore>? logger = null)
|
||||
: base(context, configProvider, vectorClockService, new LastWriteWinsConflictResolver(), logger)
|
||||
{
|
||||
// Register CDC watchers for local change detection
|
||||
// InterestedCollection is automatically populated
|
||||
WatchCollection(UsersCollection, context.Users, u => u.Id);
|
||||
WatchCollection(TodoListsCollection, context.TodoLists, t => t.Id);
|
||||
}
|
||||
|
||||
#region Abstract Method Implementations
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ApplyContentToEntityAsync(
|
||||
string collection, string key, JsonElement content, CancellationToken cancellationToken)
|
||||
{
|
||||
UpsertEntity(collection, key, content);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ApplyContentToEntitiesBatchAsync(
|
||||
IEnumerable<(string Collection, string Key, JsonElement Content)> documents, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (collection, key, content) in documents)
|
||||
{
|
||||
UpsertEntity(collection, key, content);
|
||||
}
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void UpsertEntity(string collection, string key, JsonElement content)
|
||||
{
|
||||
switch (collection)
|
||||
{
|
||||
case UsersCollection:
|
||||
var user = content.Deserialize<User>()!;
|
||||
user.Id = key;
|
||||
var existingUser = _context.Users.Find(u => u.Id == key).FirstOrDefault();
|
||||
if (existingUser != null)
|
||||
_context.Users.Update(user);
|
||||
else
|
||||
_context.Users.Insert(user);
|
||||
break;
|
||||
|
||||
case TodoListsCollection:
|
||||
var todoList = content.Deserialize<TodoList>()!;
|
||||
todoList.Id = key;
|
||||
var existingTodoList = _context.TodoLists.Find(t => t.Id == key).FirstOrDefault();
|
||||
if (existingTodoList != null)
|
||||
_context.TodoLists.Update(todoList);
|
||||
else
|
||||
_context.TodoLists.Insert(todoList);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Collection '{collection}' is not supported for sync.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<JsonElement?> GetEntityAsJsonAsync(
|
||||
string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<JsonElement?>(collection switch
|
||||
{
|
||||
UsersCollection => SerializeEntity(_context.Users.Find(u => u.Id == key).FirstOrDefault()),
|
||||
TodoListsCollection => SerializeEntity(_context.TodoLists.Find(t => t.Id == key).FirstOrDefault()),
|
||||
_ => null
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task RemoveEntityAsync(
|
||||
string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
DeleteEntity(collection, key);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task RemoveEntitiesBatchAsync(
|
||||
IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (collection, key) in documents)
|
||||
{
|
||||
DeleteEntity(collection, key);
|
||||
}
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void DeleteEntity(string collection, string key)
|
||||
{
|
||||
switch (collection)
|
||||
{
|
||||
case UsersCollection:
|
||||
_context.Users.Delete(key);
|
||||
break;
|
||||
case TodoListsCollection:
|
||||
_context.TodoLists.Delete(key);
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning("Attempted to remove entity from unsupported collection: {Collection}", collection);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
|
||||
string collection, CancellationToken cancellationToken)
|
||||
{
|
||||
return await Task.Run(() => collection switch
|
||||
{
|
||||
UsersCollection => _context.Users.FindAll()
|
||||
.Select(u => (u.Id, SerializeEntity(u)!.Value)),
|
||||
|
||||
TodoListsCollection => _context.TodoLists.FindAll()
|
||||
.Select(t => (t.Id, SerializeEntity(t)!.Value)),
|
||||
|
||||
_ => Enumerable.Empty<(string, JsonElement)>()
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static JsonElement? SerializeEntity<T>(T? entity) where T : class
|
||||
{
|
||||
if (entity == null) return null;
|
||||
return JsonSerializer.SerializeToElement(entity);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
41
samples/ZB.MOM.WW.CBDDC.Sample.Console/TodoList.cs
Executable file
41
samples/ZB.MOM.WW.CBDDC.Sample.Console/TodoList.cs
Executable file
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
public class TodoList
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the document identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list name.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the todo items in the list.
|
||||
/// </summary>
|
||||
public List<TodoItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class TodoItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the task description.
|
||||
/// </summary>
|
||||
public string Task { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the task is completed.
|
||||
/// </summary>
|
||||
public bool Completed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC creation timestamp.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
35
samples/ZB.MOM.WW.CBDDC.Sample.Console/User.cs
Executable file
35
samples/ZB.MOM.WW.CBDDC.Sample.Console/User.cs
Executable file
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
public class User
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique user identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user age.
|
||||
/// </summary>
|
||||
public int Age { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user address.
|
||||
/// </summary>
|
||||
public Address? Address { get; set; }
|
||||
}
|
||||
|
||||
public class Address
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the city value.
|
||||
/// </summary>
|
||||
public string? City { get; set; }
|
||||
}
|
||||
41
samples/ZB.MOM.WW.CBDDC.Sample.Console/ZB.MOM.WW.CBDDC.Sample.Console.csproj
Executable file
41
samples/ZB.MOM.WW.CBDDC.Sample.Console/ZB.MOM.WW.CBDDC.Sample.Console.csproj
Executable file
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Lifter.Core" Version="1.1.0" />
|
||||
<PackageReference Include="BLite.SourceGenerators" Version="1.3.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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.Persistence\ZB.MOM.WW.CBDDC.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" 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.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>ZB.MOM.WW.CBDDC.Sample.Console</AssemblyName>
|
||||
<RootNamespace>ZB.MOM.WW.CBDDC.Sample.Console</RootNamespace>
|
||||
<PackageId>ZB.MOM.WW.CBDDC.Sample.Console</PackageId>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
51
samples/ZB.MOM.WW.CBDDC.Sample.Console/appsettings.json
Executable file
51
samples/ZB.MOM.WW.CBDDC.Sample.Console/appsettings.json
Executable file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning",
|
||||
"CBDDC": "Information",
|
||||
"ZB.MOM.WW.CBDDC.Network.SyncOrchestrator": "Information",
|
||||
"ZB.MOM.WW.CBDDC.Core.Storage.OplogCoordinator": "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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user