Replace BLite with Surreal embedded persistence
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m21s
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m21s
This commit is contained in:
@@ -3,10 +3,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using System.Text.Json;
|
||||
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.Network;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.BLite;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.Snapshot;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
@@ -16,6 +19,8 @@ internal class Program
|
||||
{
|
||||
private static async Task Main(string[] args)
|
||||
{
|
||||
if (await TryRunMigrationAsync(args)) return;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// Configuration
|
||||
@@ -55,11 +60,20 @@ internal class Program
|
||||
// Database path
|
||||
string dataPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data");
|
||||
Directory.CreateDirectory(dataPath);
|
||||
string databasePath = Path.Combine(dataPath, $"{nodeId}.blite");
|
||||
string databasePath = Path.Combine(dataPath, $"{nodeId}.rocksdb");
|
||||
string surrealDatabase = nodeId.Replace("-", "_", StringComparison.Ordinal);
|
||||
|
||||
// Register CBDDC Services using Fluent Extensions with BLite, SampleDbContext, and SampleDocumentStore
|
||||
// Register CBDDC services with embedded Surreal (RocksDB).
|
||||
builder.Services.AddSingleton<ICBDDCSurrealSchemaInitializer, SampleSurrealSchemaInitializer>();
|
||||
builder.Services.AddSingleton<SampleDbContext>();
|
||||
builder.Services.AddCBDDCCore()
|
||||
.AddCBDDCBLite<SampleDbContext, SampleDocumentStore>(sp => new SampleDbContext(databasePath))
|
||||
.AddCBDDCSurrealEmbedded<SampleDocumentStore>(_ => new CBDDCSurrealEmbeddedOptions
|
||||
{
|
||||
Endpoint = "rocksdb://local",
|
||||
DatabasePath = databasePath,
|
||||
Namespace = "cbddc_sample",
|
||||
Database = surrealDatabase
|
||||
})
|
||||
.AddCBDDCNetwork<StaticPeerNodeConfigurationProvider>(); // useHostedService = true by default
|
||||
|
||||
builder.Services.AddHostedService<ConsoleInteractiveService>(); // Runs the Input Loop
|
||||
@@ -73,6 +87,107 @@ internal class Program
|
||||
await host.RunAsync();
|
||||
}
|
||||
|
||||
private static async Task<bool> TryRunMigrationAsync(string[] args)
|
||||
{
|
||||
int migrateIndex = Array.IndexOf(args, "--migrate-snapshot");
|
||||
if (migrateIndex < 0) return false;
|
||||
|
||||
string snapshotPath = GetRequiredArgumentValue(args, migrateIndex, "--migrate-snapshot");
|
||||
if (!File.Exists(snapshotPath))
|
||||
throw new FileNotFoundException("Snapshot file not found.", snapshotPath);
|
||||
|
||||
string targetPath = GetOptionalArgumentValue(args, "--target-db")
|
||||
?? Path.Combine(Directory.GetCurrentDirectory(), "data", "migration.rocksdb");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(targetPath))!);
|
||||
|
||||
string nodeId = "migration-node";
|
||||
var configProvider = new StaticPeerNodeConfigurationProvider(new PeerNodeConfiguration
|
||||
{
|
||||
NodeId = nodeId,
|
||||
TcpPort = 0,
|
||||
AuthToken = "migration"
|
||||
});
|
||||
|
||||
string databaseName = $"migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IPeerNodeConfigurationProvider>(configProvider);
|
||||
services.AddSingleton<ICBDDCSurrealSchemaInitializer, SampleSurrealSchemaInitializer>();
|
||||
services.AddSingleton<SampleDbContext>();
|
||||
services.AddCBDDCCore()
|
||||
.AddCBDDCSurrealEmbedded<SampleDocumentStore>(_ => new CBDDCSurrealEmbeddedOptions
|
||||
{
|
||||
Endpoint = "rocksdb://local",
|
||||
DatabasePath = targetPath,
|
||||
Namespace = "cbddc_migration",
|
||||
Database = databaseName
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var snapshotService = provider.GetRequiredService<ISnapshotService>();
|
||||
await using (var snapshotStream = File.OpenRead(snapshotPath))
|
||||
{
|
||||
await snapshotService.ReplaceDatabaseAsync(snapshotStream);
|
||||
}
|
||||
|
||||
await VerifyMigrationAsync(provider, snapshotPath);
|
||||
System.Console.WriteLine($"Migration completed successfully to: {targetPath}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task VerifyMigrationAsync(IServiceProvider provider, string snapshotPath)
|
||||
{
|
||||
await using var snapshotStream = File.OpenRead(snapshotPath);
|
||||
var source = await JsonSerializer.DeserializeAsync<SnapshotDto>(snapshotStream)
|
||||
?? throw new InvalidOperationException("Unable to deserialize source snapshot.");
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var oplogStore = provider.GetRequiredService<IOplogStore>();
|
||||
var peerStore = provider.GetRequiredService<IPeerConfigurationStore>();
|
||||
var confirmationStore = provider.GetService<IPeerOplogConfirmationStore>();
|
||||
|
||||
int destinationDocuments = (await documentStore.ExportAsync()).Count();
|
||||
int destinationOplog = (await oplogStore.ExportAsync()).Count();
|
||||
int destinationPeers = (await peerStore.ExportAsync()).Count();
|
||||
int destinationConfirmations = confirmationStore == null
|
||||
? 0
|
||||
: (await confirmationStore.ExportAsync()).Count();
|
||||
|
||||
if (destinationDocuments != source.Documents.Count ||
|
||||
destinationOplog != source.Oplog.Count ||
|
||||
destinationPeers != source.RemotePeers.Count ||
|
||||
destinationConfirmations != source.PeerConfirmations.Count)
|
||||
throw new InvalidOperationException("Snapshot parity verification failed after migration.");
|
||||
|
||||
if (source.Oplog.Count > 0)
|
||||
{
|
||||
string firstHash = source.Oplog[0].Hash;
|
||||
string lastHash = source.Oplog[^1].Hash;
|
||||
|
||||
var firstEntry = await oplogStore.GetEntryByHashAsync(firstHash);
|
||||
var lastEntry = await oplogStore.GetEntryByHashAsync(lastHash);
|
||||
if (firstEntry == null || lastEntry == null)
|
||||
throw new InvalidOperationException("Oplog hash spot-check failed after migration.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRequiredArgumentValue(string[] args, int optionIndex, string optionName)
|
||||
{
|
||||
if (optionIndex < 0 || optionIndex + 1 >= args.Length || args[optionIndex + 1].StartsWith("--"))
|
||||
throw new ArgumentException($"Missing value for {optionName}.");
|
||||
|
||||
return args[optionIndex + 1];
|
||||
}
|
||||
|
||||
private static string? GetOptionalArgumentValue(string[] args, string optionName)
|
||||
{
|
||||
int index = Array.IndexOf(args, optionName);
|
||||
if (index < 0) return null;
|
||||
if (index + 1 >= args.Length || args[index + 1].StartsWith("--"))
|
||||
throw new ArgumentException($"Missing value for {optionName}.");
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
private class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider
|
||||
{
|
||||
/// <summary>
|
||||
@@ -112,4 +227,4 @@ internal class Program
|
||||
ConfigurationChanged?.Invoke(this, newConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,15 @@ Terminal 3:
|
||||
dotnet run -- --node-id node3 --tcp-port 5003 --udp-port 6003
|
||||
```
|
||||
|
||||
Changes made on any node will automatically sync to all peers!
|
||||
Changes made on any node will automatically sync to all peers!
|
||||
|
||||
### Import Snapshot Into Surreal (Migration Utility)
|
||||
|
||||
```bash
|
||||
dotnet run -- --migrate-snapshot /path/to/snapshot.json --target-db /path/to/data.rocksdb
|
||||
```
|
||||
|
||||
This imports a CBDDC snapshot into embedded Surreal RocksDB and validates parity (counts plus oplog hash spot checks).
|
||||
|
||||
## Available Commands
|
||||
|
||||
@@ -149,7 +157,7 @@ var page = await users.Find(u => true, skip: 10, take: 5);
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Storage**: SQLite with HLC timestamps
|
||||
- **Storage**: Surreal embedded RocksDB 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
|
||||
|
||||
@@ -1,50 +1,299 @@
|
||||
using BLite.Core.Collections;
|
||||
using BLite.Core.Metadata;
|
||||
using BLite.Core.Storage;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.BLite;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
|
||||
public class SampleDbContext : CBDDCDocumentDbContext
|
||||
public class SampleDbContext : IDisposable
|
||||
{
|
||||
/// <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)
|
||||
private const string UsersTable = "sample_users";
|
||||
private const string TodoListsTable = "sample_todo_lists";
|
||||
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
public SampleDbContext(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer)
|
||||
{
|
||||
SurrealEmbeddedClient = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
SchemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
|
||||
Users = new SampleSurrealCollection<User>(UsersTable, u => u.Id, SurrealEmbeddedClient, SchemaInitializer);
|
||||
TodoLists = new SampleSurrealCollection<TodoList>(TodoListsTable, t => t.Id, SurrealEmbeddedClient, SchemaInitializer);
|
||||
OplogEntries = new SampleSurrealReadOnlyCollection<SampleOplogEntry>(
|
||||
CBDDCSurrealSchemaNames.OplogEntriesTable,
|
||||
SurrealEmbeddedClient,
|
||||
SchemaInitializer);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
public SampleDbContext(string databasePath)
|
||||
{
|
||||
string normalizedPath = NormalizeDatabasePath(databasePath);
|
||||
string suffix = ComputeDeterministicSuffix(normalizedPath);
|
||||
var options = new CBDDCSurrealEmbeddedOptions
|
||||
{
|
||||
Endpoint = "rocksdb://local",
|
||||
DatabasePath = normalizedPath,
|
||||
Namespace = $"cbddc_sample_{suffix}",
|
||||
Database = $"main_{suffix}"
|
||||
};
|
||||
|
||||
SurrealEmbeddedClient = new CBDDCSurrealEmbeddedClient(options);
|
||||
|
||||
_ownsClient = true;
|
||||
SchemaInitializer = new SampleSurrealSchemaInitializer(SurrealEmbeddedClient);
|
||||
|
||||
Users = new SampleSurrealCollection<User>(UsersTable, u => u.Id, SurrealEmbeddedClient, SchemaInitializer);
|
||||
TodoLists = new SampleSurrealCollection<TodoList>(TodoListsTable, t => t.Id, SurrealEmbeddedClient, SchemaInitializer);
|
||||
OplogEntries = new SampleSurrealReadOnlyCollection<SampleOplogEntry>(
|
||||
CBDDCSurrealSchemaNames.OplogEntriesTable,
|
||||
SurrealEmbeddedClient,
|
||||
SchemaInitializer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the users collection.
|
||||
/// </summary>
|
||||
public DocumentCollection<string, User> Users { get; private set; } = null!;
|
||||
public ICBDDCSurrealEmbeddedClient SurrealEmbeddedClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the todo lists collection.
|
||||
/// </summary>
|
||||
public DocumentCollection<string, TodoList> TodoLists { get; private set; } = null!;
|
||||
public ICBDDCSurrealSchemaInitializer SchemaInitializer { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
public SampleSurrealCollection<User> Users { get; private set; }
|
||||
|
||||
public SampleSurrealCollection<TodoList> TodoLists { get; private set; }
|
||||
|
||||
public SampleSurrealReadOnlyCollection<SampleOplogEntry> OplogEntries { get; private set; }
|
||||
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
modelBuilder.Entity<User>()
|
||||
.ToCollection("Users")
|
||||
.HasKey(u => u.Id);
|
||||
|
||||
modelBuilder.Entity<TodoList>()
|
||||
.ToCollection("TodoLists")
|
||||
.HasKey(t => t.Id);
|
||||
await SchemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Users.Dispose();
|
||||
TodoLists.Dispose();
|
||||
|
||||
if (_ownsClient) SurrealEmbeddedClient.Dispose();
|
||||
}
|
||||
|
||||
private static string NormalizeDatabasePath(string databasePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(databasePath))
|
||||
throw new ArgumentException("Database path is required.", nameof(databasePath));
|
||||
|
||||
return Path.GetFullPath(databasePath);
|
||||
}
|
||||
|
||||
private static string ComputeDeterministicSuffix(string value)
|
||||
{
|
||||
byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant()[..12];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SampleSurrealSchemaInitializer : ICBDDCSurrealSchemaInitializer
|
||||
{
|
||||
private const string SampleSchemaSql = """
|
||||
DEFINE TABLE OVERWRITE sample_users SCHEMALESS CHANGEFEED 7d;
|
||||
DEFINE TABLE OVERWRITE sample_todo_lists SCHEMALESS CHANGEFEED 7d;
|
||||
""";
|
||||
private readonly ICBDDCSurrealEmbeddedClient _client;
|
||||
private int _initialized;
|
||||
|
||||
public SampleSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Volatile.Read(ref _initialized) == 1) return;
|
||||
await _client.InitializeAsync(cancellationToken);
|
||||
await _client.RawQueryAsync(SampleSchemaSql, cancellationToken: cancellationToken);
|
||||
Volatile.Write(ref _initialized, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SampleSurrealCollection<TEntity> : ISurrealWatchableCollection<TEntity>, IDisposable
|
||||
where TEntity : class
|
||||
{
|
||||
private readonly SurrealCollectionChangeFeed<TEntity> _changeFeed = new();
|
||||
private readonly ISurrealDbClient _client;
|
||||
private readonly Func<TEntity, string> _keySelector;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly string _tableName;
|
||||
|
||||
public SampleSurrealCollection(
|
||||
string tableName,
|
||||
Func<TEntity, string> keySelector,
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tableName))
|
||||
throw new ArgumentException("Table name is required.", nameof(tableName));
|
||||
|
||||
_tableName = tableName;
|
||||
_keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector));
|
||||
_client = (surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient))).Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
}
|
||||
|
||||
public IDisposable Subscribe(IObserver<SurrealCollectionChange<TEntity>> observer)
|
||||
{
|
||||
return _changeFeed.Subscribe(observer);
|
||||
}
|
||||
|
||||
public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await UpsertAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await UpsertAsync(entity, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Document id is required.", nameof(id));
|
||||
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _client.Delete(RecordId.From(_tableName, id), cancellationToken);
|
||||
_changeFeed.PublishDelete(id);
|
||||
}
|
||||
|
||||
public TEntity? FindById(string id)
|
||||
{
|
||||
return FindByIdAsync(id).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<TEntity?> FindByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Document id is required.", nameof(id));
|
||||
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var record = await _client.Select<SampleEntityRecord<TEntity>>(RecordId.From(_tableName, id), cancellationToken);
|
||||
return record?.Entity;
|
||||
}
|
||||
|
||||
public IEnumerable<TEntity> FindAll()
|
||||
{
|
||||
return FindAllAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var rows = await _client.Select<SampleEntityRecord<TEntity>>(_tableName, cancellationToken);
|
||||
return rows?
|
||||
.Where(r => r.Entity != null)
|
||||
.Select(r => r.Entity!)
|
||||
.ToList()
|
||||
?? [];
|
||||
}
|
||||
|
||||
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
return FindAll().Where(predicate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_changeFeed.Dispose();
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(TEntity entity, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
string key = _keySelector(entity);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new InvalidOperationException("Entity key cannot be null or empty.");
|
||||
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _client.Upsert<SampleEntityRecord<TEntity>, SampleEntityRecord<TEntity>>(
|
||||
RecordId.From(_tableName, key),
|
||||
new SampleEntityRecord<TEntity> { Entity = entity },
|
||||
cancellationToken);
|
||||
_changeFeed.PublishPut(entity, key);
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SampleSurrealReadOnlyCollection<TEntity>
|
||||
where TEntity : class
|
||||
{
|
||||
private readonly ISurrealDbClient _client;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly string _tableName;
|
||||
|
||||
public SampleSurrealReadOnlyCollection(
|
||||
string tableName,
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tableName))
|
||||
throw new ArgumentException("Table name is required.", nameof(tableName));
|
||||
|
||||
_tableName = tableName;
|
||||
_client = (surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient))).Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
}
|
||||
|
||||
public IEnumerable<TEntity> FindAll()
|
||||
{
|
||||
return FindAllAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
var rows = await _client.Select<TEntity>(_tableName, cancellationToken);
|
||||
return rows?.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
return FindAll().Where(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SampleEntityRecord<TEntity> : Record
|
||||
where TEntity : class
|
||||
{
|
||||
[JsonPropertyName("entity")]
|
||||
public TEntity? Entity { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SampleOplogEntry : Record
|
||||
{
|
||||
[JsonPropertyName("collection")]
|
||||
public string Collection { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("operation")]
|
||||
public int Operation { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampNodeId")]
|
||||
public string TimestampNodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("timestampPhysicalTime")]
|
||||
public long TimestampPhysicalTime { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampLogicalCounter")]
|
||||
public int TimestampLogicalCounter { get; set; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -3,90 +3,125 @@ using Microsoft.Extensions.Logging;
|
||||
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 ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
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.
|
||||
/// Surreal-backed document store for the sample app.
|
||||
/// </summary>
|
||||
public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
|
||||
public class SampleDocumentStore : SurrealDocumentStore<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)
|
||||
: base(
|
||||
context,
|
||||
context.SurrealEmbeddedClient,
|
||||
context.SchemaInitializer,
|
||||
configProvider,
|
||||
vectorClockService,
|
||||
new LastWriteWinsConflictResolver(),
|
||||
null,
|
||||
null,
|
||||
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 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
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ApplyContentToEntityAsync(
|
||||
string collection, string key, JsonElement content, CancellationToken cancellationToken)
|
||||
string collection,
|
||||
string key,
|
||||
JsonElement content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UpsertEntity(collection, key, content);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await UpsertEntityAsync(collection, key, content, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ApplyContentToEntitiesBatchAsync(
|
||||
IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach ((string collection, string key, var content) in documents) UpsertEntity(collection, key, content);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
foreach ((string collection, string key, var content) in documents)
|
||||
await UpsertEntityAsync(collection, key, content, cancellationToken);
|
||||
}
|
||||
|
||||
private void UpsertEntity(string collection, string key, JsonElement content)
|
||||
protected override async Task<JsonElement?> GetEntityAsJsonAsync(
|
||||
string collection,
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return collection switch
|
||||
{
|
||||
UsersCollection => SerializeEntity(await _context.Users.FindByIdAsync(key, cancellationToken)),
|
||||
TodoListsCollection => SerializeEntity(await _context.TodoLists.FindByIdAsync(key, cancellationToken)),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task RemoveEntityAsync(
|
||||
string collection,
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await DeleteEntityAsync(collection, key, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RemoveEntitiesBatchAsync(
|
||||
IEnumerable<(string Collection, string Key)> documents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach ((string collection, string key) in documents)
|
||||
await DeleteEntityAsync(collection, key, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
|
||||
string collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return collection switch
|
||||
{
|
||||
UsersCollection => (await _context.Users.FindAllAsync(cancellationToken))
|
||||
.Select(u => (u.Id, SerializeEntity(u)!.Value))
|
||||
.ToList(),
|
||||
TodoListsCollection => (await _context.TodoLists.FindAllAsync(cancellationToken))
|
||||
.Select(t => (t.Id, SerializeEntity(t)!.Value))
|
||||
.ToList(),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private async Task UpsertEntityAsync(
|
||||
string collection,
|
||||
string key,
|
||||
JsonElement content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
switch (collection)
|
||||
{
|
||||
case UsersCollection:
|
||||
var user = content.Deserialize<User>()!;
|
||||
var user = content.Deserialize<User>() ?? throw new InvalidOperationException("Failed to deserialize user.");
|
||||
user.Id = key;
|
||||
var existingUser = _context.Users.Find(u => u.Id == key).FirstOrDefault();
|
||||
if (existingUser != null)
|
||||
_context.Users.Update(user);
|
||||
if (await _context.Users.FindByIdAsync(key, cancellationToken) == null)
|
||||
await _context.Users.InsertAsync(user, cancellationToken);
|
||||
else
|
||||
_context.Users.Insert(user);
|
||||
await _context.Users.UpdateAsync(user, cancellationToken);
|
||||
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);
|
||||
var todo = content.Deserialize<TodoList>() ??
|
||||
throw new InvalidOperationException("Failed to deserialize todo list.");
|
||||
todo.Id = key;
|
||||
if (await _context.TodoLists.FindByIdAsync(key, cancellationToken) == null)
|
||||
await _context.TodoLists.InsertAsync(todo, cancellationToken);
|
||||
else
|
||||
_context.TodoLists.Insert(todoList);
|
||||
await _context.TodoLists.UpdateAsync(todo, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -94,43 +129,15 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<JsonElement?> GetEntityAsJsonAsync(
|
||||
string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(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 ((string collection, string key) in documents) DeleteEntity(collection, key);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void DeleteEntity(string collection, string key)
|
||||
private async Task DeleteEntityAsync(string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (collection)
|
||||
{
|
||||
case UsersCollection:
|
||||
_context.Users.Delete(key);
|
||||
await _context.Users.DeleteAsync(key, cancellationToken);
|
||||
break;
|
||||
case TodoListsCollection:
|
||||
_context.TodoLists.Delete(key);
|
||||
await _context.TodoLists.DeleteAsync(key, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning("Attempted to remove entity from unsupported collection: {Collection}", collection);
|
||||
@@ -138,21 +145,8 @@ public class SampleDocumentStore : BLiteDocumentStore<SampleDbContext>
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
|
||||
string collection, CancellationToken cancellationToken)
|
||||
private static JsonElement? SerializeEntity<T>(T? entity) where T : class
|
||||
{
|
||||
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);
|
||||
return entity == null ? null : JsonSerializer.SerializeToElement(entity);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,16 @@
|
||||
|
||||
<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="Microsoft.Extensions.Configuration.Binder" Version="9.0.4"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.4"/>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4"/>
|
||||
<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"/>
|
||||
|
||||
Reference in New Issue
Block a user