Replace BLite with Surreal embedded persistence
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m21s

This commit is contained in:
Joseph Doherty
2026-02-22 05:21:53 -05:00
parent 7ebc2cb567
commit 9c2a77dc3c
56 changed files with 6613 additions and 3177 deletions

View File

@@ -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;
}
}

View File

@@ -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>();
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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";
}
}

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
};
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

View 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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View 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
};
}
}