0658d244d0
- HistoryUpdate items with SourceTimestamp > now + ServerHistorian:HistoryUpdateMaxFutureSkew (default 1 day, <=0 disables) => BadOutOfRange in the node manager (every backend, never sent); clock injected via OtOpcUaNodeManager.Clock (host TimeProvider). - HistoryUpdate is Primary-only: off-Primary => BadInvalidState per node, after authorization, same IDriverPrimaryRoleSource verdict as the native-ack gate; HistoryRead ungated. - SqlServer: base schema requirement back to v1; SupportsModifiedTrail (v>=2). On v1 with AutoCreateSchema=false: writes/reads/events/retention work, modified reads + HistoryUpdate => BadHistoryOperationUnsupported with nothing sent, retention skips SampleModified, one Warning per boot. - QuestDB integration test pinning wall-clock TTL (far-future row evicts only partitions older than the TTL by the wall clock), with a positive control. - Docs: Historian.md, Configuration.md, security.md, CLAUDE.md (incl. QuestDB retry duplicates, #674.4 anonymous-readable trail with ACL off, #674.5 not implemented). Co-Authored-By: Claude <noreply@anthropic.com>
213 lines
11 KiB
C#
213 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="EnsureAsync"/> after
|
|
/// <see cref="BootstrapFailureCache.DefaultDuration"/> retries (<see cref="BootstrapFailureCache"/>). 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 <c>AutoCreateSchema=false</c> it only verifies <c>MAX(Version) >= </c><see cref="RequiredVersion"/>.
|
|
/// <para><b>Schema v1 is accepted (Gitea #674).</b> A database at version 1 (no <c>SampleModified</c>) still serves
|
|
/// live writes, raw / processed / at-time reads, alarm events and retention; only the Part 11 modification trail is
|
|
/// unavailable — <see cref="SupportsModifiedTrail"/> is false, modified reads and HistoryUpdate report
|
|
/// <c>BadHistoryOperationUnsupported</c> 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.</para>
|
|
/// </summary>
|
|
internal sealed partial class SqlServerSchemaBootstrapper
|
|
{
|
|
/// <summary>
|
|
/// The schema version this build requires to serve at all: 1 = <c>001_initial.sql</c>. Gitea #674 lowered it from
|
|
/// 2 — a missing modification trail disables only the features that need it (<see cref="ModifiedTrailVersion"/>).
|
|
/// </summary>
|
|
public const int RequiredVersion = 1;
|
|
|
|
/// <summary>
|
|
/// The schema version that carries the Part 11 modification trail: 2 = <c>002_sample_modified.sql</c> (Gitea #668).
|
|
/// An auto-creating bootstrap always upgrades to it; with <c>AutoCreateSchema=false</c> the script must be applied by
|
|
/// hand (and the node restarted — the verdict is taken once per instance).
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Whether the schema carries the Part 11 modification trail (version >= <see cref="ModifiedTrailVersion"/>).
|
|
/// Meaningful only after <see cref="EnsureAsync"/> has succeeded (false before). Taken once per instance.
|
|
/// </summary>
|
|
public bool SupportsModifiedTrail => _supportsModifiedTrail;
|
|
|
|
/// <summary>Creates the bootstrapper.</summary>
|
|
/// <param name="conn">Connection helper.</param>
|
|
/// <param name="options">Validated options.</param>
|
|
/// <param name="log">Optional logger.</param>
|
|
/// <param name="timeProvider">Clock for the failure cache; system by default.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Ensures the schema is ready; no-op after the first success.</summary>
|
|
/// <param name="ct">Cancellation.</param>
|
|
/// <returns>A task completing when the schema is usable.</returns>
|
|
/// <exception cref="HistorianSchemaMissingException">AutoCreateSchema is false and <see cref="RequiredVersion"/> is absent.</exception>
|
|
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<int> 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<string> 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();
|
|
}
|
|
}
|