826312befe
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.
181 lines
9.4 KiB
C#
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;
|
|
}
|