feat(localdb): alarm S&F on LocalDb with primary-gated drain (cutover)
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
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
@@ -7,11 +6,12 @@ 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>SqliteStoreAndForwardSink</c> (draining to the
|
||||
/// <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 SQLite store-and-forward knobs — the
|
||||
/// downstream connection (endpoint/key/TLS) is sourced from the <c>ServerHistorian</c> 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
|
||||
{
|
||||
@@ -24,9 +24,6 @@ public sealed class AlarmHistorianOptions
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
|
||||
public string DatabasePath { get; init; } = "alarm-historian.db";
|
||||
|
||||
/// <summary>Maximum number of queued rows the drain worker forwards in a single batch.</summary>
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
@@ -34,15 +31,15 @@ public sealed class AlarmHistorianOptions
|
||||
public int DrainIntervalSeconds { get; init; } = 5;
|
||||
|
||||
/// <summary>Maximum queued rows before the sink evicts the oldest. Defaults to 1,000,000
|
||||
/// (matches <c>SqliteStoreAndForwardSink</c>'s <c>DefaultCapacity</c>).</summary>
|
||||
public long Capacity { get; init; } = SqliteStoreAndForwardSink.DefaultCapacity;
|
||||
/// (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>SqliteStoreAndForwardSink</c>'s <c>DefaultMaxAttempts</c>).</summary>
|
||||
public int MaxAttempts { get; init; } = SqliteStoreAndForwardSink.DefaultMaxAttempts;
|
||||
/// 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>
|
||||
@@ -51,8 +48,6 @@ public sealed class AlarmHistorianOptions
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (!Enabled) return warnings;
|
||||
if (!Path.IsPathRooted(DatabasePath))
|
||||
warnings.Add($"AlarmHistorian:DatabasePath '{DatabasePath}' is relative — it resolves against the process working directory (e.g. System32 for a Windows service). Set an absolute path.");
|
||||
if (DrainIntervalSeconds <= 0)
|
||||
warnings.Add($"AlarmHistorian:DrainIntervalSeconds is {DrainIntervalSeconds} — must be > 0; the drain timer will throw or spin at startup.");
|
||||
if (Capacity <= 0)
|
||||
|
||||
Reference in New Issue
Block a user