fix(historian): live writers rethrow pass cancellation; gateway writes normalised (lmxopcua#746)

Every IHistorianValueWriter caught Exception and returned false, including
OperationCanceledException from the drain's own token. The recorder's guard
("a cancelled pass is not a tag failure") was therefore dead code: at shutdown
mid-pass each cancelled group could be booked as a consecutive failure and,
with a sibling that already wrote, tip a healthy tag toward quarantine.
SqlServer, QuestDb and Gateway writers now rethrow a cancellation of the
caller's token; a cancellation-shaped error on a live token is still an
ordinary failed write.

The Gateway writer relabelled a Local-kind timestamp as UTC instead of
converting it, so the same value could land at two different times on two
backends. Stamped samples now go through LiveWritePreparer (Local -> UTC,
non-finite doubles not sent, duplicate timestamps keep the first); unstamped
samples are still left for the gateway to stamp, in batch order.

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-10-22 16:42:13 -04:00
parent bc7e58ed64
commit 7c5cc9c7e5
7 changed files with 204 additions and 29 deletions
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <summary>
/// <see cref="IHistorianValueWriter"/> over the SQL <c>Sample</c> table. Redelivery-idempotent: rows are
/// inserted only where <c>(TagId, SourceTimeUtc)</c> is absent, so the recorder's at-least-once redelivery
/// leaves the first-stored sample untouched. Never throws; returns false on any failure so the recorder keeps
/// the entries and its per-tag quarantine keeps working.
/// leaves the first-stored sample untouched. Returns false on any failure so the recorder keeps the entries and its
/// per-tag quarantine keeps working — except a cancellation of the caller's token, which is rethrown: it ends the
/// PASS, and booked as a tag failure it could tip a healthy tag over its attempt budget.
/// </summary>
/// <remarks>
/// <b>Non-finite doubles.</b> NaN / ±Infinity cannot live in a SQL <c>float</c> (the TVP throws). They are stored
@@ -90,6 +91,10 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // the drain pass was cancelled (shutdown) — not a write failure of this tag
}
catch (Exception ex)
{
_log.LogWarning("SQL historian live-value write failed ({Exception}); will retry.", ex.GetType().Name);