using System.Data; using System.Reflection; using System.Text.RegularExpressions; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// Lazily applies the embedded, idempotent schema scripts once per instance, under a SQL application lock /// so concurrent nodes/instances serialise. A failure is NOT latched: an after /// retries (). Within that window the last failure is re-thrown without touching /// SQL, so a missing schema (or a SQL outage) does not serialise every write, read and alarm event behind one /// semaphore and one round-trip each. /// With AutoCreateSchema=false it only verifies MAX(Version) >= . /// Schema v1 is accepted (Gitea #674). A database at version 1 (no SampleModified) still serves /// live writes, raw / processed / at-time reads, alarm events and retention; only the Part 11 modification trail is /// unavailable — is false, modified reads and HistoryUpdate report /// BadHistoryOperationUnsupported without touching SQL, and one Warning is logged per instance (= per boot: the /// provider shares one bootstrapper). Before #674 a v1 database refused EVERY historian call, continuous-historization /// drains included, until script 002 was applied by hand. /// internal sealed partial class SqlServerSchemaBootstrapper { /// /// The schema version this build requires to serve at all: 1 = 001_initial.sql. Gitea #674 lowered it from /// 2 — a missing modification trail disables only the features that need it (). /// public const int RequiredVersion = 1; /// /// The schema version that carries the Part 11 modification trail: 2 = 002_sample_modified.sql (Gitea #668). /// An auto-creating bootstrap always upgrades to it; with AutoCreateSchema=false the script must be applied by /// hand (and the node restarted — the verdict is taken once per instance). /// public const int ModifiedTrailVersion = 2; private const string LockResource = "OtOpcUaHistorianSchema"; private const int LockTimeoutMs = 30_000; private readonly SqlHistorianConnection _conn; private readonly bool _autoCreate; private readonly ILogger _log; private readonly SemaphoreSlim _gate = new(1, 1); private readonly BootstrapFailureCache _failure; // guarded by _gate private volatile bool _ready; private volatile bool _supportsModifiedTrail; /// /// Whether the schema carries the Part 11 modification trail (version >= ). /// Meaningful only after has succeeded (false before). Taken once per instance. /// public bool SupportsModifiedTrail => _supportsModifiedTrail; /// Creates the bootstrapper. /// Connection helper. /// Validated options. /// Optional logger. /// Clock for the failure cache; system by default. public SqlServerSchemaBootstrapper(SqlHistorianConnection conn, SqlServerHistorianOptions options, ILogger? log = null, TimeProvider? timeProvider = null) { _conn = conn; _autoCreate = options.AutoCreateSchema; _log = log ?? NullLogger.Instance; _failure = new BootstrapFailureCache(timeProvider ?? TimeProvider.System, BootstrapFailureCache.DefaultDuration); } /// Ensures the schema is ready; no-op after the first success. /// Cancellation. /// A task completing when the schema is usable. /// AutoCreateSchema is false and is absent. public async Task EnsureAsync(CancellationToken ct) { if (_ready) return; await _gate.WaitAsync(ct).ConfigureAwait(false); try { if (_ready) return; _failure.ThrowIfCached(); try { await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); if (_autoCreate) await ApplyAsync(conn, ct).ConfigureAwait(false); else await VerifyAsync(conn, ct).ConfigureAwait(false); _ready = true; _failure.Clear(); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { // A caller's own cancellation is not a backend failure and is not cached. _failure.Record(ex); _log.LogWarning("Historian schema bootstrap for [{Schema}] failed ({Exception}: {Message}); " + "historian calls fail fast for {Seconds} s before retrying.", _conn.Schema, ex.GetType().Name, ex.Message, BootstrapFailureCache.DefaultDuration.TotalSeconds); throw; } } finally { _gate.Release(); } } private async Task ReadVersionAsync(SqlConnection conn, CancellationToken ct) { await using var cmd = _conn.CreateCommand(conn, $"IF OBJECT_ID(N'{_conn.Q("SchemaVersion")}', N'U') IS NULL SELECT 0 ELSE SELECT ISNULL(MAX(Version), 0) FROM {_conn.Q("SchemaVersion")}"); return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false)); } private async Task VerifyAsync(SqlConnection conn, CancellationToken ct) { var v = await ReadVersionAsync(conn, ct).ConfigureAwait(false); if (v < RequiredVersion) throw new HistorianSchemaMissingException( $"Historian schema [{_conn.Schema}] is missing version {RequiredVersion} (found {v}) and AutoCreateSchema is false. " + "Apply the docs/historian/sqlserver/NNN_*.sql scripts above version " + v + " in order, or enable AutoCreateSchema."); _supportsModifiedTrail = v >= ModifiedTrailVersion; if (!_supportsModifiedTrail) _log.LogWarning("Historian schema [{Schema}] is at version {Version}, below {TrailVersion}, and AutoCreateSchema is false: " + "the Part 11 modification trail is unavailable, so modified-value history reads and HistoryUpdate report " + "BadHistoryOperationUnsupported and retention skips SampleModified. Live historization, reads, events and retention " + "are unaffected. Apply docs/historian/sqlserver/002_sample_modified.sql and restart the node to enable them.", _conn.Schema, v, ModifiedTrailVersion); } private async Task ApplyAsync(SqlConnection conn, CancellationToken ct) { await AcquireLockAsync(conn, ct).ConfigureAwait(false); try { var current = await ReadVersionAsync(conn, ct).ConfigureAwait(false); foreach (var (version, name, text) in LoadScripts()) { if (version <= current) continue; foreach (var batch in SplitBatches(text.Replace("$(Schema)", _conn.Schema, StringComparison.Ordinal))) { await using var cmd = _conn.CreateCommand(conn, batch); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } _log.LogInformation("Applied historian schema script {Script} to [{Schema}]", name, _conn.Schema); } await VerifyAsync(conn, ct).ConfigureAwait(false); } finally { await ReleaseLockAsync(conn).ConfigureAwait(false); } } private async Task AcquireLockAsync(SqlConnection conn, CancellationToken ct) { await using var cmd = _conn.CreateCommand(conn, "sp_getapplock"); cmd.CommandType = CommandType.StoredProcedure; cmd.CommandTimeout = Math.Max(cmd.CommandTimeout, LockTimeoutMs / 1000 + 15); cmd.Parameters.AddWithValue("@Resource", LockResource); cmd.Parameters.AddWithValue("@LockMode", "Exclusive"); cmd.Parameters.AddWithValue("@LockOwner", "Session"); cmd.Parameters.AddWithValue("@LockTimeout", LockTimeoutMs); var rc = new SqlParameter("@rc", SqlDbType.Int) { Direction = ParameterDirection.ReturnValue }; cmd.Parameters.Add(rc); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); if ((int)rc.Value < 0) throw new TimeoutException($"Could not acquire historian schema applock within {LockTimeoutMs} ms (sp_getapplock returned {rc.Value})."); } private async Task ReleaseLockAsync(SqlConnection conn) { try { await using var cmd = _conn.CreateCommand(conn, "sp_releaseapplock"); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@Resource", LockResource); cmd.Parameters.AddWithValue("@LockOwner", "Session"); await cmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { // A pooled connection keeps its server session (sp_reset_connection does not release a Session-owned // applock), so disposing this connection would return it to the pool STILL HOLDING the lock and block // every other node's bootstrap until that pooled session dies. Clearing the pool marks it to be // physically closed on dispose instead, which ends the session and releases the lock. _log.LogWarning("sp_releaseapplock failed ({Exception}); discarding the connection pool so the " + "session holding the historian schema applock is closed rather than pooled.", ex.GetType().Name); SqlConnection.ClearPool(conn); } } [GeneratedRegex(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] private static partial Regex GoLine(); internal static IEnumerable SplitBatches(string script) => GoLine().Split(script).Select(b => b.Trim()).Where(b => b.Length > 0); private static List<(int Version, string Name, string Text)> LoadScripts() { var asm = typeof(SqlServerSchemaBootstrapper).Assembly; var list = new List<(int, string, string)>(); foreach (var res in asm.GetManifestResourceNames()) { var m = Regex.Match(res, @"^Schema\.(\d+)_.*\.sql$"); if (!m.Success) continue; using var s = asm.GetManifestResourceStream(res)!; using var r = new StreamReader(s); list.Add((int.Parse(m.Groups[1].Value), res, r.ReadToEnd())); } return list.OrderBy(x => x.Item1).ToList(); } }