docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// The event shape the historian sink consumes — source-agnostic across scripted
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource per Phase 7 plan
/// decision #15 (sink scope = all alarm sources, not just scripted). A per-alarm
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource (sink scope = all
/// alarm sources, not just scripted). A per-alarm
/// <c>HistorizeToAveva</c> toggle on the producer side gates which events flow.
/// </summary>
/// <param name="AlarmId">Stable condition identity.</param>
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <see cref="EnqueueAsync"/> is fire-and-forget from the engine's perspective —
/// the sink MUST NOT block the emitting thread. Production implementations
/// (<see cref="SqliteStoreAndForwardSink"/>) persist to a local SQLite queue
/// first, then drain asynchronously to the actual historian. Per Phase 7 plan
/// decision #16, failed downstream writes replay with exponential backoff;
/// first, then drain asynchronously to the actual historian. Per the Phase 7 plan,
/// failed downstream writes replay with exponential backoff;
/// operator actions are never blocked waiting on the historian.
/// </para>
/// <para>
@@ -25,9 +25,11 @@ public interface IAlarmHistorianSink
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
/// <param name="evt">The alarm historian event to enqueue.</param>
/// <param name="cancellationToken">A cancellation token for async operations.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
/// <summary>Snapshot of current queue depth + drain health.</summary>
/// <returns>The current queue depth and drain health snapshot.</returns>
HistorianSinkStatus GetStatus();
}
@@ -96,6 +98,7 @@ public interface IAlarmHistorianWriter
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
/// <param name="batch">The batch of alarm historian events to write.</param>
/// <param name="cancellationToken">A cancellation token for async operations.</param>
/// <returns>One <see cref="HistorianWriteOutcome"/> per event, in the same order as <paramref name="batch"/>.</returns>
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
}
@@ -5,11 +5,10 @@ using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// Phase 7 plan decisions #16#17 implementation: durable SQLite queue on the node
/// absorbs every qualifying alarm event, a drain worker batches rows to the
/// Wonderware historian sidecar via <see cref="IAlarmHistorianWriter"/> on an
/// exponential-backoff cadence, and operator acks never block on the historian
/// being reachable.
/// Durable SQLite queue on the node absorbs every qualifying alarm event, a drain
/// worker batches rows to the Wonderware historian sidecar via
/// <see cref="IAlarmHistorianWriter"/> on an exponential-backoff cadence, and
/// operator acks never block on the historian being reachable.
/// </summary>
/// <remarks>
/// <para>
@@ -27,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// );
/// </code>
/// Dead-lettered rows stay in place for the configured retention window (default
/// 30 days per Phase 7 plan decision #21) so operators can inspect + manually
/// 30 days) so operators can inspect + manually
/// retry before the sweeper purges them. Regular queue capacity is bounded —
/// overflow evicts the oldest non-dead-lettered rows with a WARN log. The
/// durability guarantee is therefore bounded by <see cref="DefaultCapacity"/>:
@@ -77,29 +76,16 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
private TimeSpan _tickInterval;
private volatile int _backoffIndex;
// volatile: read by EnqueueAsync, DrainOnceAsync, RescheduleDrain, and StartDrainLoop
// from different threads; Dispose() writes it from the owner's thread (Core.AlarmHistorian-013).
// from different threads; Dispose() writes it from the owner's thread.
private volatile bool _disposed;
// Core.AlarmHistorian-005: status fields written by the drain timer thread and
// read concurrently by GetStatus() / health-check threads. Guard all reads and
// writes with this lock so the Admin UI never observes a torn or stale value.
private readonly object _statusLock = new();
private DateTime? _lastDrainUtc;
private DateTime? _lastSuccessUtc;
private string? _lastError;
private HistorianDrainState _drainState = HistorianDrainState.Idle;
// Core.AlarmHistorian-009: lifetime counter of rows evicted due to capacity overflow.
// Surfaces in HistorianSinkStatus so operators can see data-loss events without
// having to scrape the WARN log.
private long _evictedCount;
// Core.AlarmHistorian-008: keep an approximate in-memory count of non-dead-lettered
// rows so EnqueueAsync does not need to run a SELECT COUNT(*) on every call. The
// counter is seeded from storage at construction, kept current by every mutation
// (Enqueue, Drain, RetryDeadLettered, PurgeAgedDeadLetters, EnforceCapacity), and
// periodically re-synced from storage as a safety net against drift.
// Mutations cross threads (EnqueueAsync is called from the emitting thread, drain
// runs on the timer / drain thread) so it is updated via Interlocked.
private long _queuedRowCount;
// Probe counter — incremented every time we actually issue a real COUNT(*) for
// capacity enforcement. Public for test instrumentation only.
@@ -145,7 +131,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
// DefaultTimeout gives ADO.NET command-level retry; the PRAGMA busy_timeout
// applied in OpenConnection backs it with SQLite's own busy-handler so an
// enqueue/drain collision waits out the file lock instead of throwing
// SQLITE_BUSY immediately (Core.AlarmHistorian-004).
// SQLITE_BUSY immediately.
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = databasePath,
@@ -153,8 +139,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}.ToString();
InitializeSchema();
// Core.AlarmHistorian-008: seed the in-memory counter from storage so the
// perf-optimised EnqueueAsync path starts in sync with what's on disk.
_queuedRowCount = ProbeQueuedRowCount();
}
@@ -195,11 +179,11 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// <remarks>
/// The worker is a self-rescheduling one-shot <see cref="Timer"/>: after each
/// drain it sets its next due-time to <c>max(tickInterval, CurrentBackoff)</c>
/// so a historian outage actually slows the cadence down the backoff ladder
/// (Core.AlarmHistorian-002). The callback body is fully guarded — a fault in
/// so a historian outage actually slows the cadence down the backoff ladder.
/// The callback body is fully guarded — a fault in
/// <see cref="DrainOnceAsync"/> is logged and recorded into
/// <see cref="GetStatus"/> rather than being lost as an unobserved task
/// exception (Core.AlarmHistorian-006).
/// exception.
/// </remarks>
/// <param name="tickInterval">The base interval between drain attempts.</param>
public void StartDrainLoop(TimeSpan tickInterval)
@@ -251,19 +235,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
catch (ObjectDisposedException) { /* raced with Dispose — nothing to re-arm */ }
}
/// <summary>
/// Enqueues an alarm historian event asynchronously for forwarding to the historian.
/// Respects the queue capacity and enforces eviction of oldest rows when full.
/// </summary>
/// <remarks>
/// Core.AlarmHistorian-003: use async SQLite APIs so the emitting thread is not
/// blocked waiting for a file-lock or disk write; honor the cancellationToken
/// throughout. Microsoft.Data.Sqlite's async surface (OpenAsync /
/// ExecuteNonQueryAsync) is a thin wrapper over the synchronous path, so the
/// blocking still happens — but on a thread-pool thread, not the caller's thread.
/// </remarks>
/// <param name="evt">The alarm historian event to enqueue.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
if (evt is null) throw new ArgumentNullException(nameof(evt));
@@ -273,10 +245,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await ApplyPragmasAsync(conn, cancellationToken).ConfigureAwait(false);
// Core.AlarmHistorian-008: use the in-memory counter to short-circuit the
// capacity check on every enqueue. The bare hot path is now one INSERT — no
// SELECT COUNT(*). We fall back to a real probe only when the cached counter
// says we're at or above capacity, or periodically to defend against drift.
await EnforceCapacityFastPathAsync(conn, cancellationToken).ConfigureAwait(false);
using var cmd = conn.CreateCommand();
@@ -347,13 +315,8 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// on RetryPlease. Safe to call from multiple threads; the semaphore enforces
/// serial execution.
/// </summary>
/// <remarks>
/// Core.AlarmHistorian-008: every per-tick SQLite operation runs through a
/// single shared connection (purge, read, corrupt-row dead-letter, and the
/// outcome-applying transaction). Pre-fix the drain opened three independent
/// connections per tick, each paying the open + PRAGMA cost.
/// </remarks>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DrainOnceAsync(CancellationToken ct)
{
if (_disposed) return;
@@ -391,8 +354,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
foreach (var rowId in corruptRowIds)
DeadLetterRow(conn, corruptTx, rowId, $"corrupt payload at {_clock():O}");
corruptTx.Commit();
// Each corrupt row leaves the non-dead-lettered queue — bookkeeping for
// the in-memory counter (Core.AlarmHistorian-008).
Interlocked.Add(ref _queuedRowCount, -corruptRowIds.Count);
_logger.Warning(
"Dead-lettered {Count} historian queue row(s) with un-deserializable payload",
@@ -413,9 +374,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
catch (OperationCanceledException)
{
// Core.AlarmHistorian-012: reset _drainState so the status surface does
// not stay stuck at Draining after the cancellation unwinds the method.
// The row stays queued; the next drain tick will retry it.
lock (_statusLock) { _drainState = HistorianDrainState.BackingOff; }
throw;
}
@@ -432,13 +390,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
return;
}
// Core.AlarmHistorian-007: a cardinality mismatch is a writer contract
// violation — potentially the events were already persisted. Rather than
// throwing (which, pre -006 fix, was swallowed and left _drainState
// stale), treat it as a transient batch failure so the rows stay queued
// and the backoff surface becomes visible to the operator. A deterministic
// mismatch will stall the row until an operator intervenes or the writer
// is fixed — far safer than re-throwing into a fire-and-forget timer.
if (outcomes.Count != events.Count)
{
var msg = $"Writer returned {outcomes.Count} outcomes for {events.Count} events — expected 1:1; treating as batch retry";
@@ -491,7 +442,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
// Ack-deleted + PermanentFail-dead-lettered rows both leave the
// non-dead-lettered queue, as do RetryPlease rows that hit the max-attempts
// cap (finding 002) — keep the counter aligned (Core.AlarmHistorian-008).
// cap (finding 002) — keep the counter aligned.
if (rowsLeavingQueue > 0)
Interlocked.Add(ref _queuedRowCount, -rowsLeavingQueue);
@@ -517,14 +468,10 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
}
/// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
/// <inheritdoc />
public HistorianSinkStatus GetStatus()
{
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
// counter so a busy Admin UI / health probe does not hammer the DB. Dead-letter
// depth is rare-path only (it lives in the queue until retention) so a real
// COUNT(*) on a single combined connection is fine.
var queued = Interlocked.Read(ref _queuedRowCount);
if (queued < 0) queued = 0;
@@ -536,8 +483,6 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
}
// Core.AlarmHistorian-005: snapshot status fields atomically under the lock
// so the Admin UI never sees a torn DateTime? or stale DrainState.
DateTime? lastDrain, lastSuccess;
string? lastError;
HistorianDrainState drainState;
@@ -562,6 +507,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
/// <returns>The number of rows revived from the dead-letter state.</returns>
public int RetryDeadLettered()
{
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
@@ -570,7 +516,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1";
var revived = cmd.ExecuteNonQuery();
// Dead-lettered rows rejoin the non-dead-lettered queue — keep the in-memory
// counter aligned (Core.AlarmHistorian-008).
// counter aligned.
if (revived > 0) Interlocked.Add(ref _queuedRowCount, revived);
return revived;
}
@@ -652,10 +598,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
cmd.ExecuteNonQuery();
}
// Async variant used by EnqueueAsync (Core.AlarmHistorian-003).
// Core.AlarmHistorian-008: the precise path — runs COUNT(*) to compute the exact
// number of rows to evict. Reached only from the fast-path fallback when the
// in-memory counter says we are at or above capacity.
// Async variant used by EnqueueAsync.
private async Task EnforceCapacityAsync(SqliteConnection conn, CancellationToken ct)
{
Interlocked.Increment(ref _capacityProbeCount);