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:
@@ -0,0 +1,142 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SurrealDb.Embedded.Options;
|
||||
using SurrealDb.Embedded.RocksDb;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models.Response;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Embedded RocksDB-backed Surreal client wrapper used by CBDDC persistence components.
|
||||
/// </summary>
|
||||
public sealed class CBDDCSurrealEmbeddedClient : ICBDDCSurrealEmbeddedClient
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyParameters = new Dictionary<string, object?>();
|
||||
private readonly SemaphoreSlim _initializeGate = new(1, 1);
|
||||
private readonly ILogger<CBDDCSurrealEmbeddedClient> _logger;
|
||||
private readonly CBDDCSurrealEmbeddedOptions _options;
|
||||
private bool _disposed;
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CBDDCSurrealEmbeddedClient" /> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Embedded Surreal options.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public CBDDCSurrealEmbeddedClient(
|
||||
CBDDCSurrealEmbeddedOptions options,
|
||||
ILogger<CBDDCSurrealEmbeddedClient>? logger = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<CBDDCSurrealEmbeddedClient>.Instance;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.Endpoint) &&
|
||||
!_options.Endpoint.StartsWith("rocksdb://", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException(
|
||||
"Embedded Surreal endpoint must use the rocksdb:// scheme.",
|
||||
nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Namespace))
|
||||
throw new ArgumentException("Namespace is required.", nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Database))
|
||||
throw new ArgumentException("Database is required.", nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.NamingPolicy))
|
||||
throw new ArgumentException("Naming policy is required.", nameof(options));
|
||||
|
||||
string dbPath = ResolveDatabasePath(_options.DatabasePath);
|
||||
var embeddedOptionsBuilder = SurrealDbEmbeddedOptions.Create();
|
||||
if (_options.StrictMode.HasValue)
|
||||
embeddedOptionsBuilder.WithStrictMode(_options.StrictMode.Value);
|
||||
|
||||
Client = new SurrealDbRocksDbClient(dbPath, embeddedOptionsBuilder.Build(), _options.NamingPolicy);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISurrealDbClient Client { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_initialized) return;
|
||||
|
||||
await _initializeGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
await Client.Connect(cancellationToken);
|
||||
await Client.Use(_options.Namespace, _options.Database, cancellationToken);
|
||||
_initialized = true;
|
||||
|
||||
_logger.LogInformation("Surreal embedded client initialized for namespace '{Namespace}' and database '{Database}'.",
|
||||
_options.Namespace, _options.Database);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initializeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SurrealDbResponse> RawQueryAsync(
|
||||
string query,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
throw new ArgumentException("Query is required.", nameof(query));
|
||||
|
||||
await InitializeAsync(cancellationToken);
|
||||
return await Client.RawQuery(query, parameters ?? EmptyParameters, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
await InitializeAsync(cancellationToken);
|
||||
return await Client.Health(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_disposed = true;
|
||||
Client.Dispose();
|
||||
_initializeGate.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_disposed = true;
|
||||
await Client.DisposeAsync();
|
||||
_initializeGate.Dispose();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private static string ResolveDatabasePath(string databasePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(databasePath))
|
||||
throw new ArgumentException("DatabasePath is required.", nameof(databasePath));
|
||||
|
||||
string fullPath = Path.GetFullPath(databasePath);
|
||||
string? directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
using SurrealDb.Net;
|
||||
using ZB.MOM.WW.CBDDC.Core.Storage;
|
||||
using ZB.MOM.WW.CBDDC.Core.Sync;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring embedded Surreal persistence for CBDDC.
|
||||
/// </summary>
|
||||
public static class CBDDCSurrealEmbeddedExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds embedded Surreal infrastructure to CBDDC and registers a document store implementation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDocumentStore">The concrete document store implementation.</typeparam>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="optionsFactory">Factory used to build embedded Surreal options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCBDDCSurrealEmbedded<TDocumentStore>(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, CBDDCSurrealEmbeddedOptions> optionsFactory)
|
||||
where TDocumentStore : class, IDocumentStore
|
||||
{
|
||||
RegisterCoreServices(services, optionsFactory);
|
||||
services.TryAddSingleton<IDocumentStore, TDocumentStore>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds embedded Surreal infrastructure to CBDDC without registering store implementations.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="optionsFactory">Factory used to build embedded Surreal options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Register store implementations separately when they become available.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddCBDDCSurrealEmbedded(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, CBDDCSurrealEmbeddedOptions> optionsFactory)
|
||||
{
|
||||
RegisterCoreServices(services, optionsFactory);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterCoreServices(
|
||||
IServiceCollection services,
|
||||
Func<IServiceProvider, CBDDCSurrealEmbeddedOptions> optionsFactory)
|
||||
{
|
||||
if (services == null) throw new ArgumentNullException(nameof(services));
|
||||
if (optionsFactory == null) throw new ArgumentNullException(nameof(optionsFactory));
|
||||
|
||||
services.TryAddSingleton(optionsFactory);
|
||||
|
||||
services.TryAddSingleton<ICBDDCSurrealEmbeddedClient, CBDDCSurrealEmbeddedClient>();
|
||||
services.TryAddSingleton<ISurrealDbClient>(sp => sp.GetRequiredService<ICBDDCSurrealEmbeddedClient>().Client);
|
||||
services.TryAddSingleton<ICBDDCSurrealSchemaInitializer, CBDDCSurrealSchemaInitializer>();
|
||||
services.TryAddSingleton<ICBDDCSurrealReadinessProbe, CBDDCSurrealReadinessProbe>();
|
||||
services.TryAddSingleton<ISurrealCdcCheckpointPersistence, SurrealCdcCheckpointPersistence>();
|
||||
|
||||
services.TryAddSingleton<IConflictResolver, LastWriteWinsConflictResolver>();
|
||||
services.TryAddSingleton<IVectorClockService, VectorClockService>();
|
||||
services.TryAddSingleton<IPeerConfigurationStore, SurrealPeerConfigurationStore>();
|
||||
services.TryAddSingleton<IPeerOplogConfirmationStore, SurrealPeerOplogConfirmationStore>();
|
||||
services.TryAddSingleton<ISnapshotMetadataStore, SurrealSnapshotMetadataStore>();
|
||||
services.TryAddSingleton<IDocumentMetadataStore, SurrealDocumentMetadataStore>();
|
||||
services.TryAddSingleton<IOplogStore, SurrealOplogStore>();
|
||||
|
||||
// SnapshotStore registration matches the other provider extension patterns.
|
||||
services.TryAddSingleton<ISnapshotService, SnapshotStore>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the embedded SurrealDB RocksDB provider.
|
||||
/// </summary>
|
||||
public sealed class CBDDCSurrealEmbeddedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical endpoint for this provider. For embedded RocksDB this should use the <c>rocksdb://</c> scheme.
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = "rocksdb://local";
|
||||
|
||||
/// <summary>
|
||||
/// File path used by the embedded RocksDB engine.
|
||||
/// </summary>
|
||||
public string DatabasePath { get; set; } = "data/cbddc-surreal.db";
|
||||
|
||||
/// <summary>
|
||||
/// Surreal namespace.
|
||||
/// </summary>
|
||||
public string Namespace { get; set; } = "cbddc";
|
||||
|
||||
/// <summary>
|
||||
/// Surreal database name inside the namespace.
|
||||
/// </summary>
|
||||
public string Database { get; set; } = "main";
|
||||
|
||||
/// <summary>
|
||||
/// Naming policy used by the Surreal .NET client serializer.
|
||||
/// </summary>
|
||||
public string NamingPolicy { get; set; } = "camelCase";
|
||||
|
||||
/// <summary>
|
||||
/// Optional strict mode flag for embedded Surreal.
|
||||
/// </summary>
|
||||
public bool? StrictMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CDC-related options used by persistence stores.
|
||||
/// </summary>
|
||||
public CBDDCSurrealCdcOptions Cdc { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CDC/checkpoint configuration for the embedded Surreal provider.
|
||||
/// </summary>
|
||||
public sealed class CBDDCSurrealCdcOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables CDC-oriented checkpoint bookkeeping.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint table name used for CDC progress tracking.
|
||||
/// </summary>
|
||||
public string CheckpointTable { get; set; } = "cbddc_cdc_checkpoint";
|
||||
|
||||
/// <summary>
|
||||
/// Enables LIVE SELECT subscriptions as a low-latency wake-up signal for polling.
|
||||
/// </summary>
|
||||
public bool EnableLiveSelectAccelerator { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Logical consumer identifier used by checkpoint records.
|
||||
/// </summary>
|
||||
public string ConsumerId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Polling interval for CDC readers that use pull-based processing.
|
||||
/// </summary>
|
||||
public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of changefeed entries fetched per poll cycle.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Delay before re-subscribing LIVE SELECT after failures or closure.
|
||||
/// </summary>
|
||||
public TimeSpan LiveSelectReconnectDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Retention window used when defining Surreal changefeed history.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionDuration { get; set; } = TimeSpan.FromDays(7);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Health/readiness helper for the embedded Surreal provider.
|
||||
/// </summary>
|
||||
public sealed class CBDDCSurrealReadinessProbe : ICBDDCSurrealReadinessProbe
|
||||
{
|
||||
private readonly ICBDDCSurrealEmbeddedClient _surrealClient;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly ILogger<CBDDCSurrealReadinessProbe> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CBDDCSurrealReadinessProbe" /> class.
|
||||
/// </summary>
|
||||
/// <param name="surrealClient">Surreal client abstraction.</param>
|
||||
/// <param name="schemaInitializer">Schema initializer.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public CBDDCSurrealReadinessProbe(
|
||||
ICBDDCSurrealEmbeddedClient surrealClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
ILogger<CBDDCSurrealReadinessProbe>? logger = null)
|
||||
{
|
||||
_surrealClient = surrealClient ?? throw new ArgumentNullException(nameof(surrealClient));
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
_logger = logger ?? NullLogger<CBDDCSurrealReadinessProbe>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsReadyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
return await _surrealClient.HealthAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Surreal embedded readiness probe failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes Surreal schema objects required by CBDDC persistence stores.
|
||||
/// </summary>
|
||||
public sealed class CBDDCSurrealSchemaInitializer : ICBDDCSurrealSchemaInitializer
|
||||
{
|
||||
private static readonly Regex IdentifierRegex = new("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled);
|
||||
private readonly SemaphoreSlim _initializeGate = new(1, 1);
|
||||
private readonly ICBDDCSurrealEmbeddedClient _surrealClient;
|
||||
private readonly ILogger<CBDDCSurrealSchemaInitializer> _logger;
|
||||
private readonly string _checkpointTable;
|
||||
private readonly string _changefeedRetentionLiteral;
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CBDDCSurrealSchemaInitializer" /> class.
|
||||
/// </summary>
|
||||
/// <param name="surrealClient">Surreal client abstraction.</param>
|
||||
/// <param name="options">Embedded options.</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public CBDDCSurrealSchemaInitializer(
|
||||
ICBDDCSurrealEmbeddedClient surrealClient,
|
||||
CBDDCSurrealEmbeddedOptions options,
|
||||
ILogger<CBDDCSurrealSchemaInitializer>? logger = null)
|
||||
{
|
||||
_surrealClient = surrealClient ?? throw new ArgumentNullException(nameof(surrealClient));
|
||||
_logger = logger ?? NullLogger<CBDDCSurrealSchemaInitializer>.Instance;
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
if (options.Cdc == null) throw new ArgumentException("CDC options are required.", nameof(options));
|
||||
|
||||
_checkpointTable = EnsureValidIdentifier(options.Cdc.CheckpointTable, nameof(options.Cdc.CheckpointTable));
|
||||
_changefeedRetentionLiteral = ToSurrealDurationLiteral(
|
||||
options.Cdc.RetentionDuration,
|
||||
nameof(options.Cdc.RetentionDuration));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
await _initializeGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
string schemaSql = BuildSchemaSql();
|
||||
await _surrealClient.RawQueryAsync(schemaSql, cancellationToken: cancellationToken);
|
||||
|
||||
_initialized = true;
|
||||
_logger.LogInformation(
|
||||
"Surreal schema initialized with checkpoint table '{CheckpointTable}'.",
|
||||
_checkpointTable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initializeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildSchemaSql()
|
||||
{
|
||||
return $"""
|
||||
DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.OplogEntriesTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral};
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.OplogHashIndex} ON TABLE {CBDDCSurrealSchemaNames.OplogEntriesTable} COLUMNS hash UNIQUE;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.OplogHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.OplogEntriesTable} COLUMNS timestampPhysicalTime, timestampLogicalCounter, timestampNodeId;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.OplogCollectionIndex} ON TABLE {CBDDCSurrealSchemaNames.OplogEntriesTable} COLUMNS collection;
|
||||
|
||||
DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.SnapshotMetadataTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral};
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.SnapshotNodeIdIndex} ON TABLE {CBDDCSurrealSchemaNames.SnapshotMetadataTable} COLUMNS nodeId UNIQUE;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.SnapshotHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.SnapshotMetadataTable} COLUMNS timestampPhysicalTime, timestampLogicalCounter;
|
||||
|
||||
DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral};
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerNodeIdIndex} ON TABLE {CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable} COLUMNS nodeId UNIQUE;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerEnabledIndex} ON TABLE {CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable} COLUMNS isEnabled;
|
||||
|
||||
DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.DocumentMetadataTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral};
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.DocumentMetadataCollectionKeyIndex} ON TABLE {CBDDCSurrealSchemaNames.DocumentMetadataTable} COLUMNS collection, key UNIQUE;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.DocumentMetadataHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.DocumentMetadataTable} COLUMNS hlcPhysicalTime, hlcLogicalCounter, hlcNodeId;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.DocumentMetadataCollectionIndex} ON TABLE {CBDDCSurrealSchemaNames.DocumentMetadataTable} COLUMNS collection;
|
||||
|
||||
DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral};
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerConfirmationPairIndex} ON TABLE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} COLUMNS peerNodeId, sourceNodeId UNIQUE;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerConfirmationActiveIndex} ON TABLE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} COLUMNS isActive;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerConfirmationSourceHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} COLUMNS sourceNodeId, confirmedWall, confirmedLogic;
|
||||
|
||||
DEFINE TABLE OVERWRITE {_checkpointTable} SCHEMAFULL;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.CdcCheckpointConsumerIndex} ON TABLE {_checkpointTable} COLUMNS consumerId UNIQUE;
|
||||
DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.CdcCheckpointVersionstampIndex} ON TABLE {_checkpointTable} COLUMNS versionstampCursor;
|
||||
""";
|
||||
}
|
||||
|
||||
private static string EnsureValidIdentifier(string? identifier, string argumentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
throw new ArgumentException("Surreal identifier is required.", argumentName);
|
||||
|
||||
if (!IdentifierRegex.IsMatch(identifier))
|
||||
throw new ArgumentException(
|
||||
$"Invalid Surreal identifier '{identifier}'. Use letters, numbers, and underscores only.",
|
||||
argumentName);
|
||||
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private static string ToSurrealDurationLiteral(TimeSpan duration, string argumentName)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(argumentName, "Surreal changefeed retention duration must be positive.");
|
||||
|
||||
if (duration.TotalDays >= 1 && duration.TotalDays == Math.Truncate(duration.TotalDays))
|
||||
return $"{(long)duration.TotalDays}d";
|
||||
|
||||
if (duration.TotalHours >= 1 && duration.TotalHours == Math.Truncate(duration.TotalHours))
|
||||
return $"{(long)duration.TotalHours}h";
|
||||
|
||||
if (duration.TotalMinutes >= 1 && duration.TotalMinutes == Math.Truncate(duration.TotalMinutes))
|
||||
return $"{(long)duration.TotalMinutes}m";
|
||||
|
||||
if (duration.TotalSeconds >= 1 && duration.TotalSeconds == Math.Truncate(duration.TotalSeconds))
|
||||
return $"{(long)duration.TotalSeconds}s";
|
||||
|
||||
long totalMs = checked((long)Math.Ceiling(duration.TotalMilliseconds));
|
||||
return $"{totalMs}ms";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Surreal table and index names shared by the embedded CBDDC provider.
|
||||
/// </summary>
|
||||
public static class CBDDCSurrealSchemaNames
|
||||
{
|
||||
public const string OplogEntriesTable = "cbddc_oplog_entries";
|
||||
public const string SnapshotMetadataTable = "cbddc_snapshot_metadatas";
|
||||
public const string RemotePeerConfigurationsTable = "cbddc_remote_peer_configurations";
|
||||
public const string DocumentMetadataTable = "cbddc_document_metadatas";
|
||||
public const string PeerOplogConfirmationsTable = "cbddc_peer_oplog_confirmations";
|
||||
|
||||
public const string OplogHashIndex = "idx_cbddc_oplog_hash";
|
||||
public const string OplogHlcIndex = "idx_cbddc_oplog_hlc";
|
||||
public const string OplogCollectionIndex = "idx_cbddc_oplog_collection";
|
||||
public const string SnapshotNodeIdIndex = "idx_cbddc_snapshot_node";
|
||||
public const string SnapshotHlcIndex = "idx_cbddc_snapshot_hlc";
|
||||
public const string PeerNodeIdIndex = "idx_cbddc_peer_node";
|
||||
public const string PeerEnabledIndex = "idx_cbddc_peer_enabled";
|
||||
public const string DocumentMetadataCollectionKeyIndex = "idx_cbddc_docmeta_collection_key";
|
||||
public const string DocumentMetadataHlcIndex = "idx_cbddc_docmeta_hlc";
|
||||
public const string DocumentMetadataCollectionIndex = "idx_cbddc_docmeta_collection";
|
||||
public const string PeerConfirmationPairIndex = "idx_cbddc_peer_confirm_pair";
|
||||
public const string PeerConfirmationActiveIndex = "idx_cbddc_peer_confirm_active";
|
||||
public const string PeerConfirmationSourceHlcIndex = "idx_cbddc_peer_confirm_source_hlc";
|
||||
public const string CdcCheckpointConsumerIndex = "idx_cbddc_cdc_checkpoint_consumer";
|
||||
public const string CdcCheckpointVersionstampIndex = "idx_cbddc_cdc_checkpoint_versionstamp";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models.Response;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the embedded Surreal client used by CBDDC persistence stores.
|
||||
/// </summary>
|
||||
public interface ICBDDCSurrealEmbeddedClient : IAsyncDisposable, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the underlying Surreal client.
|
||||
/// </summary>
|
||||
ISurrealDbClient Client { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Connects and selects namespace/database exactly once.
|
||||
/// </summary>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a raw SurrealQL statement.
|
||||
/// </summary>
|
||||
Task<SurrealDbResponse> RawQueryAsync(string query,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the embedded client responds to health probes.
|
||||
/// </summary>
|
||||
Task<bool> HealthAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Simple readiness probe for embedded Surreal infrastructure.
|
||||
/// </summary>
|
||||
public interface ICBDDCSurrealReadinessProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when client initialization, schema initialization, and health checks pass.
|
||||
/// </summary>
|
||||
Task<bool> IsReadyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures required Surreal schema objects exist.
|
||||
/// </summary>
|
||||
public interface ICBDDCSurrealSchemaInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates required tables/indexes/checkpoint schema for CBDDC stores.
|
||||
/// </summary>
|
||||
Task EnsureInitializedAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents durable CDC progress for a logical consumer.
|
||||
/// </summary>
|
||||
public sealed class SurrealCdcCheckpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the logical consumer identifier.
|
||||
/// </summary>
|
||||
public string ConsumerId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last processed hybrid logical timestamp.
|
||||
/// </summary>
|
||||
public HlcTimestamp Timestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last processed hash in the local chain.
|
||||
/// </summary>
|
||||
public string LastHash { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC instant when the checkpoint was updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional changefeed versionstamp cursor associated with this checkpoint.
|
||||
/// </summary>
|
||||
public long? VersionstampCursor { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines persistence operations for local CDC checkpoint progress.
|
||||
/// </summary>
|
||||
public interface ISurrealCdcCheckpointPersistence
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the checkpoint for a consumer.
|
||||
/// </summary>
|
||||
/// <param name="consumerId">Optional consumer id. Defaults to configured CDC consumer id.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The checkpoint if found; otherwise <see langword="null" />.</returns>
|
||||
Task<SurrealCdcCheckpoint?> GetCheckpointAsync(
|
||||
string? consumerId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts checkpoint progress for a consumer.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The last processed timestamp.</param>
|
||||
/// <param name="lastHash">The last processed hash.</param>
|
||||
/// <param name="consumerId">Optional consumer id. Defaults to configured CDC consumer id.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <param name="versionstampCursor">Optional changefeed versionstamp cursor.</param>
|
||||
Task UpsertCheckpointAsync(
|
||||
HlcTimestamp timestamp,
|
||||
string lastHash,
|
||||
string? consumerId = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
long? versionstampCursor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Advances checkpoint progress from an oplog entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The oplog entry that was processed.</param>
|
||||
/// <param name="consumerId">Optional consumer id. Defaults to configured CDC consumer id.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
Task AdvanceCheckpointAsync(
|
||||
OplogEntry entry,
|
||||
string? consumerId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Defines lifecycle controls for the durable Surreal CDC polling worker.
|
||||
/// </summary>
|
||||
public interface ISurrealCdcWorkerLifecycle
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the CDC worker is currently running.
|
||||
/// </summary>
|
||||
bool IsCdcWorkerRunning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the CDC worker.
|
||||
/// </summary>
|
||||
Task StartCdcWorkerAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes one CDC polling pass across all watched collections.
|
||||
/// </summary>
|
||||
Task PollCdcOnceAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the CDC worker.
|
||||
/// </summary>
|
||||
Task StopCdcWorkerAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Surreal-backed persistence for CDC checkpoint progress.
|
||||
/// </summary>
|
||||
public sealed class SurrealCdcCheckpointPersistence : ISurrealCdcCheckpointPersistence
|
||||
{
|
||||
private readonly bool _enabled;
|
||||
private readonly string _checkpointTable;
|
||||
private readonly string _defaultConsumerId;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly ISurrealDbClient _surrealClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SurrealCdcCheckpointPersistence" /> class.
|
||||
/// </summary>
|
||||
/// <param name="surrealEmbeddedClient">The embedded Surreal client abstraction.</param>
|
||||
/// <param name="schemaInitializer">The Surreal schema initializer.</param>
|
||||
/// <param name="options">Embedded Surreal options.</param>
|
||||
public SurrealCdcCheckpointPersistence(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
CBDDCSurrealEmbeddedOptions options)
|
||||
{
|
||||
_ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
_surrealClient = surrealEmbeddedClient.Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
_enabled = options.Cdc.Enabled;
|
||||
_checkpointTable = options.Cdc.CheckpointTable;
|
||||
_defaultConsumerId = options.Cdc.ConsumerId;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_checkpointTable))
|
||||
throw new ArgumentException("CDC checkpoint table is required.", nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_defaultConsumerId))
|
||||
throw new ArgumentException("CDC consumer id is required.", nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SurrealCdcCheckpoint?> GetCheckpointAsync(
|
||||
string? consumerId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_enabled) return null;
|
||||
|
||||
string resolvedConsumerId = ResolveConsumerId(consumerId);
|
||||
var existing = await FindByConsumerIdAsync(resolvedConsumerId, cancellationToken);
|
||||
return existing?.ToDomain();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertCheckpointAsync(
|
||||
HlcTimestamp timestamp,
|
||||
string lastHash,
|
||||
string? consumerId = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
long? versionstampCursor = null)
|
||||
{
|
||||
if (!_enabled) return;
|
||||
|
||||
string resolvedConsumerId = ResolveConsumerId(consumerId);
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
|
||||
long? effectiveVersionstampCursor = versionstampCursor;
|
||||
if (!effectiveVersionstampCursor.HasValue)
|
||||
{
|
||||
var existing = await FindByConsumerIdAsync(
|
||||
resolvedConsumerId,
|
||||
cancellationToken,
|
||||
ensureInitialized: false);
|
||||
effectiveVersionstampCursor = existing?.VersionstampCursor;
|
||||
}
|
||||
|
||||
RecordId recordId = RecordId.From(_checkpointTable, ComputeConsumerKey(resolvedConsumerId));
|
||||
|
||||
var record = new SurrealCdcCheckpointRecord
|
||||
{
|
||||
ConsumerId = resolvedConsumerId,
|
||||
TimestampPhysicalTime = timestamp.PhysicalTime,
|
||||
TimestampLogicalCounter = timestamp.LogicalCounter,
|
||||
TimestampNodeId = timestamp.NodeId,
|
||||
LastHash = lastHash ?? string.Empty,
|
||||
UpdatedUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
VersionstampCursor = effectiveVersionstampCursor
|
||||
};
|
||||
|
||||
await _surrealClient.Upsert<SurrealCdcCheckpointRecord, SurrealCdcCheckpointRecord>(
|
||||
recordId,
|
||||
record,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AdvanceCheckpointAsync(
|
||||
OplogEntry entry,
|
||||
string? consumerId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
return UpsertCheckpointAsync(entry.Timestamp, entry.Hash, consumerId, cancellationToken);
|
||||
}
|
||||
|
||||
private string ResolveConsumerId(string? consumerId)
|
||||
{
|
||||
string resolved = string.IsNullOrWhiteSpace(consumerId) ? _defaultConsumerId : consumerId;
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
throw new ArgumentException("CDC consumer id is required.", nameof(consumerId));
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SurrealCdcCheckpointRecord?> FindByConsumerIdAsync(
|
||||
string consumerId,
|
||||
CancellationToken cancellationToken,
|
||||
bool ensureInitialized = true)
|
||||
{
|
||||
if (ensureInitialized) await EnsureReadyAsync(cancellationToken);
|
||||
|
||||
RecordId deterministicId = RecordId.From(_checkpointTable, ComputeConsumerKey(consumerId));
|
||||
var deterministic = await _surrealClient.Select<SurrealCdcCheckpointRecord>(deterministicId, cancellationToken);
|
||||
if (deterministic != null &&
|
||||
string.Equals(deterministic.ConsumerId, consumerId, StringComparison.Ordinal))
|
||||
return deterministic;
|
||||
|
||||
var all = await _surrealClient.Select<SurrealCdcCheckpointRecord>(_checkpointTable, cancellationToken);
|
||||
return all?.FirstOrDefault(c =>
|
||||
string.Equals(c.ConsumerId, consumerId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string ComputeConsumerKey(string consumerId)
|
||||
{
|
||||
byte[] input = Encoding.UTF8.GetBytes(consumerId);
|
||||
return Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SurrealCdcCheckpointRecord : Record
|
||||
{
|
||||
[JsonPropertyName("consumerId")]
|
||||
public string ConsumerId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("timestampPhysicalTime")]
|
||||
public long TimestampPhysicalTime { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampLogicalCounter")]
|
||||
public int TimestampLogicalCounter { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampNodeId")]
|
||||
public string TimestampNodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lastHash")]
|
||||
public string LastHash { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("updatedUtcMs")]
|
||||
public long UpdatedUtcMs { get; set; }
|
||||
|
||||
[JsonPropertyName("versionstampCursor")]
|
||||
public long? VersionstampCursor { get; set; }
|
||||
}
|
||||
|
||||
internal static class SurrealCdcCheckpointRecordMappers
|
||||
{
|
||||
public static SurrealCdcCheckpoint ToDomain(this SurrealCdcCheckpointRecord record)
|
||||
{
|
||||
return new SurrealCdcCheckpoint
|
||||
{
|
||||
ConsumerId = record.ConsumerId,
|
||||
Timestamp = new HlcTimestamp(
|
||||
record.TimestampPhysicalTime,
|
||||
record.TimestampLogicalCounter,
|
||||
record.TimestampNodeId),
|
||||
LastHash = record.LastHash,
|
||||
UpdatedUtc = DateTimeOffset.FromUnixTimeMilliseconds(record.UpdatedUtcMs),
|
||||
VersionstampCursor = record.VersionstampCursor
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Surreal SHOW CHANGES polling worker.
|
||||
/// </summary>
|
||||
public sealed class SurrealCdcPollingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether polling is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the polling interval.
|
||||
/// </summary>
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of changefeed rows fetched per poll.
|
||||
/// </summary>
|
||||
public int BatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether LIVE SELECT wake-ups are enabled.
|
||||
/// </summary>
|
||||
public bool EnableLiveSelectAccelerator { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the delay used before re-subscribing a failed LIVE SELECT stream.
|
||||
/// </summary>
|
||||
public TimeSpan LiveSelectReconnectDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Storage;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
public class SurrealDocumentMetadataStore : DocumentMetadataStore
|
||||
{
|
||||
private readonly ILogger<SurrealDocumentMetadataStore> _logger;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly ISurrealDbClient _surrealClient;
|
||||
|
||||
public SurrealDocumentMetadataStore(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
ILogger<SurrealDocumentMetadataStore>? logger = null)
|
||||
{
|
||||
_ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
_surrealClient = surrealEmbeddedClient.Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
_logger = logger ?? NullLogger<SurrealDocumentMetadataStore>.Instance;
|
||||
}
|
||||
|
||||
public override async Task<DocumentMetadata?> GetMetadataAsync(string collection, string key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByCollectionKeyAsync(collection, key, cancellationToken);
|
||||
return existing?.ToDomain();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<DocumentMetadata>> GetMetadataByCollectionAsync(string collection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all
|
||||
.Where(m => string.Equals(m.Collection, collection, StringComparison.Ordinal))
|
||||
.Select(m => m.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task UpsertMetadataAsync(DocumentMetadata metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
|
||||
var existing = await FindByCollectionKeyAsync(metadata.Collection, metadata.Key, cancellationToken);
|
||||
RecordId recordId = existing?.Id ?? SurrealStoreRecordIds.DocumentMetadata(metadata.Collection, metadata.Key);
|
||||
|
||||
await _surrealClient.Upsert<SurrealDocumentMetadataRecord, SurrealDocumentMetadataRecord>(
|
||||
recordId,
|
||||
metadata.ToSurrealRecord(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task UpsertMetadataBatchAsync(IEnumerable<DocumentMetadata> metadatas,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var metadata in metadatas)
|
||||
await UpsertMetadataAsync(metadata, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task MarkDeletedAsync(string collection, string key, HlcTimestamp timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = new DocumentMetadata(collection, key, timestamp, true);
|
||||
await UpsertMetadataAsync(metadata, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<DocumentMetadata>> GetMetadataAfterAsync(HlcTimestamp since,
|
||||
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
HashSet<string>? collectionSet = collections != null ? new HashSet<string>(collections) : null;
|
||||
|
||||
return all
|
||||
.Where(m =>
|
||||
(m.HlcPhysicalTime > since.PhysicalTime ||
|
||||
(m.HlcPhysicalTime == since.PhysicalTime && m.HlcLogicalCounter > since.LogicalCounter)) &&
|
||||
(collectionSet == null || collectionSet.Contains(m.Collection)))
|
||||
.OrderBy(m => m.HlcPhysicalTime)
|
||||
.ThenBy(m => m.HlcLogicalCounter)
|
||||
.Select(m => m.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task DropAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Delete(CBDDCSurrealSchemaNames.DocumentMetadataTable, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<DocumentMetadata>> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.Select(m => m.ToDomain()).ToList();
|
||||
}
|
||||
|
||||
public override async Task ImportAsync(IEnumerable<DocumentMetadata> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items) await UpsertMetadataAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task MergeAsync(IEnumerable<DocumentMetadata> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var existing = await FindByCollectionKeyAsync(item.Collection, item.Key, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
await UpsertMetadataAsync(item, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingTimestamp =
|
||||
new HlcTimestamp(existing.HlcPhysicalTime, existing.HlcLogicalCounter, existing.HlcNodeId);
|
||||
|
||||
if (item.UpdatedAt.CompareTo(existingTimestamp) <= 0) continue;
|
||||
|
||||
RecordId recordId = existing.Id ?? SurrealStoreRecordIds.DocumentMetadata(item.Collection, item.Key);
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Upsert<SurrealDocumentMetadataRecord, SurrealDocumentMetadataRecord>(
|
||||
recordId,
|
||||
item.ToSurrealRecord(),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<SurrealDocumentMetadataRecord>> SelectAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var rows = await _surrealClient.Select<SurrealDocumentMetadataRecord>(
|
||||
CBDDCSurrealSchemaNames.DocumentMetadataTable,
|
||||
cancellationToken);
|
||||
return rows?.ToList() ?? [];
|
||||
}
|
||||
|
||||
private async Task<SurrealDocumentMetadataRecord?> FindByCollectionKeyAsync(string collection, string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
RecordId deterministicId = SurrealStoreRecordIds.DocumentMetadata(collection, key);
|
||||
var deterministic = await _surrealClient.Select<SurrealDocumentMetadataRecord>(deterministicId, cancellationToken);
|
||||
if (deterministic != null &&
|
||||
string.Equals(deterministic.Collection, collection, StringComparison.Ordinal) &&
|
||||
string.Equals(deterministic.Key, key, StringComparison.Ordinal))
|
||||
return deterministic;
|
||||
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.FirstOrDefault(m =>
|
||||
string.Equals(m.Collection, collection, StringComparison.Ordinal) &&
|
||||
string.Equals(m.Key, key, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
1331
src/ZB.MOM.WW.CBDDC.Persistence/Surreal/SurrealDocumentStore.cs
Normal file
1331
src/ZB.MOM.WW.CBDDC.Persistence/Surreal/SurrealDocumentStore.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single change notification emitted by a watchable collection.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">The entity type being observed.</typeparam>
|
||||
public readonly record struct SurrealCollectionChange<TEntity>(
|
||||
OperationType OperationType,
|
||||
string? DocumentId,
|
||||
TEntity? Entity)
|
||||
where TEntity : class;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for a collection that can publish change notifications to document-store watchers.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">The entity type being observed.</typeparam>
|
||||
public interface ISurrealWatchableCollection<TEntity> where TEntity : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes to collection change notifications.
|
||||
/// </summary>
|
||||
/// <param name="observer">The observer receiving collection changes.</param>
|
||||
/// <returns>A disposable subscription.</returns>
|
||||
IDisposable Subscribe(IObserver<SurrealCollectionChange<TEntity>> observer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory watchable collection feed used to publish local change events.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">The entity type being observed.</typeparam>
|
||||
public sealed class SurrealCollectionChangeFeed<TEntity> : ISurrealWatchableCollection<TEntity>, IDisposable
|
||||
where TEntity : class
|
||||
{
|
||||
private readonly object _observersGate = new();
|
||||
private readonly List<IObserver<SurrealCollectionChange<TEntity>>> _observers = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable Subscribe(IObserver<SurrealCollectionChange<TEntity>> observer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observer);
|
||||
|
||||
lock (_observersGate)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_observers.Add(observer);
|
||||
}
|
||||
|
||||
return new Subscription(this, observer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a put notification for an entity.
|
||||
/// </summary>
|
||||
/// <param name="entity">The changed entity.</param>
|
||||
/// <param name="documentId">Optional explicit document identifier.</param>
|
||||
public void PublishPut(TEntity entity, string? documentId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
Publish(new SurrealCollectionChange<TEntity>(OperationType.Put, documentId, entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a delete notification for an entity key.
|
||||
/// </summary>
|
||||
/// <param name="documentId">The document identifier that was removed.</param>
|
||||
public void PublishDelete(string documentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
throw new ArgumentException("Document id is required.", nameof(documentId));
|
||||
|
||||
Publish(new SurrealCollectionChange<TEntity>(OperationType.Delete, documentId, null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a raw collection change notification.
|
||||
/// </summary>
|
||||
/// <param name="change">The change payload.</param>
|
||||
public void Publish(SurrealCollectionChange<TEntity> change)
|
||||
{
|
||||
List<IObserver<SurrealCollectionChange<TEntity>>> snapshot;
|
||||
lock (_observersGate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
snapshot = _observers.ToList();
|
||||
}
|
||||
|
||||
foreach (var observer in snapshot)
|
||||
observer.OnNext(change);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
List<IObserver<SurrealCollectionChange<TEntity>>> snapshot;
|
||||
lock (_observersGate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
snapshot = _observers.ToList();
|
||||
_observers.Clear();
|
||||
}
|
||||
|
||||
foreach (var observer in snapshot)
|
||||
observer.OnCompleted();
|
||||
}
|
||||
|
||||
private void Unsubscribe(IObserver<SurrealCollectionChange<TEntity>> observer)
|
||||
{
|
||||
lock (_observersGate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_observers.Remove(observer);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly SurrealCollectionChangeFeed<TEntity> _owner;
|
||||
private readonly IObserver<SurrealCollectionChange<TEntity>> _observer;
|
||||
private int _disposed;
|
||||
|
||||
public Subscription(
|
||||
SurrealCollectionChangeFeed<TEntity> owner,
|
||||
IObserver<SurrealCollectionChange<TEntity>> observer)
|
||||
{
|
||||
_owner = owner;
|
||||
_observer = observer;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
_owner.Unsubscribe(_observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
272
src/ZB.MOM.WW.CBDDC.Persistence/Surreal/SurrealOplogStore.cs
Normal file
272
src/ZB.MOM.WW.CBDDC.Persistence/Surreal/SurrealOplogStore.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Storage;
|
||||
using ZB.MOM.WW.CBDDC.Core.Sync;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
public class SurrealOplogStore : OplogStore
|
||||
{
|
||||
private readonly ILogger<SurrealOplogStore> _logger;
|
||||
private readonly ICBDDCSurrealSchemaInitializer? _schemaInitializer;
|
||||
private readonly ISurrealDbClient? _surrealClient;
|
||||
|
||||
public SurrealOplogStore(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
IDocumentStore documentStore,
|
||||
IConflictResolver conflictResolver,
|
||||
IVectorClockService vectorClockService,
|
||||
ISnapshotMetadataStore? snapshotMetadataStore = null,
|
||||
ILogger<SurrealOplogStore>? logger = null) : base(
|
||||
documentStore,
|
||||
conflictResolver,
|
||||
vectorClockService,
|
||||
snapshotMetadataStore)
|
||||
{
|
||||
_ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
_surrealClient = surrealEmbeddedClient.Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
_logger = logger ?? NullLogger<SurrealOplogStore>.Instance;
|
||||
|
||||
_vectorClock.Invalidate();
|
||||
InitializeVectorClock();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<OplogEntry>> GetChainRangeAsync(string startHash, string endHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startRow = await FindByHashAsync(startHash, cancellationToken);
|
||||
var endRow = await FindByHashAsync(endHash, cancellationToken);
|
||||
|
||||
if (startRow == null || endRow == null) return [];
|
||||
|
||||
string nodeId = startRow.TimestampNodeId;
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
|
||||
return all
|
||||
.Where(o => string.Equals(o.TimestampNodeId, nodeId, StringComparison.Ordinal) &&
|
||||
(o.TimestampPhysicalTime > startRow.TimestampPhysicalTime ||
|
||||
(o.TimestampPhysicalTime == startRow.TimestampPhysicalTime &&
|
||||
o.TimestampLogicalCounter > startRow.TimestampLogicalCounter)) &&
|
||||
(o.TimestampPhysicalTime < endRow.TimestampPhysicalTime ||
|
||||
(o.TimestampPhysicalTime == endRow.TimestampPhysicalTime &&
|
||||
o.TimestampLogicalCounter <= endRow.TimestampLogicalCounter)))
|
||||
.OrderBy(o => o.TimestampPhysicalTime)
|
||||
.ThenBy(o => o.TimestampLogicalCounter)
|
||||
.Select(o => o.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task<OplogEntry?> GetEntryByHashAsync(string hash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByHashAsync(hash, cancellationToken);
|
||||
return existing?.ToDomain();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<OplogEntry>> GetOplogAfterAsync(HlcTimestamp timestamp,
|
||||
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
HashSet<string>? collectionSet = collections != null ? new HashSet<string>(collections) : null;
|
||||
|
||||
return all
|
||||
.Where(o =>
|
||||
(o.TimestampPhysicalTime > timestamp.PhysicalTime ||
|
||||
(o.TimestampPhysicalTime == timestamp.PhysicalTime &&
|
||||
o.TimestampLogicalCounter > timestamp.LogicalCounter)) &&
|
||||
(collectionSet == null || collectionSet.Contains(o.Collection)))
|
||||
.OrderBy(o => o.TimestampPhysicalTime)
|
||||
.ThenBy(o => o.TimestampLogicalCounter)
|
||||
.Select(o => o.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<OplogEntry>> GetOplogForNodeAfterAsync(string nodeId, HlcTimestamp since,
|
||||
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
HashSet<string>? collectionSet = collections != null ? new HashSet<string>(collections) : null;
|
||||
|
||||
return all
|
||||
.Where(o =>
|
||||
string.Equals(o.TimestampNodeId, nodeId, StringComparison.Ordinal) &&
|
||||
(o.TimestampPhysicalTime > since.PhysicalTime ||
|
||||
(o.TimestampPhysicalTime == since.PhysicalTime &&
|
||||
o.TimestampLogicalCounter > since.LogicalCounter)) &&
|
||||
(collectionSet == null || collectionSet.Contains(o.Collection)))
|
||||
.OrderBy(o => o.TimestampPhysicalTime)
|
||||
.ThenBy(o => o.TimestampLogicalCounter)
|
||||
.Select(o => o.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task PruneOplogAsync(HlcTimestamp cutoff, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
var toDelete = all
|
||||
.Where(o => o.TimestampPhysicalTime < cutoff.PhysicalTime ||
|
||||
(o.TimestampPhysicalTime == cutoff.PhysicalTime &&
|
||||
o.TimestampLogicalCounter <= cutoff.LogicalCounter))
|
||||
.ToList();
|
||||
|
||||
foreach (var row in toDelete)
|
||||
{
|
||||
RecordId recordId = row.Id ?? SurrealStoreRecordIds.Oplog(row.Hash);
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient!.Delete(recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task DropAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient!.Delete(CBDDCSurrealSchemaNames.OplogEntriesTable, cancellationToken);
|
||||
_vectorClock.Invalidate();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<OplogEntry>> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.Select(o => o.ToDomain()).ToList();
|
||||
}
|
||||
|
||||
public override async Task ImportAsync(IEnumerable<OplogEntry> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var existing = await FindByHashAsync(item.Hash, cancellationToken);
|
||||
RecordId recordId = existing?.Id ?? SurrealStoreRecordIds.Oplog(item.Hash);
|
||||
await UpsertAsync(item, recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task MergeAsync(IEnumerable<OplogEntry> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var existing = await FindByHashAsync(item.Hash, cancellationToken);
|
||||
if (existing != null) continue;
|
||||
|
||||
await UpsertAsync(item, SurrealStoreRecordIds.Oplog(item.Hash), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void InitializeVectorClock()
|
||||
{
|
||||
if (_vectorClock.IsInitialized) return;
|
||||
|
||||
if (_surrealClient == null || _schemaInitializer == null)
|
||||
{
|
||||
_vectorClock.IsInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_snapshotMetadataStore != null)
|
||||
try
|
||||
{
|
||||
var snapshots = _snapshotMetadataStore.GetAllSnapshotMetadataAsync().GetAwaiter().GetResult();
|
||||
foreach (var snapshot in snapshots)
|
||||
_vectorClock.UpdateNode(
|
||||
snapshot.NodeId,
|
||||
new HlcTimestamp(
|
||||
snapshot.TimestampPhysicalTime,
|
||||
snapshot.TimestampLogicalCounter,
|
||||
snapshot.NodeId),
|
||||
snapshot.Hash ?? "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore snapshot bootstrap failures to keep oplog fallback behavior aligned.
|
||||
}
|
||||
|
||||
EnsureReadyAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
var all = _surrealClient.Select<SurrealOplogRecord>(CBDDCSurrealSchemaNames.OplogEntriesTable, CancellationToken.None)
|
||||
.GetAwaiter().GetResult()
|
||||
?? [];
|
||||
|
||||
var latestPerNode = all
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.TimestampNodeId))
|
||||
.GroupBy(x => x.TimestampNodeId)
|
||||
.Select(g => g
|
||||
.OrderByDescending(x => x.TimestampPhysicalTime)
|
||||
.ThenByDescending(x => x.TimestampLogicalCounter)
|
||||
.First())
|
||||
.ToList();
|
||||
|
||||
foreach (var latest in latestPerNode)
|
||||
_vectorClock.UpdateNode(
|
||||
latest.TimestampNodeId,
|
||||
new HlcTimestamp(latest.TimestampPhysicalTime, latest.TimestampLogicalCounter, latest.TimestampNodeId),
|
||||
latest.Hash ?? "");
|
||||
|
||||
_vectorClock.IsInitialized = true;
|
||||
}
|
||||
|
||||
protected override async Task InsertOplogEntryAsync(OplogEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByHashAsync(entry.Hash, cancellationToken);
|
||||
if (existing != null) return;
|
||||
|
||||
await UpsertAsync(entry, SurrealStoreRecordIds.Oplog(entry.Hash), cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<string?> QueryLastHashForNodeAsync(string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
var lastEntry = all
|
||||
.Where(o => string.Equals(o.TimestampNodeId, nodeId, StringComparison.Ordinal))
|
||||
.OrderByDescending(o => o.TimestampPhysicalTime)
|
||||
.ThenByDescending(o => o.TimestampLogicalCounter)
|
||||
.FirstOrDefault();
|
||||
return lastEntry?.Hash;
|
||||
}
|
||||
|
||||
protected override async Task<(long Wall, int Logic)?> QueryLastHashTimestampFromOplogAsync(string hash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByHashAsync(hash, cancellationToken);
|
||||
if (existing == null) return null;
|
||||
return (existing.TimestampPhysicalTime, existing.TimestampLogicalCounter);
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(OplogEntry entry, RecordId recordId, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient!.Upsert<SurrealOplogRecord, SurrealOplogRecord>(
|
||||
recordId,
|
||||
entry.ToSurrealRecord(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer!.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<SurrealOplogRecord>> SelectAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var rows = await _surrealClient!.Select<SurrealOplogRecord>(
|
||||
CBDDCSurrealSchemaNames.OplogEntriesTable,
|
||||
cancellationToken);
|
||||
return rows?.ToList() ?? [];
|
||||
}
|
||||
|
||||
private async Task<SurrealOplogRecord?> FindByHashAsync(string hash, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
|
||||
RecordId deterministicId = SurrealStoreRecordIds.Oplog(hash);
|
||||
var deterministic = await _surrealClient!.Select<SurrealOplogRecord>(deterministicId, cancellationToken);
|
||||
if (deterministic != null && string.Equals(deterministic.Hash, hash, StringComparison.Ordinal))
|
||||
return deterministic;
|
||||
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.FirstOrDefault(o => string.Equals(o.Hash, hash, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
public class SurrealPeerConfigurationStore : PeerConfigurationStore
|
||||
{
|
||||
private readonly ILogger<SurrealPeerConfigurationStore> _logger;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly ISurrealDbClient _surrealClient;
|
||||
|
||||
public SurrealPeerConfigurationStore(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
ILogger<SurrealPeerConfigurationStore>? logger = null)
|
||||
{
|
||||
_ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
_surrealClient = surrealEmbeddedClient.Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
_logger = logger ?? NullLogger<SurrealPeerConfigurationStore>.Instance;
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<RemotePeerConfiguration>> GetRemotePeersAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.Select(p => p.ToDomain()).ToList();
|
||||
}
|
||||
|
||||
public override async Task<RemotePeerConfiguration?> GetRemotePeerAsync(string nodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(nodeId, cancellationToken);
|
||||
return existing?.ToDomain();
|
||||
}
|
||||
|
||||
public override async Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var existing = await FindByNodeIdAsync(nodeId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to remove non-existent remote peer: {NodeId}", nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
RecordId recordId = existing.Id ?? SurrealStoreRecordIds.RemotePeer(nodeId);
|
||||
await _surrealClient.Delete(recordId, cancellationToken);
|
||||
_logger.LogInformation("Removed remote peer configuration: {NodeId}", nodeId);
|
||||
}
|
||||
|
||||
public override async Task SaveRemotePeerAsync(RemotePeerConfiguration peer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var existing = await FindByNodeIdAsync(peer.NodeId, cancellationToken);
|
||||
RecordId recordId = existing?.Id ?? SurrealStoreRecordIds.RemotePeer(peer.NodeId);
|
||||
|
||||
await _surrealClient.Upsert<SurrealRemotePeerRecord, SurrealRemotePeerRecord>(
|
||||
recordId,
|
||||
peer.ToSurrealRecord(),
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation("Saved remote peer configuration: {NodeId} ({Type})", peer.NodeId, peer.Type);
|
||||
}
|
||||
|
||||
public override async Task DropAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Dropping peer configuration store - all remote peer configurations will be permanently deleted!");
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Delete(CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable, cancellationToken);
|
||||
_logger.LogInformation("Peer configuration store dropped successfully.");
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<RemotePeerConfiguration>> ExportAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetRemotePeersAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<SurrealRemotePeerRecord>> SelectAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var rows = await _surrealClient.Select<SurrealRemotePeerRecord>(
|
||||
CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable,
|
||||
cancellationToken);
|
||||
return rows?.ToList() ?? [];
|
||||
}
|
||||
|
||||
private async Task<SurrealRemotePeerRecord?> FindByNodeIdAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
RecordId deterministicId = SurrealStoreRecordIds.RemotePeer(nodeId);
|
||||
var deterministic = await _surrealClient.Select<SurrealRemotePeerRecord>(deterministicId, cancellationToken);
|
||||
if (deterministic != null &&
|
||||
string.Equals(deterministic.NodeId, nodeId, StringComparison.Ordinal))
|
||||
return deterministic;
|
||||
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.FirstOrDefault(p => string.Equals(p.NodeId, nodeId, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
|
||||
{
|
||||
internal const string RegistrationSourceNodeId = "__peer_registration__";
|
||||
|
||||
private readonly ILogger<SurrealPeerOplogConfirmationStore> _logger;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly ISurrealDbClient _surrealClient;
|
||||
|
||||
public SurrealPeerOplogConfirmationStore(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
ILogger<SurrealPeerOplogConfirmationStore>? logger = null)
|
||||
{
|
||||
_ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
_surrealClient = surrealEmbeddedClient.Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
_logger = logger ?? NullLogger<SurrealPeerOplogConfirmationStore>.Instance;
|
||||
}
|
||||
|
||||
public override async Task EnsurePeerRegisteredAsync(
|
||||
string peerNodeId,
|
||||
string address,
|
||||
PeerType type,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(peerNodeId))
|
||||
throw new ArgumentException("Peer node id is required.", nameof(peerNodeId));
|
||||
|
||||
var existing =
|
||||
await FindByPairAsync(peerNodeId, RegistrationSourceNodeId, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var created = new PeerOplogConfirmation
|
||||
{
|
||||
PeerNodeId = peerNodeId,
|
||||
SourceNodeId = RegistrationSourceNodeId,
|
||||
ConfirmedWall = 0,
|
||||
ConfirmedLogic = 0,
|
||||
ConfirmedHash = "",
|
||||
LastConfirmedUtc = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await UpsertAsync(created, SurrealStoreRecordIds.PeerOplogConfirmation(peerNodeId, RegistrationSourceNodeId),
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogDebug("Registered peer confirmation tracking for {PeerNodeId} ({Address}, {Type}).", peerNodeId,
|
||||
address, type);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.IsActive) return;
|
||||
|
||||
existing.IsActive = true;
|
||||
existing.LastConfirmedUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
RecordId recordId =
|
||||
existing.Id ?? SurrealStoreRecordIds.PeerOplogConfirmation(peerNodeId, RegistrationSourceNodeId);
|
||||
await UpsertAsync(existing, recordId, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task UpdateConfirmationAsync(
|
||||
string peerNodeId,
|
||||
string sourceNodeId,
|
||||
HlcTimestamp timestamp,
|
||||
string hash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(peerNodeId))
|
||||
throw new ArgumentException("Peer node id is required.", nameof(peerNodeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceNodeId))
|
||||
throw new ArgumentException("Source node id is required.", nameof(sourceNodeId));
|
||||
|
||||
var existing = await FindByPairAsync(peerNodeId, sourceNodeId, cancellationToken);
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var created = new PeerOplogConfirmation
|
||||
{
|
||||
PeerNodeId = peerNodeId,
|
||||
SourceNodeId = sourceNodeId,
|
||||
ConfirmedWall = timestamp.PhysicalTime,
|
||||
ConfirmedLogic = timestamp.LogicalCounter,
|
||||
ConfirmedHash = hash ?? "",
|
||||
LastConfirmedUtc = DateTimeOffset.FromUnixTimeMilliseconds(nowMs),
|
||||
IsActive = true
|
||||
};
|
||||
await UpsertAsync(created, SurrealStoreRecordIds.PeerOplogConfirmation(peerNodeId, sourceNodeId),
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isNewer = IsIncomingTimestampNewer(timestamp, existing);
|
||||
bool samePointHashChanged = timestamp.PhysicalTime == existing.ConfirmedWall &&
|
||||
timestamp.LogicalCounter == existing.ConfirmedLogic &&
|
||||
!string.Equals(existing.ConfirmedHash, hash, StringComparison.Ordinal);
|
||||
|
||||
if (!isNewer && !samePointHashChanged && existing.IsActive) return;
|
||||
|
||||
existing.ConfirmedWall = timestamp.PhysicalTime;
|
||||
existing.ConfirmedLogic = timestamp.LogicalCounter;
|
||||
existing.ConfirmedHash = hash ?? "";
|
||||
existing.LastConfirmedUtcMs = nowMs;
|
||||
existing.IsActive = true;
|
||||
|
||||
RecordId recordId = existing.Id ?? SurrealStoreRecordIds.PeerOplogConfirmation(peerNodeId, sourceNodeId);
|
||||
await UpsertAsync(existing, recordId, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<PeerOplogConfirmation>> GetConfirmationsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all
|
||||
.Where(c => !string.Equals(c.SourceNodeId, RegistrationSourceNodeId, StringComparison.Ordinal))
|
||||
.Select(c => c.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<PeerOplogConfirmation>> GetConfirmationsForPeerAsync(
|
||||
string peerNodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(peerNodeId))
|
||||
throw new ArgumentException("Peer node id is required.", nameof(peerNodeId));
|
||||
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all
|
||||
.Where(c => string.Equals(c.PeerNodeId, peerNodeId, StringComparison.Ordinal) &&
|
||||
!string.Equals(c.SourceNodeId, RegistrationSourceNodeId, StringComparison.Ordinal))
|
||||
.Select(c => c.ToDomain())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task RemovePeerTrackingAsync(string peerNodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(peerNodeId))
|
||||
throw new ArgumentException("Peer node id is required.", nameof(peerNodeId));
|
||||
|
||||
var matches = (await SelectAllAsync(cancellationToken))
|
||||
.Where(c => string.Equals(c.PeerNodeId, peerNodeId, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (matches.Count == 0) return;
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (!match.IsActive) continue;
|
||||
|
||||
match.IsActive = false;
|
||||
match.LastConfirmedUtcMs = nowMs;
|
||||
|
||||
RecordId recordId = match.Id ?? SurrealStoreRecordIds.PeerOplogConfirmation(match.PeerNodeId, match.SourceNodeId);
|
||||
await UpsertAsync(match, recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<string>> GetActiveTrackedPeersAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all
|
||||
.Where(c => c.IsActive)
|
||||
.Select(c => c.PeerNodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async Task DropAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Delete(CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<PeerOplogConfirmation>> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.Select(c => c.ToDomain()).ToList();
|
||||
}
|
||||
|
||||
public override async Task ImportAsync(IEnumerable<PeerOplogConfirmation> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var existing = await FindByPairAsync(item.PeerNodeId, item.SourceNodeId, cancellationToken);
|
||||
RecordId recordId =
|
||||
existing?.Id ?? SurrealStoreRecordIds.PeerOplogConfirmation(item.PeerNodeId, item.SourceNodeId);
|
||||
await UpsertAsync(item, recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task MergeAsync(IEnumerable<PeerOplogConfirmation> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var existing = await FindByPairAsync(item.PeerNodeId, item.SourceNodeId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
await UpsertAsync(item, SurrealStoreRecordIds.PeerOplogConfirmation(item.PeerNodeId, item.SourceNodeId),
|
||||
cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
var incomingTimestamp = new HlcTimestamp(item.ConfirmedWall, item.ConfirmedLogic, item.SourceNodeId);
|
||||
var existingTimestamp = new HlcTimestamp(existing.ConfirmedWall, existing.ConfirmedLogic, existing.SourceNodeId);
|
||||
|
||||
if (incomingTimestamp > existingTimestamp)
|
||||
{
|
||||
existing.ConfirmedWall = item.ConfirmedWall;
|
||||
existing.ConfirmedLogic = item.ConfirmedLogic;
|
||||
existing.ConfirmedHash = item.ConfirmedHash;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
long incomingLastConfirmedMs = item.LastConfirmedUtc.ToUnixTimeMilliseconds();
|
||||
if (incomingLastConfirmedMs > existing.LastConfirmedUtcMs)
|
||||
{
|
||||
existing.LastConfirmedUtcMs = incomingLastConfirmedMs;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (existing.IsActive != item.IsActive)
|
||||
{
|
||||
existing.IsActive = item.IsActive;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
|
||||
RecordId recordId =
|
||||
existing.Id ?? SurrealStoreRecordIds.PeerOplogConfirmation(existing.PeerNodeId, existing.SourceNodeId);
|
||||
await UpsertAsync(existing, recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(PeerOplogConfirmation confirmation, RecordId recordId, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Upsert<SurrealPeerOplogConfirmationRecord, SurrealPeerOplogConfirmationRecord>(
|
||||
recordId,
|
||||
confirmation.ToSurrealRecord(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(SurrealPeerOplogConfirmationRecord confirmation, RecordId recordId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Upsert<SurrealPeerOplogConfirmationRecord, SurrealPeerOplogConfirmationRecord>(
|
||||
recordId,
|
||||
confirmation,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<SurrealPeerOplogConfirmationRecord>> SelectAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var rows = await _surrealClient.Select<SurrealPeerOplogConfirmationRecord>(
|
||||
CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable,
|
||||
cancellationToken);
|
||||
return rows?.ToList() ?? [];
|
||||
}
|
||||
|
||||
private async Task<SurrealPeerOplogConfirmationRecord?> FindByPairAsync(string peerNodeId, string sourceNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
RecordId deterministicId = SurrealStoreRecordIds.PeerOplogConfirmation(peerNodeId, sourceNodeId);
|
||||
var deterministic = await _surrealClient.Select<SurrealPeerOplogConfirmationRecord>(deterministicId, cancellationToken);
|
||||
if (deterministic != null &&
|
||||
string.Equals(deterministic.PeerNodeId, peerNodeId, StringComparison.Ordinal) &&
|
||||
string.Equals(deterministic.SourceNodeId, sourceNodeId, StringComparison.Ordinal))
|
||||
return deterministic;
|
||||
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.FirstOrDefault(c =>
|
||||
string.Equals(c.PeerNodeId, peerNodeId, StringComparison.Ordinal) &&
|
||||
string.Equals(c.SourceNodeId, sourceNodeId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsIncomingTimestampNewer(HlcTimestamp incomingTimestamp, SurrealPeerOplogConfirmationRecord existing)
|
||||
{
|
||||
if (incomingTimestamp.PhysicalTime > existing.ConfirmedWall) return true;
|
||||
|
||||
if (incomingTimestamp.PhysicalTime == existing.ConfirmedWall &&
|
||||
incomingTimestamp.LogicalCounter > existing.ConfirmedLogic)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Text.Json;
|
||||
using Dahomey.Cbor.ObjectModel;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
internal readonly record struct SurrealPolledChangeRow(
|
||||
ulong Versionstamp,
|
||||
IReadOnlyList<SurrealPolledChange> Changes);
|
||||
|
||||
internal readonly record struct SurrealPolledChange(
|
||||
OperationType OperationType,
|
||||
string Key,
|
||||
JsonElement? Content);
|
||||
|
||||
internal static class SurrealShowChangesCborDecoder
|
||||
{
|
||||
private static readonly string[] PutChangeKinds = ["create", "update", "upsert", "insert", "set", "replace"];
|
||||
|
||||
public static IReadOnlyList<SurrealPolledChangeRow> DecodeRows(
|
||||
IEnumerable<CborObject> rows,
|
||||
string expectedTableName)
|
||||
{
|
||||
var result = new List<SurrealPolledChangeRow>();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!TryGetProperty(row, "versionstamp", out CborValue versionstampValue)) continue;
|
||||
if (!TryReadUInt64(versionstampValue, out ulong versionstamp)) continue;
|
||||
|
||||
var changes = new List<SurrealPolledChange>();
|
||||
if (TryGetProperty(row, "changes", out CborValue rawChanges) &&
|
||||
rawChanges is CborArray changeArray)
|
||||
foreach (CborValue changeValue in changeArray)
|
||||
{
|
||||
if (changeValue is not CborObject changeObject) continue;
|
||||
|
||||
if (TryExtractChange(changeObject, expectedTableName, out SurrealPolledChange change))
|
||||
changes.Add(change);
|
||||
}
|
||||
|
||||
result.Add(new SurrealPolledChangeRow(versionstamp, changes));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryExtractChange(
|
||||
CborObject changeObject,
|
||||
string expectedTableName,
|
||||
out SurrealPolledChange change)
|
||||
{
|
||||
if (TryGetProperty(changeObject, "delete", out CborValue deletePayload))
|
||||
if (TryExtractRecordKey(deletePayload, expectedTableName, out string deleteKey))
|
||||
{
|
||||
change = new SurrealPolledChange(OperationType.Delete, deleteKey, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (string putKind in PutChangeKinds)
|
||||
if (TryGetProperty(changeObject, putKind, out CborValue putPayload))
|
||||
if (TryExtractRecordKey(putPayload, expectedTableName, out string putKey))
|
||||
{
|
||||
JsonElement? content = BuildNormalizedJsonPayload(putPayload, putKey);
|
||||
change = new SurrealPolledChange(OperationType.Put, putKey, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
change = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExtractRecordKey(
|
||||
CborValue payload,
|
||||
string expectedTableName,
|
||||
out string key)
|
||||
{
|
||||
key = "";
|
||||
if (payload is not CborObject payloadObject) return false;
|
||||
if (!TryGetProperty(payloadObject, "id", out CborValue idValue)) return false;
|
||||
|
||||
if (TryExtractRecordKeyFromIdValue(idValue, expectedTableName, out string extracted))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extracted)) return false;
|
||||
key = extracted;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExtractRecordKeyFromIdValue(
|
||||
CborValue idValue,
|
||||
string expectedTableName,
|
||||
out string key)
|
||||
{
|
||||
key = "";
|
||||
|
||||
if (idValue is CborArray arrayId)
|
||||
{
|
||||
if (arrayId.Count < 2) return false;
|
||||
if (!TryReadString(arrayId[0], out string tableName)) return false;
|
||||
if (!string.IsNullOrWhiteSpace(expectedTableName) &&
|
||||
!string.Equals(tableName, expectedTableName, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
if (!TryReadString(arrayId[1], out string recordKey)) return false;
|
||||
key = recordKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (idValue is CborString)
|
||||
{
|
||||
if (!TryReadString(idValue, out string recordId)) return false;
|
||||
key = ExtractKeyFromRecordId(recordId) ?? "";
|
||||
return !string.IsNullOrWhiteSpace(key);
|
||||
}
|
||||
|
||||
if (idValue is CborObject idObject)
|
||||
{
|
||||
string? tableName = null;
|
||||
if (TryGetProperty(idObject, "tb", out CborValue tbValue) && TryReadString(tbValue, out string tb))
|
||||
tableName = tb;
|
||||
else if (TryGetProperty(idObject, "table", out CborValue tableValue) &&
|
||||
TryReadString(tableValue, out string table))
|
||||
tableName = table;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedTableName) &&
|
||||
!string.IsNullOrWhiteSpace(tableName) &&
|
||||
!string.Equals(tableName, expectedTableName, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
if (TryGetProperty(idObject, "id", out CborValue nestedId))
|
||||
{
|
||||
if (TryReadString(nestedId, out string nestedIdValue))
|
||||
{
|
||||
key = nestedIdValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
key = nestedId.ToString()?.Trim('"') ?? "";
|
||||
return !string.IsNullOrWhiteSpace(key);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JsonElement? BuildNormalizedJsonPayload(CborValue payload, string key)
|
||||
{
|
||||
object? clrValue = ConvertCborToClr(payload);
|
||||
if (clrValue == null) return null;
|
||||
|
||||
if (clrValue is Dictionary<string, object?> payloadMap)
|
||||
payloadMap["id"] = key;
|
||||
|
||||
return JsonSerializer.SerializeToElement(clrValue);
|
||||
}
|
||||
|
||||
private static object? ConvertCborToClr(CborValue value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case CborNull:
|
||||
return null;
|
||||
|
||||
case CborObject cborObject:
|
||||
var map = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach ((CborValue rawKey, CborValue rawValue) in cborObject)
|
||||
{
|
||||
if (!TryReadString(rawKey, out string key) || string.IsNullOrWhiteSpace(key))
|
||||
key = rawKey.ToString()?.Trim('"') ?? "";
|
||||
if (string.IsNullOrWhiteSpace(key)) continue;
|
||||
|
||||
map[key] = ConvertCborToClr(rawValue);
|
||||
}
|
||||
|
||||
return map;
|
||||
|
||||
case CborArray cborArray:
|
||||
return cborArray.Select(ConvertCborToClr).ToList();
|
||||
|
||||
default:
|
||||
if (TryReadString(value, out string stringValue)) return stringValue;
|
||||
if (TryReadBoolean(value, out bool boolValue)) return boolValue;
|
||||
if (TryReadInt64(value, out long intValue)) return intValue;
|
||||
if (TryReadUInt64(value, out ulong uintValue)) return uintValue;
|
||||
if (TryReadDouble(value, out double doubleValue)) return doubleValue;
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(CborObject source, string name, out CborValue value)
|
||||
{
|
||||
if (source.TryGetValue((CborValue)name, out CborValue? found))
|
||||
{
|
||||
value = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = CborValue.Null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryReadString(CborValue value, out string result)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? parsed = value.Value<string>();
|
||||
if (parsed == null)
|
||||
{
|
||||
result = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
result = parsed;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = "";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadBoolean(CborValue value, out bool result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = value.Value<bool>();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadInt64(CborValue value, out long result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = value.Value<long>();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadUInt64(CborValue value, out ulong result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = value.Value<ulong>();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadDouble(CborValue value, out double result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = value.Value<double>();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractKeyFromRecordId(string recordId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(recordId)) return null;
|
||||
int separator = recordId.IndexOf(':');
|
||||
if (separator < 0) return recordId;
|
||||
|
||||
string key = recordId[(separator + 1)..].Trim();
|
||||
if (key.StartsWith('"') && key.EndsWith('"') && key.Length >= 2)
|
||||
key = key[1..^1];
|
||||
if (key.StartsWith('`') && key.EndsWith('`') && key.Length >= 2)
|
||||
key = key[1..^1];
|
||||
|
||||
return string.IsNullOrWhiteSpace(key) ? null : key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SurrealDb.Net;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
|
||||
{
|
||||
private readonly ILogger<SurrealSnapshotMetadataStore> _logger;
|
||||
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
|
||||
private readonly ISurrealDbClient _surrealClient;
|
||||
|
||||
public SurrealSnapshotMetadataStore(
|
||||
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
|
||||
ICBDDCSurrealSchemaInitializer schemaInitializer,
|
||||
ILogger<SurrealSnapshotMetadataStore>? logger = null)
|
||||
{
|
||||
_ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient));
|
||||
_surrealClient = surrealEmbeddedClient.Client;
|
||||
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
|
||||
_logger = logger ?? NullLogger<SurrealSnapshotMetadataStore>.Instance;
|
||||
}
|
||||
|
||||
public override async Task DropAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Delete(CBDDCSurrealSchemaNames.SnapshotMetadataTable, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<SnapshotMetadata>> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.Select(m => m.ToDomain()).ToList();
|
||||
}
|
||||
|
||||
public override async Task<SnapshotMetadata?> GetSnapshotMetadataAsync(string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(nodeId, cancellationToken);
|
||||
return existing?.ToDomain();
|
||||
}
|
||||
|
||||
public override async Task<string?> GetSnapshotHashAsync(string nodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(nodeId, cancellationToken);
|
||||
return existing?.Hash;
|
||||
}
|
||||
|
||||
public override async Task ImportAsync(IEnumerable<SnapshotMetadata> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(item.NodeId, cancellationToken);
|
||||
RecordId recordId = existing?.Id ?? SurrealStoreRecordIds.SnapshotMetadata(item.NodeId);
|
||||
await UpsertAsync(item, recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task InsertSnapshotMetadataAsync(SnapshotMetadata metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(metadata.NodeId, cancellationToken);
|
||||
RecordId recordId = existing?.Id ?? SurrealStoreRecordIds.SnapshotMetadata(metadata.NodeId);
|
||||
await UpsertAsync(metadata, recordId, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task MergeAsync(IEnumerable<SnapshotMetadata> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var metadata in items)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(metadata.NodeId, cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
await UpsertAsync(metadata, SurrealStoreRecordIds.SnapshotMetadata(metadata.NodeId), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metadata.TimestampPhysicalTime < existing.TimestampPhysicalTime ||
|
||||
(metadata.TimestampPhysicalTime == existing.TimestampPhysicalTime &&
|
||||
metadata.TimestampLogicalCounter <= existing.TimestampLogicalCounter))
|
||||
continue;
|
||||
|
||||
RecordId recordId = existing.Id ?? SurrealStoreRecordIds.SnapshotMetadata(metadata.NodeId);
|
||||
await UpsertAsync(metadata, recordId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task UpdateSnapshotMetadataAsync(SnapshotMetadata existingMeta,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await FindByNodeIdAsync(existingMeta.NodeId, cancellationToken);
|
||||
if (existing == null) return;
|
||||
|
||||
RecordId recordId = existing.Id ?? SurrealStoreRecordIds.SnapshotMetadata(existingMeta.NodeId);
|
||||
await UpsertAsync(existingMeta, recordId, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<IEnumerable<SnapshotMetadata>> GetAllSnapshotMetadataAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExportAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(SnapshotMetadata metadata, RecordId recordId, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
await _surrealClient.Upsert<SurrealSnapshotMetadataRecord, SurrealSnapshotMetadataRecord>(
|
||||
recordId,
|
||||
metadata.ToSurrealRecord(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<SurrealSnapshotMetadataRecord>> SelectAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
var rows = await _surrealClient.Select<SurrealSnapshotMetadataRecord>(
|
||||
CBDDCSurrealSchemaNames.SnapshotMetadataTable,
|
||||
cancellationToken);
|
||||
return rows?.ToList() ?? [];
|
||||
}
|
||||
|
||||
private async Task<SurrealSnapshotMetadataRecord?> FindByNodeIdAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureReadyAsync(cancellationToken);
|
||||
RecordId deterministicId = SurrealStoreRecordIds.SnapshotMetadata(nodeId);
|
||||
var deterministic = await _surrealClient.Select<SurrealSnapshotMetadataRecord>(deterministicId, cancellationToken);
|
||||
if (deterministic != null &&
|
||||
string.Equals(deterministic.NodeId, nodeId, StringComparison.Ordinal))
|
||||
return deterministic;
|
||||
|
||||
var all = await SelectAllAsync(cancellationToken);
|
||||
return all.FirstOrDefault(m => string.Equals(m.NodeId, nodeId, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
294
src/ZB.MOM.WW.CBDDC.Persistence/Surreal/SurrealStoreRecords.cs
Normal file
294
src/ZB.MOM.WW.CBDDC.Persistence/Surreal/SurrealStoreRecords.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SurrealDb.Net.Models;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
using ZB.MOM.WW.CBDDC.Core.Storage;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
|
||||
|
||||
internal static class SurrealStoreRecordIds
|
||||
{
|
||||
public static RecordId Oplog(string hash)
|
||||
{
|
||||
return RecordId.From(CBDDCSurrealSchemaNames.OplogEntriesTable, hash);
|
||||
}
|
||||
|
||||
public static RecordId DocumentMetadata(string collection, string key)
|
||||
{
|
||||
return RecordId.From(
|
||||
CBDDCSurrealSchemaNames.DocumentMetadataTable,
|
||||
CompositeKey("docmeta", collection, key));
|
||||
}
|
||||
|
||||
public static RecordId SnapshotMetadata(string nodeId)
|
||||
{
|
||||
return RecordId.From(CBDDCSurrealSchemaNames.SnapshotMetadataTable, nodeId);
|
||||
}
|
||||
|
||||
public static RecordId RemotePeer(string nodeId)
|
||||
{
|
||||
return RecordId.From(CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable, nodeId);
|
||||
}
|
||||
|
||||
public static RecordId PeerOplogConfirmation(string peerNodeId, string sourceNodeId)
|
||||
{
|
||||
return RecordId.From(
|
||||
CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable,
|
||||
CompositeKey("peerconfirm", peerNodeId, sourceNodeId));
|
||||
}
|
||||
|
||||
private static string CompositeKey(string prefix, string first, string second)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes($"{prefix}\n{first}\n{second}");
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SurrealOplogRecord : Record
|
||||
{
|
||||
[JsonPropertyName("collection")]
|
||||
public string Collection { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("operation")]
|
||||
public int Operation { get; set; }
|
||||
|
||||
[JsonPropertyName("payloadJson")]
|
||||
public string PayloadJson { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("timestampPhysicalTime")]
|
||||
public long TimestampPhysicalTime { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampLogicalCounter")]
|
||||
public int TimestampLogicalCounter { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampNodeId")]
|
||||
public string TimestampNodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("previousHash")]
|
||||
public string PreviousHash { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class SurrealDocumentMetadataRecord : Record
|
||||
{
|
||||
[JsonPropertyName("collection")]
|
||||
public string Collection { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("hlcPhysicalTime")]
|
||||
public long HlcPhysicalTime { get; set; }
|
||||
|
||||
[JsonPropertyName("hlcLogicalCounter")]
|
||||
public int HlcLogicalCounter { get; set; }
|
||||
|
||||
[JsonPropertyName("hlcNodeId")]
|
||||
public string HlcNodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("isDeleted")]
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SurrealRemotePeerRecord : Record
|
||||
{
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("address")]
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public int Type { get; set; }
|
||||
|
||||
[JsonPropertyName("isEnabled")]
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("interestsJson")]
|
||||
public string InterestsJson { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class SurrealPeerOplogConfirmationRecord : Record
|
||||
{
|
||||
[JsonPropertyName("peerNodeId")]
|
||||
public string PeerNodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("sourceNodeId")]
|
||||
public string SourceNodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("confirmedWall")]
|
||||
public long ConfirmedWall { get; set; }
|
||||
|
||||
[JsonPropertyName("confirmedLogic")]
|
||||
public int ConfirmedLogic { get; set; }
|
||||
|
||||
[JsonPropertyName("confirmedHash")]
|
||||
public string ConfirmedHash { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lastConfirmedUtcMs")]
|
||||
public long LastConfirmedUtcMs { get; set; }
|
||||
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SurrealSnapshotMetadataRecord : Record
|
||||
{
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("timestampPhysicalTime")]
|
||||
public long TimestampPhysicalTime { get; set; }
|
||||
|
||||
[JsonPropertyName("timestampLogicalCounter")]
|
||||
public int TimestampLogicalCounter { get; set; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = "";
|
||||
}
|
||||
|
||||
internal static class SurrealStoreRecordMappers
|
||||
{
|
||||
public static SurrealOplogRecord ToSurrealRecord(this OplogEntry entry)
|
||||
{
|
||||
return new SurrealOplogRecord
|
||||
{
|
||||
Collection = entry.Collection,
|
||||
Key = entry.Key,
|
||||
Operation = (int)entry.Operation,
|
||||
PayloadJson = entry.Payload?.GetRawText() ?? "",
|
||||
TimestampPhysicalTime = entry.Timestamp.PhysicalTime,
|
||||
TimestampLogicalCounter = entry.Timestamp.LogicalCounter,
|
||||
TimestampNodeId = entry.Timestamp.NodeId,
|
||||
Hash = entry.Hash,
|
||||
PreviousHash = entry.PreviousHash
|
||||
};
|
||||
}
|
||||
|
||||
public static OplogEntry ToDomain(this SurrealOplogRecord record)
|
||||
{
|
||||
JsonElement? payload = null;
|
||||
if (!string.IsNullOrEmpty(record.PayloadJson))
|
||||
payload = JsonSerializer.Deserialize<JsonElement>(record.PayloadJson);
|
||||
|
||||
return new OplogEntry(
|
||||
record.Collection,
|
||||
record.Key,
|
||||
(OperationType)record.Operation,
|
||||
payload,
|
||||
new HlcTimestamp(record.TimestampPhysicalTime, record.TimestampLogicalCounter, record.TimestampNodeId),
|
||||
record.PreviousHash,
|
||||
record.Hash);
|
||||
}
|
||||
|
||||
public static SurrealDocumentMetadataRecord ToSurrealRecord(this DocumentMetadata metadata)
|
||||
{
|
||||
return new SurrealDocumentMetadataRecord
|
||||
{
|
||||
Collection = metadata.Collection,
|
||||
Key = metadata.Key,
|
||||
HlcPhysicalTime = metadata.UpdatedAt.PhysicalTime,
|
||||
HlcLogicalCounter = metadata.UpdatedAt.LogicalCounter,
|
||||
HlcNodeId = metadata.UpdatedAt.NodeId,
|
||||
IsDeleted = metadata.IsDeleted
|
||||
};
|
||||
}
|
||||
|
||||
public static DocumentMetadata ToDomain(this SurrealDocumentMetadataRecord record)
|
||||
{
|
||||
return new DocumentMetadata(
|
||||
record.Collection,
|
||||
record.Key,
|
||||
new HlcTimestamp(record.HlcPhysicalTime, record.HlcLogicalCounter, record.HlcNodeId),
|
||||
record.IsDeleted);
|
||||
}
|
||||
|
||||
public static SurrealRemotePeerRecord ToSurrealRecord(this RemotePeerConfiguration peer)
|
||||
{
|
||||
return new SurrealRemotePeerRecord
|
||||
{
|
||||
NodeId = peer.NodeId,
|
||||
Address = peer.Address,
|
||||
Type = (int)peer.Type,
|
||||
IsEnabled = peer.IsEnabled,
|
||||
InterestsJson = peer.InterestingCollections.Count > 0
|
||||
? JsonSerializer.Serialize(peer.InterestingCollections)
|
||||
: ""
|
||||
};
|
||||
}
|
||||
|
||||
public static RemotePeerConfiguration ToDomain(this SurrealRemotePeerRecord record)
|
||||
{
|
||||
var result = new RemotePeerConfiguration
|
||||
{
|
||||
NodeId = record.NodeId,
|
||||
Address = record.Address,
|
||||
Type = (PeerType)record.Type,
|
||||
IsEnabled = record.IsEnabled
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(record.InterestsJson))
|
||||
result.InterestingCollections =
|
||||
JsonSerializer.Deserialize<List<string>>(record.InterestsJson) ?? [];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static SurrealPeerOplogConfirmationRecord ToSurrealRecord(this PeerOplogConfirmation confirmation)
|
||||
{
|
||||
return new SurrealPeerOplogConfirmationRecord
|
||||
{
|
||||
PeerNodeId = confirmation.PeerNodeId,
|
||||
SourceNodeId = confirmation.SourceNodeId,
|
||||
ConfirmedWall = confirmation.ConfirmedWall,
|
||||
ConfirmedLogic = confirmation.ConfirmedLogic,
|
||||
ConfirmedHash = confirmation.ConfirmedHash,
|
||||
LastConfirmedUtcMs = confirmation.LastConfirmedUtc.ToUnixTimeMilliseconds(),
|
||||
IsActive = confirmation.IsActive
|
||||
};
|
||||
}
|
||||
|
||||
public static PeerOplogConfirmation ToDomain(this SurrealPeerOplogConfirmationRecord record)
|
||||
{
|
||||
return new PeerOplogConfirmation
|
||||
{
|
||||
PeerNodeId = record.PeerNodeId,
|
||||
SourceNodeId = record.SourceNodeId,
|
||||
ConfirmedWall = record.ConfirmedWall,
|
||||
ConfirmedLogic = record.ConfirmedLogic,
|
||||
ConfirmedHash = record.ConfirmedHash,
|
||||
LastConfirmedUtc = DateTimeOffset.FromUnixTimeMilliseconds(record.LastConfirmedUtcMs),
|
||||
IsActive = record.IsActive
|
||||
};
|
||||
}
|
||||
|
||||
public static SurrealSnapshotMetadataRecord ToSurrealRecord(this SnapshotMetadata metadata)
|
||||
{
|
||||
return new SurrealSnapshotMetadataRecord
|
||||
{
|
||||
NodeId = metadata.NodeId,
|
||||
TimestampPhysicalTime = metadata.TimestampPhysicalTime,
|
||||
TimestampLogicalCounter = metadata.TimestampLogicalCounter,
|
||||
Hash = metadata.Hash
|
||||
};
|
||||
}
|
||||
|
||||
public static SnapshotMetadata ToDomain(this SurrealSnapshotMetadataRecord record)
|
||||
{
|
||||
return new SnapshotMetadata
|
||||
{
|
||||
NodeId = record.NodeId,
|
||||
TimestampPhysicalTime = record.TimestampPhysicalTime,
|
||||
TimestampLogicalCounter = record.TimestampLogicalCounter,
|
||||
Hash = record.Hash
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user