Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoryUpdatePlanner.cs
T
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
2026-10-15 06:14:58 -04:00

181 lines
9.4 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// <summary>What the backend did with one HistoryUpdate winner row.</summary>
public enum UpsertOutcome
{
/// <summary>The row was absent and was inserted.</summary>
Inserted,
/// <summary>The row existed and was replaced.</summary>
Replaced,
}
/// <summary>The database-independent half of one <c>UpdateDataAsync</c> call (see <see cref="HistoryUpdatePlanner.Plan"/>).</summary>
public sealed class UpdatePlan
{
/// <summary>Creates a plan from the per-sample results and the winning rows.</summary>
/// <param name="results">The per-sample status results, indexed by request position.</param>
/// <param name="winners">The winning sample rows with their request indices.</param>
/// <param name="keys">The per-sample tick keys, or null where a sample has no key.</param>
/// <param name="winnerByTick">Maps each tick to the index of the sample that won it.</param>
internal UpdatePlan(uint?[] results, IReadOnlyList<(int Index, HistorianSampleRow Row)> winners,
long?[] keys, IReadOnlyDictionary<long, int> winnerByTick)
{
Results = results;
Winners = winners;
Keys = keys;
WinnerByTick = winnerByTick;
}
/// <summary>Pre-decided codes (invalid ts, NaN, superseded duplicate…); null = decided by the backend.</summary>
public uint?[] Results { get; }
/// <summary>Rows to send, de-duplicated per mode, in input order.</summary>
public IReadOnlyList<(int Index, HistorianSampleRow Row)> Winners { get; }
/// <summary>Per input item: its (truncated) timestamp ticks, or null when the timestamp is invalid.</summary>
internal long?[] Keys { get; }
/// <summary>(Truncated) tick → input index of the item actually sent.</summary>
internal IReadOnlyDictionary<long, int> WinnerByTick { get; }
}
/// <summary>
/// Per-item result rules for OPC UA HistoryUpdate (spec §4 "Updates"): exactly one status per input item, in input
/// order.
/// <para><b>Duplicate timestamps within one request</b> (compared at the backend's resolution): Insert — the first
/// occurrence is applied, later ones are <c>BadEntryExists</c>; 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 <c>BadNoEntryExists</c>.</para>
/// <para><b>Invalid items.</b> A null or <see cref="DateTime.MinValue"/> timestamp is <c>BadInvalidTimestamp</c>.
/// A Double NaN/±Infinity is rejected with <c>BadInvalidArgument</c>. An Empty (null) value is only accepted with a
/// Bad status (an operator-authored outage marker); with a Good or Uncertain status it is <c>BadInvalidArgument</c>.
/// Other items still proceed.</para>
/// <para><b>Tag names over <see cref="HistorianTagNames.MaxLength"/> characters</b> are refused, never truncated:
/// every item reports <c>BadInvalidArgument</c> and nothing is sent.</para>
/// </summary>
public static class HistoryUpdatePlanner
{
/// <summary>The pre-pass: classifies every item and picks the winner row per timestamp.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="mode">Insert, Replace or Update.</param>
/// <param name="values">Input items, in request order.</param>
/// <param name="resolutionTicks">1 = 100 ns (SQL Server); 10 = timestamps truncated to µs BEFORE de-duplication (QuestDB).</param>
/// <returns>The plan.</returns>
public static UpdatePlan Plan(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values,
long resolutionTicks = 1)
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(values);
ArgumentOutOfRangeException.ThrowIfLessThan(resolutionTicks, 1);
var results = new uint?[values.Count];
var winner = new Dictionary<long, int>(); // tick -> index of the item actually sent
if (!HistorianTagNames.IsValid(tag))
{
Array.Fill(results, HistorianStatusCodes.BadInvalidArgument);
return new UpdatePlan(results, [], new long?[values.Count], winner);
}
var times = new DateTime[values.Count];
var keys = new long?[values.Count];
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
if (v.TimestampUtc is not { } ts || ts == DateTime.MinValue)
{
results[i] = HistorianStatusCodes.BadInvalidTimestamp;
continue;
}
times[i] = LiveWritePreparer.Truncate(ts, resolutionTicks);
keys[i] = times[i].Ticks;
if ((v.Value.Kind == HistorianValueKind.Double && !double.IsFinite(v.Value.Number))
|| (v.Value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(v.StatusCode)))
{
results[i] = HistorianStatusCodes.BadInvalidArgument;
continue;
}
var key = times[i].Ticks;
if (!winner.TryGetValue(key, out _))
winner[key] = i;
else if (mode == HistoryUpdateMode.Insert)
results[i] = HistorianStatusCodes.BadEntryExists; // first occurrence stays the winner
else
winner[key] = i; // last wins
}
var send = winner.Values.OrderBy(i => i)
.Select(i => (i, new HistorianSampleRow(times[i], values[i].Value, values[i].StatusCode)))
.ToList();
return new UpdatePlan(results, send, keys, winner);
}
/// <summary>The post-pass: fills in each winner's code from what the backend wrote, then the superseded duplicates.</summary>
/// <param name="plan">The plan from <see cref="Plan"/>.</param>
/// <param name="mode">The same mode passed to <see cref="Plan"/>.</param>
/// <param name="outcomeByTick">Ticks of each winner that the backend actually wrote → Inserted/Replaced; absent ⇒ not
/// written (Insert: the row already existed ⇒ <c>BadEntryExists</c>; Replace: no row ⇒ <c>BadNoEntryExists</c>).</param>
/// <returns>One status per input item, in input order.</returns>
public static IReadOnlyList<uint> Complete(UpdatePlan plan, HistoryUpdateMode mode,
IReadOnlyDictionary<long, UpsertOutcome> outcomeByTick)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(outcomeByTick);
var results = (uint?[])plan.Results.Clone();
// No written row means the absent branch: Insert => matched (exists); Replace => not matched.
var absent = mode == HistoryUpdateMode.Replace
? HistorianStatusCodes.BadNoEntryExists
: HistorianStatusCodes.BadEntryExists;
foreach (var (index, row) in plan.Winners)
results[index] = outcomeByTick.TryGetValue(row.TimeUtc.Ticks, out var o)
? o == UpsertOutcome.Inserted ? HistorianStatusCodes.GoodEntryInserted : HistorianStatusCodes.GoodEntryReplaced
: absent;
var final = new uint[results.Length];
for (var i = 0; i < results.Length; i++) final[i] = results[i] ?? 0u;
// Superseded earlier duplicates (Replace/Update) share the winner's code.
if (mode != HistoryUpdateMode.Insert)
for (var i = 0; i < results.Length; i++)
if (plan.Keys[i] is { } key && results[i] is null
&& plan.WinnerByTick.TryGetValue(key, out var w) && w != i)
final[i] = final[w];
return final;
}
/// <summary>
/// DeleteAtTime results: <see cref="DateTime.MinValue"/> ⇒ <c>BadInvalidTimestamp</c>; the first occurrence of a
/// deleted time ⇒ <c>Good</c>; everything else (unknown tag, no row, later duplicate) ⇒ <c>BadNoEntryExists</c>.
/// </summary>
/// <param name="times">Requested times, in request order (already at the backend's resolution).</param>
/// <param name="deletedTicks">Ticks of the rows the backend actually deleted.</param>
/// <param name="tagKnown">Whether the tag exists.</param>
/// <returns>One status per requested time.</returns>
public static IReadOnlyList<uint> DeleteAtTimeResults(IReadOnlyList<DateTime> times, ISet<long> deletedTicks, bool tagKnown)
{
ArgumentNullException.ThrowIfNull(times);
ArgumentNullException.ThrowIfNull(deletedTicks);
var results = new uint[times.Count];
var first = new Dictionary<long, int>();
for (var i = 0; i < times.Count; i++)
{
results[i] = HistorianStatusCodes.BadNoEntryExists;
if (times[i] == DateTime.MinValue) results[i] = HistorianStatusCodes.BadInvalidTimestamp;
else first.TryAdd(times[i].Ticks, i);
}
if (!tagKnown) return results;
foreach (var (ticks, idx) in first)
if (deletedTicks.Contains(ticks))
results[idx] = HistorianStatusCodes.Good;
return results;
}
/// <summary>DeleteRaw result: unknown tag or nothing deleted ⇒ <c>BadNoData</c>; otherwise <c>Good</c>.</summary>
/// <param name="tagKnown">Whether the tag exists.</param>
/// <param name="rowsAffected">Rows the backend deleted.</param>
/// <returns>The status.</returns>
public static uint DeleteRawResult(bool tagKnown, long rowsAffected) =>
tagKnown && rowsAffected > 0 ? HistorianStatusCodes.Good : HistorianStatusCodes.BadNoData;
}