Merge feat/674-historyupdate-posture follow-ups: HistoryUpdate closed until first redundancy snapshot, node-level v1 refusal, host wiring tests, plan-before-gate, UTC skew (#674 review)

This commit is contained in:
Joseph Doherty
2026-10-11 11:41:36 -04:00
16 changed files with 618 additions and 78 deletions
@@ -27,10 +27,4 @@ public static class HistorianStatusCodes
/// status, or a tag name longer than <see cref="HistorianTagNames.MaxLength"/>.
/// </summary>
public const uint BadInvalidArgument = 0x80AB0000u;
/// <summary>
/// A modified-value read or HistoryUpdate item against a SQL Server schema still at version 1 (no Part 11
/// modification trail, Gitea #674): refused without touching the database.
/// </summary>
public const uint BadHistoryOperationUnsupported = 0x80720000u;
}
@@ -28,8 +28,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <see cref="HistoryModificationType"/> and <c>SYSUTCDATETIME()</c>. One statement means the sample change and its
/// audit row commit or roll back together: a failed update leaves no audit row.</para>
/// <para><b>Schema v1 (Gitea #674).</b> Without the trail (<see cref="SqlServerSchemaBootstrapper.SupportsModifiedTrail"/>
/// false) a change could not be audited, so every item (and DeleteRaw's single result) reports
/// <c>BadHistoryOperationUnsupported</c> and nothing is sent to the database beyond the one-time schema check.</para>
/// false) a change could not be audited, so every call throws <see cref="NotSupportedException"/> — which the node
/// manager reports as a node-level <c>BadHistoryOperationUnsupported</c>, on all three arms alike — and nothing is
/// sent to the database beyond the one-time schema check.</para>
/// </summary>
internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
{
@@ -49,20 +50,19 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
_tags = tags;
}
/// <summary>#674: ensures the schema and reports whether the Part 11 trail exists (no SQL after the first success).</summary>
/// <summary>
/// #674: ensures the schema and refuses the call when it has no Part 11 trail (no SQL after the first success).
/// </summary>
/// <param name="ct">Cancellation.</param>
/// <returns>True when the modification trail is available.</returns>
private async Task<bool> TrailAvailableAsync(CancellationToken ct)
/// <returns>A task completing when the trail is available.</returns>
/// <exception cref="NotSupportedException">The schema is below <see cref="SqlServerSchemaBootstrapper.ModifiedTrailVersion"/>.</exception>
private async Task RequireTrailAsync(CancellationToken ct)
{
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
return _bootstrap.SupportsModifiedTrail;
}
private static uint[] Unsupported(int count)
{
var codes = new uint[count];
Array.Fill(codes, HistorianStatusCodes.BadHistoryOperationUnsupported);
return codes;
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).");
}
/// <inheritdoc/>
@@ -72,7 +72,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(context);
if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return Unsupported(values.Count);
await RequireTrailAsync(ct).ConfigureAwait(false);
var plan = HistoryUpdatePlanner.Plan(tag, mode, values);
if (plan.Winners.Count == 0) return HistoryUpdatePlanner.Complete(plan, mode, new Dictionary<long, UpsertOutcome>());
@@ -92,7 +92,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(context);
if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return HistorianStatusCodes.BadHistoryOperationUnsupported;
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);
@@ -120,7 +120,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(timesUtc);
ArgumentNullException.ThrowIfNull(context);
if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return Unsupported(timesUtc.Count);
await RequireTrailAsync(ct).ConfigureAwait(false);
if (!HistorianTagNames.IsValid(tag))
{
var refused = new uint[timesUtc.Count];