71379816e7
Moves the alarm store-and-forward buffer out of its own alarm-historian.db and into the node's consolidated LocalDb, where it replicates to the redundant pair peer. A node that dies holding undelivered alarm history no longer takes it to the grave. Tasks 2 and 3 land together, as the plan anticipated. They are not separable: the gate is a constructor argument of the rewritten sink, and a commit that replicated the queue without gating the drain would be a commit in which both nodes of a pair deliver every alarm event, continuously. That drain gate is the load-bearing part of the change, and the recon explains why it is new work rather than a refinement. Exactly-once delivery across a pair is enforced today on the ENQUEUE side, by HistorianAdapterActor: only the Primary enqueues, so the Secondary's queue is empty and its ungated drain has nothing to send. Replicating the table destroys that invariant. The gate is a Func<bool> the caller supplies, because the drain runs on a timer the sink owns rather than on a mailbox, and Core.AlarmHistorian cannot reference PrimaryGatePolicy in Runtime. Runtime supplies it via a new IRedundancyRoleView singleton that DriverHostActor publishes its Primary-gate verdict to -- the same verdict the inbound-write and native-ack gates use, so there is no second notion of am-I-the-Primary to drift. Two failure modes are deliberately closed: - The view is seeded OPEN, matching the policy's own answer for an unknown role with no driver peer. A deployment that runs no redundancy never publishes to it, and defaulting closed would silently stop its alarm history forever. - A gate that throws is read as not-now, never as permission, and a closed gate reports the new HistorianDrainState.NotPrimary rather than Idle. A Secondary's rising queue is supposed to look different from a stalled drain, and if BOTH nodes report NotPrimary the pair is misconfigured and says so instead of quietly filling toward the capacity ceiling. Row ids are a hash of the payload rather than fresh GUIDs. Both adapters accept the same fanned transition in the window before the first redundancy snapshot arrives, and under last-writer-wins an equal key converges those two accepts into one row instead of duplicating them. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
62 lines
3.4 KiB
C#
62 lines
3.4 KiB
C#
using System.Collections.Generic;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
/// <summary>
|
|
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
|
|
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
|
|
/// <c>AddAlarmHistorian</c> registers a <c>LocalDbStoreAndForwardSink</c> (draining to the
|
|
/// gateway alarm writer supplied by the Host) in place of the
|
|
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives. This section
|
|
/// supplies only the <see cref="Enabled"/> gate and the store-and-forward knobs — the
|
|
/// downstream connection (endpoint/key/TLS) is sourced from the <c>ServerHistorian</c> section,
|
|
/// and the queue's storage location is the node's consolidated <c>LocalDb:Path</c> database.
|
|
/// </summary>
|
|
public sealed class AlarmHistorianOptions
|
|
{
|
|
/// <summary>The configuration section name this options class binds.</summary>
|
|
public const string SectionName = "AlarmHistorian";
|
|
|
|
/// <summary>
|
|
/// When <c>true</c>, the durable SQLite store-and-forward sink is registered; when
|
|
/// <c>false</c> (the default) the no-op <c>NullAlarmHistorianSink</c> stays in place.
|
|
/// </summary>
|
|
public bool Enabled { get; init; }
|
|
|
|
/// <summary>Maximum number of queued rows the drain worker forwards in a single batch.</summary>
|
|
public int BatchSize { get; init; } = 100;
|
|
|
|
/// <summary>Seconds between drain-worker ticks. Defaults to 5.</summary>
|
|
public int DrainIntervalSeconds { get; init; } = 5;
|
|
|
|
/// <summary>Maximum queued rows before the sink evicts the oldest. Defaults to 1,000,000
|
|
/// (matches <c>LocalDbStoreAndForwardSink</c>'s <c>DefaultCapacity</c>).</summary>
|
|
public long Capacity { get; init; } = LocalDbStoreAndForwardSink.DefaultCapacity;
|
|
|
|
/// <summary>Days to retain dead-lettered rows before purge. Defaults to 30.</summary>
|
|
public int DeadLetterRetentionDays { get; init; } = 30;
|
|
|
|
/// <summary>Maximum delivery attempts before a perpetually-retrying (poison) row is dead-lettered.
|
|
/// Defaults to 10 (matches <c>LocalDbStoreAndForwardSink</c>'s <c>DefaultMaxAttempts</c>).</summary>
|
|
public int MaxAttempts { get; init; } = LocalDbStoreAndForwardSink.DefaultMaxAttempts;
|
|
|
|
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
|
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
|
/// <returns>Zero or more human-readable warning messages.</returns>
|
|
public IReadOnlyList<string> Validate()
|
|
{
|
|
var warnings = new List<string>();
|
|
if (!Enabled) return warnings;
|
|
if (DrainIntervalSeconds <= 0)
|
|
warnings.Add($"AlarmHistorian:DrainIntervalSeconds is {DrainIntervalSeconds} — must be > 0; the drain timer will throw or spin at startup.");
|
|
if (Capacity <= 0)
|
|
warnings.Add($"AlarmHistorian:Capacity is {Capacity} — must be > 0; the sink constructor will throw at startup.");
|
|
if (DeadLetterRetentionDays <= 0)
|
|
warnings.Add($"AlarmHistorian:DeadLetterRetentionDays is {DeadLetterRetentionDays} — must be > 0; dead-lettered rows would be purged on every drain tick.");
|
|
if (MaxAttempts <= 0)
|
|
warnings.Add($"AlarmHistorian:MaxAttempts is {MaxAttempts} — must be > 0; the sink constructor will throw at startup.");
|
|
return warnings;
|
|
}
|
|
}
|