using System.Collections.Concurrent; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Dahomey.Cbor.ObjectModel; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using SurrealDb.Net; using SurrealDb.Net.Models.LiveQuery; using SurrealDb.Net.Models; using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Sync; namespace ZB.MOM.WW.CBDDC.Persistence.Surreal; /// /// Abstract base class for Surreal-backed document stores. /// Handles local oplog/document-metadata persistence and remote-sync suppression. /// /// The application context type used by the concrete store. public abstract class SurrealDocumentStore : IDocumentStore, ISurrealCdcWorkerLifecycle, IDisposable where TContext : class { private static readonly Regex SurrealIdentifierRegex = new("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled); private readonly List _cdcWatchers = new(); private readonly SurrealCdcPollingOptions _cdcPollingOptions; private readonly SemaphoreSlim _cdcWorkerLifecycleGate = new(1, 1); private readonly SemaphoreSlim _liveSelectSignal = new(0, 1); private readonly ISurrealCdcCheckpointPersistence? _checkpointPersistence; private readonly object _clockLock = new(); private readonly HashSet _registeredCollections = new(StringComparer.Ordinal); /// /// Semaphore used to suppress CDC-triggered oplog entry creation during remote sync. /// private readonly SemaphoreSlim _remoteSyncGuard = new(1, 1); private readonly ConcurrentDictionary _suppressedCdcEvents = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _watchedCollections = new( StringComparer.Ordinal); private CancellationTokenSource? _cdcWorkerCts; private Task? _cdcWorkerTask; private CancellationTokenSource? _liveSelectCts; private readonly List _liveSelectTasks = new(); protected readonly IPeerNodeConfigurationProvider _configProvider; protected readonly IConflictResolver _conflictResolver; protected readonly TContext _context; protected readonly ILogger> _logger; protected readonly ICBDDCSurrealSchemaInitializer _schemaInitializer; protected readonly ISurrealDbClient _surrealClient; protected readonly IVectorClockService _vectorClock; // HLC state for local change timestamp generation. private int _logicalCounter; private long _lastPhysicalTime; /// /// Initializes a new instance of the class. /// /// The application context used by the concrete store. /// The embedded Surreal client provider. /// The Surreal schema initializer. /// The peer node configuration provider. /// The vector clock service used for local oplog state. /// Optional conflict resolver; defaults to last-write-wins. /// Optional CDC checkpoint persistence component. /// Optional CDC polling options. /// Optional logger instance. protected SurrealDocumentStore( TContext context, ICBDDCSurrealEmbeddedClient surrealEmbeddedClient, ICBDDCSurrealSchemaInitializer schemaInitializer, IPeerNodeConfigurationProvider configProvider, IVectorClockService vectorClockService, IConflictResolver? conflictResolver = null, ISurrealCdcCheckpointPersistence? checkpointPersistence = null, SurrealCdcPollingOptions? cdcPollingOptions = null, ILogger? logger = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); _ = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient)); _surrealClient = surrealEmbeddedClient.Client; _schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer)); _configProvider = configProvider ?? throw new ArgumentNullException(nameof(configProvider)); _vectorClock = vectorClockService ?? throw new ArgumentNullException(nameof(vectorClockService)); _conflictResolver = conflictResolver ?? new LastWriteWinsConflictResolver(); _checkpointPersistence = checkpointPersistence; _cdcPollingOptions = NormalizePollingOptions(cdcPollingOptions); _logger = CreateTypedLogger(logger); _lastPhysicalTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); _logicalCounter = 0; } /// /// Releases managed resources used by this document store. /// public virtual void Dispose() { try { StopCdcWorkerAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch { } foreach (var watcher in _cdcWatchers) try { watcher.Dispose(); } catch { } _cdcWatchers.Clear(); _cdcWorkerCts?.Dispose(); _liveSelectCts?.Dispose(); _liveSelectSignal.Dispose(); _cdcWorkerLifecycleGate.Dispose(); _remoteSyncGuard.Dispose(); } private static ILogger> CreateTypedLogger(ILogger? logger) { if (logger is null) return NullLogger>.Instance; if (logger is ILogger> typedLogger) return typedLogger; return new ForwardingLogger(logger); } private sealed class ForwardingLogger : ILogger> { private readonly ILogger _inner; /// /// Initializes a new instance of the class. /// /// The logger instance to forward calls to. public ForwardingLogger(ILogger inner) { _inner = inner; } /// public IDisposable? BeginScope(TState state) where TState : notnull { return _inner.BeginScope(state); } /// public bool IsEnabled(LogLevel logLevel) { return _inner.IsEnabled(logLevel); } /// public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { _inner.Log(logLevel, eventId, state, exception, formatter); } } #region CDC Registration private static string BuildSuppressionKey(string collection, string key, OperationType operationType) { return $"{collection}|{key}|{(int)operationType}"; } private void RegisterSuppressedCdcEvent(string collection, string key, OperationType operationType) { string suppressionKey = BuildSuppressionKey(collection, key, operationType); _suppressedCdcEvents.AddOrUpdate(suppressionKey, 1, (_, current) => current + 1); } private bool TryConsumeSuppressedCdcEvent(string collection, string key, OperationType operationType) { string suppressionKey = BuildSuppressionKey(collection, key, operationType); while (true) { if (!_suppressedCdcEvents.TryGetValue(suppressionKey, out int current)) return false; if (current <= 1) return _suppressedCdcEvents.TryRemove(suppressionKey, out _); if (_suppressedCdcEvents.TryUpdate(suppressionKey, current - 1, current)) return true; } } private bool IsCdcPollingWorkerActiveForCollection(string collection) { return IsCdcWorkerRunning && _watchedCollections.ContainsKey(collection); } /// /// Registers a watchable collection for local change tracking. /// /// The entity type emitted by the watch source. /// Logical collection name used by oplog and metadata records. /// Watchable change source. /// Function used to resolve the entity key. /// Whether to subscribe to in-memory collection events. protected void WatchCollection( string collectionName, ISurrealWatchableCollection collection, Func keySelector, bool subscribeForInMemoryEvents = true) where TEntity : class { if (string.IsNullOrWhiteSpace(collectionName)) throw new ArgumentException("Collection name is required.", nameof(collectionName)); ArgumentNullException.ThrowIfNull(collection); ArgumentNullException.ThrowIfNull(keySelector); _registeredCollections.Add(collectionName); string tableName = ResolveSurrealTableName(collection, collectionName); _watchedCollections[collectionName] = new WatchedCollectionRegistration(collectionName, tableName); if (!subscribeForInMemoryEvents) return; var watcher = collection.Subscribe(new CdcObserver(collectionName, keySelector, this)); _cdcWatchers.Add(watcher); } private sealed class CdcObserver : IObserver> where TEntity : class { private readonly string _collectionName; private readonly Func _keySelector; private readonly SurrealDocumentStore _store; /// /// Initializes a new instance of the class. /// /// The logical collection name. /// The key selector for observed entities. /// The owning document store. public CdcObserver( string collectionName, Func keySelector, SurrealDocumentStore store) { _collectionName = collectionName; _keySelector = keySelector; _store = store; } /// public void OnNext(SurrealCollectionChange changeEvent) { if (_store.IsCdcPollingWorkerActiveForCollection(_collectionName)) return; var operationType = changeEvent.OperationType == OperationType.Delete ? OperationType.Delete : OperationType.Put; string entityId = changeEvent.DocumentId ?? ""; if (operationType == OperationType.Put && changeEvent.Entity != null) { string selectedKey = _keySelector(changeEvent.Entity); if (!string.IsNullOrWhiteSpace(selectedKey)) entityId = selectedKey; } if (operationType == OperationType.Delete && string.IsNullOrWhiteSpace(entityId)) return; if (_store.TryConsumeSuppressedCdcEvent(_collectionName, entityId, operationType)) return; if (_store._remoteSyncGuard.CurrentCount == 0) return; if (operationType == OperationType.Delete) { _store.OnLocalChangeDetectedAsync(_collectionName, entityId, OperationType.Delete, null) .GetAwaiter().GetResult(); return; } if (changeEvent.Entity == null) return; var content = JsonSerializer.SerializeToElement(changeEvent.Entity); string key = _keySelector(changeEvent.Entity); if (string.IsNullOrWhiteSpace(key)) key = entityId; if (string.IsNullOrWhiteSpace(key)) return; _store.OnLocalChangeDetectedAsync(_collectionName, key, OperationType.Put, content) .GetAwaiter().GetResult(); } /// public void OnError(Exception error) { } /// public void OnCompleted() { } } private static string ResolveSurrealTableName( ISurrealWatchableCollection collection, string fallbackCollectionName) where TEntity : class { Type collectionType = collection.GetType(); const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; foreach (string memberName in new[] { "TableName", "_tableName", "tableName" }) { PropertyInfo? property = collectionType.GetProperty(memberName, flags); if (property?.CanRead == true && property.GetValue(collection) is string propertyValue && !string.IsNullOrWhiteSpace(propertyValue)) return propertyValue; FieldInfo? field = collectionType.GetField(memberName, flags); if (field?.GetValue(collection) is string fieldValue && !string.IsNullOrWhiteSpace(fieldValue)) return fieldValue; } return fallbackCollectionName; } private static SurrealCdcPollingOptions NormalizePollingOptions(SurrealCdcPollingOptions? options) { TimeSpan interval = options?.PollInterval ?? TimeSpan.FromMilliseconds(250); if (interval <= TimeSpan.Zero) interval = TimeSpan.FromMilliseconds(250); int batchSize = options?.BatchSize ?? 100; if (batchSize <= 0) batchSize = 100; TimeSpan liveReconnectDelay = options?.LiveSelectReconnectDelay ?? TimeSpan.FromSeconds(2); if (liveReconnectDelay <= TimeSpan.Zero) liveReconnectDelay = TimeSpan.FromSeconds(2); return new SurrealCdcPollingOptions { Enabled = options?.Enabled ?? true, PollInterval = interval, BatchSize = batchSize, EnableLiveSelectAccelerator = options?.EnableLiveSelectAccelerator ?? true, LiveSelectReconnectDelay = liveReconnectDelay }; } private readonly record struct WatchedCollectionRegistration( string CollectionName, string TableName); protected readonly record struct PendingCursorCheckpoint( string TableName, ulong Cursor); #endregion #region CDC Worker Lifecycle /// public bool IsCdcWorkerRunning => _cdcWorkerTask != null && !_cdcWorkerTask.IsCompleted; /// public async Task StartCdcWorkerAsync(CancellationToken cancellationToken = default) { if (!_cdcPollingOptions.Enabled) { _logger.LogDebug("Surreal CDC worker start skipped because polling is disabled."); return; } if (_checkpointPersistence == null) { _logger.LogDebug("Surreal CDC worker start skipped because checkpoint persistence is not configured."); return; } await _cdcWorkerLifecycleGate.WaitAsync(cancellationToken); try { cancellationToken.ThrowIfCancellationRequested(); if (IsCdcWorkerRunning) return; await EnsureReadyAsync(cancellationToken); StartLiveSelectAcceleratorsUnsafe(); _cdcWorkerCts = new CancellationTokenSource(); _cdcWorkerTask = Task.Run(() => RunCdcWorkerAsync(_cdcWorkerCts.Token), CancellationToken.None); _logger.LogInformation( "Started Surreal CDC worker with interval {IntervalMs} ms, batch size {BatchSize}, live accelerator {LiveAccelerator}.", _cdcPollingOptions.PollInterval.TotalMilliseconds, _cdcPollingOptions.BatchSize, _cdcPollingOptions.EnableLiveSelectAccelerator); } finally { _cdcWorkerLifecycleGate.Release(); } } /// public async Task PollCdcOnceAsync(CancellationToken cancellationToken = default) { if (!_cdcPollingOptions.Enabled) return; if (_checkpointPersistence == null) return; if (_watchedCollections.IsEmpty) return; await EnsureReadyAsync(cancellationToken); await PollWatchedCollectionsOnceAsync(cancellationToken); } /// public async Task StopCdcWorkerAsync(CancellationToken cancellationToken = default) { Task? workerTask; CancellationTokenSource? workerCts; Task[] liveSelectTasks; CancellationTokenSource? liveSelectCts; await _cdcWorkerLifecycleGate.WaitAsync(cancellationToken); try { workerTask = _cdcWorkerTask; workerCts = _cdcWorkerCts; _cdcWorkerTask = null; _cdcWorkerCts = null; liveSelectTasks = _liveSelectTasks.ToArray(); _liveSelectTasks.Clear(); liveSelectCts = _liveSelectCts; _liveSelectCts = null; } finally { _cdcWorkerLifecycleGate.Release(); } if (workerTask == null) { workerCts?.Dispose(); if (liveSelectTasks.Length == 0) { liveSelectCts?.Dispose(); return; } } try { workerCts?.Cancel(); liveSelectCts?.Cancel(); if (workerTask != null) await workerTask.WaitAsync(cancellationToken); if (liveSelectTasks.Length > 0) { Task waitAll = Task.WhenAll(liveSelectTasks); try { await waitAll.WaitAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch { } } } catch (OperationCanceledException) when ((workerTask?.IsCanceled ?? false) || cancellationToken.IsCancellationRequested) { } finally { workerCts?.Dispose(); liveSelectCts?.Dispose(); } } private async Task RunCdcWorkerAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) try { await PollCdcOnceAsync(cancellationToken); if (!_cdcPollingOptions.EnableLiveSelectAccelerator || _liveSelectCts == null || _liveSelectTasks.Count == 0) { await Task.Delay(_cdcPollingOptions.PollInterval, cancellationToken); continue; } Task delayTask = Task.Delay(_cdcPollingOptions.PollInterval, cancellationToken); Task signalTask = _liveSelectSignal.WaitAsync(cancellationToken); await Task.WhenAny(delayTask, signalTask); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception exception) { _logger.LogError(exception, "Surreal CDC worker polling iteration failed."); try { await Task.Delay(_cdcPollingOptions.PollInterval, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } } _logger.LogDebug("Stopped Surreal CDC worker."); } private void StartLiveSelectAcceleratorsUnsafe() { if (!_cdcPollingOptions.EnableLiveSelectAccelerator) return; if (_watchedCollections.IsEmpty) return; if (_liveSelectCts != null) return; _liveSelectCts = new CancellationTokenSource(); _liveSelectTasks.Clear(); foreach (WatchedCollectionRegistration watched in _watchedCollections.Values .OrderBy(v => v.CollectionName, StringComparer.Ordinal)) _liveSelectTasks.Add(Task.Run( () => RunLiveSelectAcceleratorAsync(watched, _liveSelectCts.Token), CancellationToken.None)); } private async Task RunLiveSelectAcceleratorAsync( WatchedCollectionRegistration watched, CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { try { await using var liveQuery = await _surrealClient.LiveTable(watched.TableName, false, cancellationToken); await foreach (SurrealDbLiveQueryResponse response in liveQuery.GetResults(cancellationToken)) { if (cancellationToken.IsCancellationRequested) break; if (response is SurrealDbLiveQueryOpenResponse) continue; if (response is SurrealDbLiveQueryCloseResponse closeResponse) { _logger.LogDebug( "LIVE SELECT stream closed for table {Table} with reason {Reason}.", watched.TableName, closeResponse.Reason); break; } SignalLiveSelectWake(); } } catch (NotSupportedException) { _logger.LogDebug( "LIVE SELECT accelerator is not supported for table {Table}; fallback remains polling-only.", watched.TableName); return; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception exception) { _logger.LogDebug( exception, "LIVE SELECT accelerator loop failed for table {Table}; retrying.", watched.TableName); } try { await Task.Delay(_cdcPollingOptions.LiveSelectReconnectDelay, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } } } private void SignalLiveSelectWake() { if (_liveSelectSignal.CurrentCount > 0) return; try { _liveSelectSignal.Release(); } catch (SemaphoreFullException) { } } private async Task PollWatchedCollectionsOnceAsync(CancellationToken cancellationToken) { if (_watchedCollections.IsEmpty) return; foreach (WatchedCollectionRegistration watched in _watchedCollections.Values .OrderBy(v => v.CollectionName, StringComparer.Ordinal)) await PollCollectionChangesAsync(watched, cancellationToken); } private async Task PollCollectionChangesAsync( WatchedCollectionRegistration watched, CancellationToken cancellationToken) { if (!SurrealIdentifierRegex.IsMatch(watched.TableName)) { _logger.LogDebug( "Skipping CDC polling for collection {Collection} because table name '{Table}' is not a valid Surreal identifier.", watched.CollectionName, watched.TableName); return; } ulong cursor = await ReadCursorCheckpointAsync(watched.TableName, cancellationToken); while (!cancellationToken.IsCancellationRequested) { IReadOnlyList rows; try { rows = await QueryChangeRowsAsync(watched.TableName, cursor, _cdcPollingOptions.BatchSize, cancellationToken); } catch (Exception exception) { if (cursor > 0 && IsLikelyChangefeedRetentionBoundary(exception)) _logger.LogWarning( exception, "SHOW CHANGES query failed for table {Table} at cursor {Cursor}. " + "The cursor may be outside configured changefeed retention; checkpoint remains unchanged until replay is re-established.", watched.TableName, cursor); else _logger.LogDebug( exception, "SHOW CHANGES query failed for table {Table}.", watched.TableName); return; } if (rows.Count == 0) return; foreach (SurrealPolledChangeRow row in rows) { ulong nextCursor = BuildNextCursor(row.Versionstamp); if (row.Changes.Count == 0) { await WriteCursorCheckpointAsync(watched.TableName, nextCursor, cancellationToken); cursor = nextCursor; continue; } for (var i = 0; i < row.Changes.Count; i++) { SurrealPolledChange change = row.Changes[i]; PendingCursorCheckpoint? pendingCursorCheckpoint = i == row.Changes.Count - 1 ? new PendingCursorCheckpoint(watched.TableName, nextCursor) : null; await OnLocalChangeDetectedAsync( watched.CollectionName, change.Key, change.OperationType, change.Content, pendingCursorCheckpoint, cancellationToken); } cursor = nextCursor; } if (rows.Count < _cdcPollingOptions.BatchSize) return; } } private async Task> QueryChangeRowsAsync( string tableName, ulong cursor, int batchSize, CancellationToken cancellationToken) { string query = $"SHOW CHANGES FOR TABLE {tableName} SINCE {cursor} LIMIT {batchSize};"; var response = await _surrealClient.RawQuery(query, cancellationToken: cancellationToken); response.EnsureAllOks(); List rows; try { rows = response.GetValues(0).ToList(); } catch { return []; } return SurrealShowChangesCborDecoder.DecodeRows(rows, tableName); } private async Task ReadCursorCheckpointAsync(string tableName, CancellationToken cancellationToken) { if (_checkpointPersistence == null) return 0; var checkpoint = await _checkpointPersistence.GetCheckpointAsync( BuildCursorCheckpointConsumerId(tableName), cancellationToken); if (checkpoint?.VersionstampCursor is > 0) return (ulong)checkpoint.VersionstampCursor.Value; if (checkpoint == null || checkpoint.Timestamp.PhysicalTime < 0) return 0; return (ulong)checkpoint.Timestamp.PhysicalTime; } private async Task WriteCursorCheckpointAsync( string tableName, ulong cursor, CancellationToken cancellationToken) { if (_checkpointPersistence == null) return; long encodedCursor = cursor > long.MaxValue ? long.MaxValue : (long)cursor; await _checkpointPersistence.UpsertCheckpointAsync( new HlcTimestamp(encodedCursor, 0, "surreal-cdc"), "", BuildCursorCheckpointConsumerId(tableName), cancellationToken, encodedCursor); } private string BuildCursorCheckpointConsumerId(string tableName) { string baseConsumerId = "default"; if (TryGetCheckpointSettings(out _, out string configuredConsumerId)) baseConsumerId = configuredConsumerId; return BuildCursorCheckpointConsumerId(tableName, baseConsumerId); } private static string BuildCursorCheckpointConsumerId(string tableName, string baseConsumerId) { return $"{baseConsumerId}:show_changes_cursor:{tableName}"; } private static ulong BuildNextCursor(ulong versionstamp) { ulong majorCursor = versionstamp >> 16; if (majorCursor == 0) majorCursor = versionstamp; return majorCursor + 1; } private static bool IsLikelyChangefeedRetentionBoundary(Exception exception) { string message = exception.ToString(); if (string.IsNullOrWhiteSpace(message)) return false; string normalized = message.ToLowerInvariant(); return normalized.Contains("retention", StringComparison.Ordinal) || (normalized.Contains("versionstamp", StringComparison.Ordinal) && normalized.Contains("outside", StringComparison.Ordinal)) || (normalized.Contains("change", StringComparison.Ordinal) && normalized.Contains("feed", StringComparison.Ordinal) && normalized.Contains("since", StringComparison.Ordinal)) || (normalized.Contains("history", StringComparison.Ordinal) && normalized.Contains("change", StringComparison.Ordinal)); } #endregion #region Abstract Methods - Implemented by subclass /// /// Applies JSON content to a single entity in the backing store. /// /// The collection name. /// The document key. /// The JSON payload to persist. /// The cancellation token. protected abstract Task ApplyContentToEntityAsync( string collection, string key, JsonElement content, CancellationToken cancellationToken); /// /// Applies JSON content to multiple entities in the backing store. /// /// The documents to persist. /// The cancellation token. protected abstract Task ApplyContentToEntitiesBatchAsync( IEnumerable<(string Collection, string Key, JsonElement Content)> documents, CancellationToken cancellationToken); /// /// Gets a single entity as JSON content. /// /// The collection name. /// The document key. /// The cancellation token. /// The JSON content when found; otherwise . protected abstract Task GetEntityAsJsonAsync( string collection, string key, CancellationToken cancellationToken); /// /// Removes a single entity from the backing store. /// /// The collection name. /// The document key. /// The cancellation token. protected abstract Task RemoveEntityAsync( string collection, string key, CancellationToken cancellationToken); /// /// Removes multiple entities from the backing store. /// /// The documents to remove. /// The cancellation token. protected abstract Task RemoveEntitiesBatchAsync( IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken); /// /// Gets all entities from a collection as JSON content. /// /// The collection name. /// The cancellation token. /// A sequence of key/content pairs. protected abstract Task> GetAllEntitiesAsJsonAsync( string collection, CancellationToken cancellationToken); #endregion #region IDocumentStore Implementation /// public IEnumerable InterestedCollection => _registeredCollections; /// public async Task GetDocumentAsync( string collection, string key, CancellationToken cancellationToken = default) { var content = await GetEntityAsJsonAsync(collection, key, cancellationToken); if (content == null) return null; var timestamp = new HlcTimestamp(0, 0, ""); return new Document(collection, key, content.Value, timestamp, false); } /// public async Task> GetDocumentsByCollectionAsync( string collection, CancellationToken cancellationToken = default) { var entities = await GetAllEntitiesAsJsonAsync(collection, cancellationToken); var timestamp = new HlcTimestamp(0, 0, ""); return entities.Select(e => new Document(collection, e.Key, e.Content, timestamp, false)); } /// public async Task> GetDocumentsAsync( List<(string Collection, string Key)> documentKeys, CancellationToken cancellationToken) { var documents = new List(); foreach ((string collection, string key) in documentKeys) { var document = await GetDocumentAsync(collection, key, cancellationToken); if (document != null) documents.Add(document); } return documents; } /// public async Task PutDocumentAsync(Document document, CancellationToken cancellationToken = default) { await _remoteSyncGuard.WaitAsync(cancellationToken); try { await PutDocumentInternalAsync(document, cancellationToken); } finally { _remoteSyncGuard.Release(); } return true; } private async Task PutDocumentInternalAsync(Document document, CancellationToken cancellationToken) { RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); await ApplyContentToEntityAsync(document.Collection, document.Key, document.Content, cancellationToken); } /// public async Task UpdateBatchDocumentsAsync( IEnumerable documents, CancellationToken cancellationToken = default) { var documentList = documents.ToList(); await _remoteSyncGuard.WaitAsync(cancellationToken); try { foreach (var document in documentList) RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); await ApplyContentToEntitiesBatchAsync( documentList.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken); } finally { _remoteSyncGuard.Release(); } return true; } /// public async Task InsertBatchDocumentsAsync( IEnumerable documents, CancellationToken cancellationToken = default) { var documentList = documents.ToList(); await _remoteSyncGuard.WaitAsync(cancellationToken); try { foreach (var document in documentList) RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); await ApplyContentToEntitiesBatchAsync( documentList.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken); } finally { _remoteSyncGuard.Release(); } return true; } /// public async Task DeleteDocumentAsync( string collection, string key, CancellationToken cancellationToken = default) { await _remoteSyncGuard.WaitAsync(cancellationToken); try { await DeleteDocumentInternalAsync(collection, key, cancellationToken); } finally { _remoteSyncGuard.Release(); } return true; } private async Task DeleteDocumentInternalAsync( string collection, string key, CancellationToken cancellationToken) { RegisterSuppressedCdcEvent(collection, key, OperationType.Delete); await RemoveEntityAsync(collection, key, cancellationToken); } /// public async Task DeleteBatchDocumentsAsync( IEnumerable documentKeys, CancellationToken cancellationToken = default) { var parsedKeys = new List<(string Collection, string Key)>(); foreach (string key in documentKeys) { string[] parts = key.Split('/'); if (parts.Length == 2) parsedKeys.Add((parts[0], parts[1])); else _logger.LogWarning("Invalid document key format: {Key}", key); } if (parsedKeys.Count == 0) return true; await _remoteSyncGuard.WaitAsync(cancellationToken); try { foreach ((string collection, string key) in parsedKeys) RegisterSuppressedCdcEvent(collection, key, OperationType.Delete); await RemoveEntitiesBatchAsync(parsedKeys, cancellationToken); } finally { _remoteSyncGuard.Release(); } return true; } /// public async Task MergeAsync(Document incoming, CancellationToken cancellationToken = default) { var existing = await GetDocumentAsync(incoming.Collection, incoming.Key, cancellationToken); if (existing == null) { await PutDocumentInternalAsync(incoming, cancellationToken); return incoming; } var resolution = _conflictResolver.Resolve(existing, new OplogEntry( incoming.Collection, incoming.Key, OperationType.Put, incoming.Content, incoming.UpdatedAt, "")); if (resolution.ShouldApply && resolution.MergedDocument != null) { await PutDocumentInternalAsync(resolution.MergedDocument, cancellationToken); return resolution.MergedDocument; } return existing; } #endregion #region ISnapshotable Implementation /// public async Task DropAsync(CancellationToken cancellationToken = default) { foreach (string collection in InterestedCollection) { var entities = await GetAllEntitiesAsJsonAsync(collection, cancellationToken); foreach ((string key, var _) in entities) await RemoveEntityAsync(collection, key, cancellationToken); } } /// public async Task> ExportAsync(CancellationToken cancellationToken = default) { var documents = new List(); foreach (string collection in InterestedCollection) { var collectionDocuments = await GetDocumentsByCollectionAsync(collection, cancellationToken); documents.AddRange(collectionDocuments); } return documents; } /// public async Task ImportAsync(IEnumerable items, CancellationToken cancellationToken = default) { var documents = items.ToList(); await _remoteSyncGuard.WaitAsync(cancellationToken); try { foreach (var document in documents) RegisterSuppressedCdcEvent(document.Collection, document.Key, OperationType.Put); await ApplyContentToEntitiesBatchAsync( documents.Select(d => (d.Collection, d.Key, d.Content)), cancellationToken); } finally { _remoteSyncGuard.Release(); } } /// public async Task MergeAsync(IEnumerable items, CancellationToken cancellationToken = default) { await _remoteSyncGuard.WaitAsync(cancellationToken); try { foreach (var document in items) await MergeAsync(document, cancellationToken); } finally { _remoteSyncGuard.Release(); } } #endregion #region Oplog Management /// /// Returns true when remote sync is in progress and local CDC must be suppressed. /// protected bool IsRemoteSyncInProgress => _remoteSyncGuard.CurrentCount == 0; /// /// Handles a local collection change and records oplog/metadata when not suppressed. /// /// The collection name. /// The document key. /// The detected operation type. /// Optional JSON content for non-delete operations. /// Optional pending cursor checkpoint to persist. /// The cancellation token. protected async Task OnLocalChangeDetectedAsync( string collection, string key, OperationType operationType, JsonElement? content, PendingCursorCheckpoint? pendingCursorCheckpoint = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(collection)) return; if (string.IsNullOrWhiteSpace(key)) return; if (TryConsumeSuppressedCdcEvent(collection, key, operationType)) return; if (IsRemoteSyncInProgress) return; await CreateOplogEntryAsync(collection, key, operationType, content, pendingCursorCheckpoint, cancellationToken); } private HlcTimestamp GenerateTimestamp(string nodeId) { lock (_clockLock) { long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (now > _lastPhysicalTime) { _lastPhysicalTime = now; _logicalCounter = 0; } else { _logicalCounter++; } return new HlcTimestamp(_lastPhysicalTime, _logicalCounter, nodeId); } } private async Task CreateOplogEntryAsync( string collection, string key, OperationType operationType, JsonElement? content, PendingCursorCheckpoint? pendingCursorCheckpoint, CancellationToken cancellationToken) { await EnsureReadyAsync(cancellationToken); var config = await _configProvider.GetConfiguration(); string nodeId = config.NodeId ?? ""; string previousHash = _vectorClock.GetLastHash(nodeId) ?? await QueryLastHashForNodeAsync(nodeId, cancellationToken) ?? string.Empty; var timestamp = GenerateTimestamp(nodeId); var oplogEntry = new OplogEntry( collection, key, operationType, content, timestamp, previousHash); var metadata = new DocumentMetadata(collection, key, timestamp, operationType == OperationType.Delete); await PersistOplogAndMetadataAtomicallyAsync(oplogEntry, metadata, pendingCursorCheckpoint, cancellationToken); _vectorClock.Update(oplogEntry); _logger.LogDebug( "Created local oplog entry: {Operation} {Collection}/{Key} at {Timestamp} (hash: {Hash})", operationType, collection, key, timestamp, oplogEntry.Hash); } private async Task PersistOplogAndMetadataAtomicallyAsync( OplogEntry oplogEntry, DocumentMetadata metadata, PendingCursorCheckpoint? pendingCursorCheckpoint, CancellationToken cancellationToken) { var parameters = new Dictionary { ["oplogRecordId"] = SurrealStoreRecordIds.Oplog(oplogEntry.Hash), ["oplogRecord"] = oplogEntry.ToSurrealRecord(), ["metadataRecordId"] = SurrealStoreRecordIds.DocumentMetadata( metadata.Collection, metadata.Key, metadata.DatasetId), ["metadataRecord"] = metadata.ToSurrealRecord() }; var sqlBuilder = new StringBuilder(); sqlBuilder.AppendLine("BEGIN TRANSACTION;"); sqlBuilder.AppendLine("UPSERT $oplogRecordId CONTENT $oplogRecord;"); sqlBuilder.AppendLine("UPSERT $metadataRecordId CONTENT $metadataRecord;"); bool localCheckpointWrittenInTransaction = TryBuildCheckpointTransactionPayload( oplogEntry, out RecordId localCheckpointRecordId, out Dictionary localCheckpointRecord); if (localCheckpointWrittenInTransaction) { parameters["localCheckpointRecordId"] = localCheckpointRecordId; parameters["localCheckpointRecord"] = localCheckpointRecord; sqlBuilder.AppendLine("UPSERT $localCheckpointRecordId CONTENT $localCheckpointRecord;"); } bool cursorCheckpointWrittenInTransaction = TryBuildCursorCheckpointTransactionPayload( pendingCursorCheckpoint, out RecordId cursorCheckpointRecordId, out Dictionary cursorCheckpointRecord); if (cursorCheckpointWrittenInTransaction) { parameters["cursorCheckpointRecordId"] = cursorCheckpointRecordId; parameters["cursorCheckpointRecord"] = cursorCheckpointRecord; sqlBuilder.AppendLine("UPSERT $cursorCheckpointRecordId CONTENT $cursorCheckpointRecord;"); } sqlBuilder.AppendLine("COMMIT TRANSACTION;"); string sql = sqlBuilder.ToString(); var response = await _surrealClient.RawQuery(sql, parameters, cancellationToken); response.EnsureAllOks(); if (!localCheckpointWrittenInTransaction && _checkpointPersistence != null) await _checkpointPersistence.AdvanceCheckpointAsync(oplogEntry, cancellationToken: cancellationToken); if (pendingCursorCheckpoint is not null && !cursorCheckpointWrittenInTransaction) await WriteCursorCheckpointAsync( pendingCursorCheckpoint.Value.TableName, pendingCursorCheckpoint.Value.Cursor, cancellationToken); } private bool TryBuildCheckpointTransactionPayload( OplogEntry oplogEntry, out RecordId checkpointRecordId, out Dictionary checkpointRecord) { checkpointRecordId = RecordId.From(CBDDCSurrealSchemaNames.DocumentMetadataTable, "__unused__"); checkpointRecord = new Dictionary(); if (!TryGetCheckpointSettings(out string checkpointTable, out string consumerId)) return false; const string datasetId = DatasetId.Primary; string consumerKey = ComputeConsumerKey(datasetId, consumerId); checkpointRecordId = RecordId.From(checkpointTable, consumerKey); checkpointRecord = new Dictionary { ["datasetId"] = datasetId, ["consumerId"] = consumerId, ["timestampPhysicalTime"] = oplogEntry.Timestamp.PhysicalTime, ["timestampLogicalCounter"] = oplogEntry.Timestamp.LogicalCounter, ["timestampNodeId"] = oplogEntry.Timestamp.NodeId, ["lastHash"] = oplogEntry.Hash, ["updatedUtcMs"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; return true; } private bool TryBuildCursorCheckpointTransactionPayload( PendingCursorCheckpoint? pendingCursorCheckpoint, out RecordId checkpointRecordId, out Dictionary checkpointRecord) { checkpointRecordId = RecordId.From(CBDDCSurrealSchemaNames.DocumentMetadataTable, "__unused__"); checkpointRecord = new Dictionary(); if (pendingCursorCheckpoint is null) return false; if (!TryGetCheckpointSettings(out string checkpointTable, out string consumerId)) return false; string cursorConsumerId = BuildCursorCheckpointConsumerId( pendingCursorCheckpoint.Value.TableName, consumerId); long encodedCursor = pendingCursorCheckpoint.Value.Cursor > long.MaxValue ? long.MaxValue : (long)pendingCursorCheckpoint.Value.Cursor; const string datasetId = DatasetId.Primary; string consumerKey = ComputeConsumerKey(datasetId, cursorConsumerId); checkpointRecordId = RecordId.From(checkpointTable, consumerKey); checkpointRecord = new Dictionary { ["datasetId"] = datasetId, ["consumerId"] = cursorConsumerId, ["timestampPhysicalTime"] = encodedCursor, ["timestampLogicalCounter"] = 0, ["timestampNodeId"] = "surreal-cdc", ["lastHash"] = "", ["versionstampCursor"] = encodedCursor, ["updatedUtcMs"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; return true; } private bool TryGetCheckpointSettings(out string checkpointTable, out string consumerId) { checkpointTable = string.Empty; consumerId = string.Empty; if (_checkpointPersistence == null) return false; if (!TryGetPrivateField(_checkpointPersistence, "_enabled", out bool enabled) || !enabled) return false; if (!TryGetPrivateField(_checkpointPersistence, "_checkpointTable", out string? resolvedCheckpointTable) || string.IsNullOrWhiteSpace(resolvedCheckpointTable)) return false; if (!TryGetPrivateField(_checkpointPersistence, "_defaultConsumerId", out string? resolvedConsumerId) || string.IsNullOrWhiteSpace(resolvedConsumerId)) return false; if (!SurrealIdentifierRegex.IsMatch(resolvedCheckpointTable)) return false; checkpointTable = resolvedCheckpointTable; consumerId = resolvedConsumerId; return true; } private static string ComputeConsumerKey(string datasetId, string consumerId) { byte[] bytes = Encoding.UTF8.GetBytes($"{datasetId}\n{consumerId}"); return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } private static bool TryGetPrivateField(object source, string fieldName, out TValue value) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; FieldInfo? fieldInfo = source.GetType().GetField(fieldName, flags); if (fieldInfo?.GetValue(source) is TValue typedValue) { value = typedValue; return true; } value = default!; return false; } private async Task QueryLastHashForNodeAsync(string nodeId, CancellationToken cancellationToken) { var all = await _surrealClient.Select( CBDDCSurrealSchemaNames.OplogEntriesTable, cancellationToken); var latest = all? .Where(o => string.Equals(o.TimestampNodeId, nodeId, StringComparison.Ordinal)) .OrderByDescending(o => o.TimestampPhysicalTime) .ThenByDescending(o => o.TimestampLogicalCounter) .FirstOrDefault(); return latest?.Hash; } private async Task EnsureReadyAsync(CancellationToken cancellationToken) { await _schemaInitializer.EnsureInitializedAsync(cancellationToken); } /// /// Marks the start of remote sync operations and suppresses local CDC loopback. /// public IDisposable BeginRemoteSync() { _remoteSyncGuard.Wait(); return new RemoteSyncScope(_remoteSyncGuard); } private sealed class RemoteSyncScope : IDisposable { private readonly SemaphoreSlim _guard; private int _disposed; /// /// Initializes a new instance of the class. /// /// The guard semaphore to release on dispose. public RemoteSyncScope(SemaphoreSlim guard) { _guard = guard; } /// public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; _guard.Release(); } } #endregion }