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