using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace ZB.MOM.WW.CBDDC.Persistence.Surreal; /// /// Initializes Surreal schema objects required by CBDDC persistence stores. /// 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 _logger; private readonly string _checkpointTable; private readonly string _changefeedRetentionLiteral; private bool _initialized; /// /// Initializes a new instance of the class. /// /// Surreal client abstraction. /// Embedded options. /// Optional logger. public CBDDCSurrealSchemaInitializer( ICBDDCSurrealEmbeddedClient surrealClient, CBDDCSurrealEmbeddedOptions options, ILogger? logger = null) { _surrealClient = surrealClient ?? throw new ArgumentNullException(nameof(surrealClient)); _logger = logger ?? NullLogger.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)); } /// 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 datasetId, hash UNIQUE; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.OplogHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.OplogEntriesTable} COLUMNS datasetId, timestampPhysicalTime, timestampLogicalCounter, timestampNodeId; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.OplogCollectionIndex} ON TABLE {CBDDCSurrealSchemaNames.OplogEntriesTable} COLUMNS datasetId, collection; DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.SnapshotMetadataTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral}; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.SnapshotNodeIdIndex} ON TABLE {CBDDCSurrealSchemaNames.SnapshotMetadataTable} COLUMNS datasetId, nodeId UNIQUE; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.SnapshotHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.SnapshotMetadataTable} COLUMNS datasetId, 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 datasetId, collection, key UNIQUE; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.DocumentMetadataHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.DocumentMetadataTable} COLUMNS datasetId, hlcPhysicalTime, hlcLogicalCounter, hlcNodeId; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.DocumentMetadataCollectionIndex} ON TABLE {CBDDCSurrealSchemaNames.DocumentMetadataTable} COLUMNS datasetId, collection; DEFINE TABLE OVERWRITE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} SCHEMAFULL CHANGEFEED {_changefeedRetentionLiteral}; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerConfirmationPairIndex} ON TABLE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} COLUMNS datasetId, peerNodeId, sourceNodeId UNIQUE; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerConfirmationActiveIndex} ON TABLE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} COLUMNS datasetId, isActive; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.PeerConfirmationSourceHlcIndex} ON TABLE {CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable} COLUMNS datasetId, sourceNodeId, confirmedWall, confirmedLogic; DEFINE TABLE OVERWRITE {_checkpointTable} SCHEMAFULL; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.CdcCheckpointConsumerIndex} ON TABLE {_checkpointTable} COLUMNS datasetId, consumerId UNIQUE; DEFINE INDEX OVERWRITE {CBDDCSurrealSchemaNames.CdcCheckpointVersionstampIndex} ON TABLE {_checkpointTable} COLUMNS datasetId, 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"; } }