using Microsoft.Data.SqlClient; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// OPC UA HistoryUpdate against the SQL Server sample table (spec §4 "Updates"). One transaction per call; /// exactly one status per input item, in input order. /// Duplicate timestamps within one request (compared at full 100 ns tick precision): /// Insert — the first occurrence is applied, later ones are BadEntryExists; Replace/Update — last wins, /// and every earlier occurrence of that timestamp reports the same code as the winning one. /// DeleteAtTime — the first occurrence reports the real outcome, later ones BadNoEntryExists. /// Invalid items. A null or timestamp is /// BadInvalidTimestamp. A Double NaN/±Infinity cannot live in the SQL float column and is /// rejected with BadInvalidArgument (not silently stored as Empty). An Empty (null) value is only /// accepted with a Bad status (an operator-authored outage marker); with a Good or Uncertain status it is /// BadInvalidArgument, because a stored sample is never served better than "no value". Other items /// still proceed. /// Tag names over characters are refused, never /// truncated: every item of the call (and DeleteRaw's single result) reports BadInvalidArgument and nothing /// is written. /// Unknown tag. Insert/Update create it via ; Replace and the /// deletes report BadNoEntryExists (DeleteRaw: BadNoData) without creating anything. /// Modified-value history (Part 11, #668). Every row the MERGE inserts or updates and every row a /// delete removes is copied into SampleModified by an OUTPUT … INTO on the SAME statement — the prior /// value from deleted.* (NULL for an insert), the caller's , the /// and SYSUTCDATETIME(). One statement means the sample change and its /// audit row commit or roll back together: a failed update leaves no audit row. /// Schema v1 (Gitea #674). Without the trail ( /// false) a change could not be audited, so every call throws — which the node /// manager reports as a node-level BadHistoryOperationUnsupported, on all three arms alike — and nothing is /// sent to the database beyond the one-time schema check. /// internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater { private readonly SqlHistorianConnection _conn; private readonly SqlServerTagCatalog _tags; private readonly SqlServerSchemaBootstrapper _bootstrap; /// Creates the updater. /// Connection helper. /// Schema bootstrapper — decides whether the modification trail exists (#674). /// Tag catalog (runs the schema bootstrap itself). public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog tags) { _conn = conn; _bootstrap = bootstrap; _tags = tags; } /// /// #674: ensures the schema and refuses the call when it has no Part 11 trail (no SQL after the first success). /// /// Cancellation. /// A task completing when the trail is available. /// The schema is below . private async Task RequireTrailAsync(CancellationToken ct) { await _bootstrap.EnsureAsync(ct).ConfigureAwait(false); if (!_bootstrap.SupportsModifiedTrail) throw new NotSupportedException( $"Historian schema [{_conn.Schema}] is below version {SqlServerSchemaBootstrapper.ModifiedTrailVersion}: " + "HistoryUpdate needs the Part 11 modification trail (apply 002_sample_modified.sql and restart)."); } /// public async Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(context); await RequireTrailAsync(ct).ConfigureAwait(false); var plan = HistoryUpdatePlanner.Plan(tag, mode, values); if (plan.Winners.Count == 0) return HistoryUpdatePlanner.Complete(plan, mode, new Dictionary()); var tagId = mode == HistoryUpdateMode.Replace ? await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false) : await _tags.GetOrCreateTagIdAsync(tag, HistorianDataTypes.FromKind(plan.Winners[0].Row.Value.Kind), ct).ConfigureAwait(false); var outcome = tagId is null ? new Dictionary() : await MergeAsync(tagId.Value, mode, plan.Winners.Select(w => w.Row).ToList(), context, ct).ConfigureAwait(false); return HistoryUpdatePlanner.Complete(plan, mode, outcome); } /// public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(context); await RequireTrailAsync(ct).ConfigureAwait(false); if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument; var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false); if (tagId is null) return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); int rows; await using (var cmd = _conn.CreateCommand(conn, $"DELETE FROM {_conn.Q("Sample")} {AuditInto(HistoryModificationType.Delete)} " + "WHERE TagId = @tagId AND SourceTimeUtc >= @s AND SourceTimeUtc < @e", tx)) { cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value }); AddAuditParameters(cmd, context); SqlHistorianConnection.AddDateTime2(cmd, "@s", startUtc); SqlHistorianConnection.AddDateTime2(cmd, "@e", endUtc); rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } await tx.CommitAsync(ct).ConfigureAwait(false); return HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, rows); } /// public async Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(timesUtc); ArgumentNullException.ThrowIfNull(context); await RequireTrailAsync(ct).ConfigureAwait(false); if (!HistorianTagNames.IsValid(tag)) { var refused = new uint[timesUtc.Count]; Array.Fill(refused, HistorianStatusCodes.BadInvalidArgument); return refused; } var distinct = new HashSet(); foreach (var t in timesUtc) if (t != DateTime.MinValue) distinct.Add(t.Ticks); var deleted = new HashSet(); if (distinct.Count == 0) return HistoryUpdatePlanner.DeleteAtTimeResults(timesUtc, deleted, tagKnown: false); var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false); if (tagId is null) return HistoryUpdatePlanner.DeleteAtTimeResults(timesUtc, deleted, tagKnown: false); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); await using (var cmd = _conn.CreateCommand(conn, $"DELETE s {AuditInto(HistoryModificationType.Delete)} OUTPUT deleted.SourceTimeUtc " + $"FROM {_conn.Q("Sample")} AS s JOIN @times AS t ON s.SourceTimeUtc = t.T WHERE s.TagId = @tagId", tx)) { cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value }); AddAuditParameters(cmd, context); _conn.AddStructured(cmd, "@times", "TimeRow", SampleRowMapper.ToTimeRecords(distinct.Select(t => new DateTime(t, DateTimeKind.Utc)))); await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await rd.ReadAsync(ct).ConfigureAwait(false)) deleted.Add(SqlHistorianConnection.GetUtc(rd, 0).Ticks); } await tx.CommitAsync(ct).ConfigureAwait(false); return HistoryUpdatePlanner.DeleteAtTimeResults(timesUtc, deleted, tagKnown: true); } private async Task> MergeAsync(int tagId, HistoryUpdateMode mode, List rows, HistoryUpdateContext context, CancellationToken ct) { var sql = $"MERGE {_conn.Q("Sample")} WITH (HOLDLOCK) AS t USING @rows AS r ON t.TagId = @tagId AND t.SourceTimeUtc = r.SourceTimeUtc "; if (mode != HistoryUpdateMode.Insert) sql += "WHEN MATCHED THEN UPDATE SET ValueKind = r.ValueKind, NumValue = r.NumValue, IntValue = r.IntValue, " + "StrValue = r.StrValue, StatusCode = r.StatusCode, ServerTimeUtc = SYSUTCDATETIME() "; if (mode != HistoryUpdateMode.Replace) sql += "WHEN NOT MATCHED THEN INSERT (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " + "VALUES (@tagId, r.SourceTimeUtc, r.ValueKind, r.NumValue, r.IntValue, r.StrValue, r.StatusCode, SYSUTCDATETIME()) "; // The MERGE's deleted.* is NULL for an inserted row, so one OUTPUT INTO records both an insert (no prior value) // and a replace (the prior value). TagId comes from inserted.* — present on both branches. sql += $"{AuditInto(ToModificationType(mode), "inserted")} OUTPUT $action, r.SourceTimeUtc;"; var outcome = new Dictionary(); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); await using (var cmd = _conn.CreateCommand(conn, sql, tx)) { cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId }); AddAuditParameters(cmd, context); _conn.AddStructured(cmd, "@rows", "SampleRow", SampleRowMapper.ToSampleRecords(rows)); await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await rd.ReadAsync(ct).ConfigureAwait(false)) outcome[SqlHistorianConnection.GetUtc(rd, 1).Ticks] = rd.GetString(0) == "INSERT" ? UpsertOutcome.Inserted : UpsertOutcome.Replaced; } await tx.CommitAsync(ct).ConfigureAwait(false); return outcome; } /// The Part 11 modification type recorded for an update mode. /// The update mode. /// Insert ⇒ Insert, Replace ⇒ Replace, Update ⇒ Update. internal static HistoryModificationType ToModificationType(HistoryUpdateMode mode) => mode switch { HistoryUpdateMode.Insert => HistoryModificationType.Insert, HistoryUpdateMode.Replace => HistoryModificationType.Replace, _ => HistoryModificationType.Update, }; /// /// The OUTPUT … INTO SampleModified clause copying each affected row's prior value (deleted.*) into /// the modification trail. names the pseudo-table that carries TagId / /// SourceTimeUtc for every affected row (deleted for a DELETE, inserted for the MERGE). /// private string AuditInto(HistoryModificationType type, string keySource = "deleted") => $"OUTPUT {keySource}.TagId, {keySource}.SourceTimeUtc, SYSUTCDATETIME(), {(byte)type}, @principal, @apiKeyId, " + "deleted.ValueKind, deleted.NumValue, deleted.IntValue, deleted.StrValue, deleted.StatusCode, deleted.ServerTimeUtc " + $"INTO {_conn.Q("SampleModified")} (TagId, SourceTimeUtc, ModificationTimeUtc, UpdateType, Principal, ApiKeyId, " + "ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc)"; private static void AddAuditParameters(SqlCommand cmd, HistoryUpdateContext context) { cmd.Parameters.Add(new SqlParameter("@principal", System.Data.SqlDbType.NVarChar, HistoryUpdateContext.MaxIdentityLength) { Value = context.StoredPrincipal }); cmd.Parameters.Add(new SqlParameter("@apiKeyId", System.Data.SqlDbType.NVarChar, HistoryUpdateContext.MaxIdentityLength) { Value = (object?)context.StoredApiKeyId ?? DBNull.Value }); } }