Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs
T
Joseph Doherty 1b2c36eb87 fix(historian): capture while the recording Primary is unreachable (#672)
A SIGKILLed Primary stays an unreachable-but-not-downed member until
auto-down, and the redundancy singleton usually dies with it, so the
standby's cached snapshot kept naming it Primary and nobody captured for
~30 s. DriverHostActor now holds its own ReachabilityEvent (+MemberUp)
subscription, caches the last snapshot, and excludes unreachable peers
from the historization peer-is-Primary predicate only, republishing just
the capture verdict on a reachability change. Alarm drain and the Primary
data-plane verdict are unchanged. Gated by
ContinuousHistorization:CaptureWhilePrimaryUnreachable (default true).

Tests: message-driven cases with real ReachabilityEvents (incl. a
falsification control on the drain/data-plane verdicts) plus a real
two-node cluster crash test with downing disabled.
2026-10-11 08:26:39 -04:00

167 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// Binds the <c>ContinuousHistorization</c> configuration section that gates the continuous
/// historization of driver (non-Galaxy) tag values. When <see cref="Enabled"/> is <c>true</c>
/// <em>and</em> the <c>ServerHistorian</c> gateway is configured, the Host builds a durable,
/// crash-safe <c>FasterLogHistorizationOutbox</c> + a gateway-backed <c>IHistorianValueWriter</c>
/// and <c>WithOtOpcUaRuntimeActors</c> spawns the <see cref="ContinuousHistorizationRecorder"/>;
/// otherwise no recorder is spawned and driver tag values are not historized.
/// <para>
/// The recorder taps the per-node dependency-mux value fan-out, appends each numeric value to
/// the outbox (the crash boundary), and drains the outbox to the historian's SQL live-value
/// write path (<c>WriteLiveValues</c>) through the single <c>ServerHistorian</c> gateway. The
/// gateway connection (endpoint / key / TLS) is sourced from <c>ServerHistorianOptions</c> — this
/// section carries only the recorder + outbox knobs, never a gateway address or credential.
/// </para>
/// </summary>
public sealed class ContinuousHistorizationOptions
{
/// <summary>The configuration section name this options class binds.</summary>
public const string SectionName = "ContinuousHistorization";
/// <summary>
/// When <c>true</c> (and the <c>ServerHistorian</c> gateway is configured), the
/// continuous-historization recorder + its durable outbox are wired and spawned; when
/// <c>false</c> (the default) no recorder is spawned and driver tag values are not historized.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Directory holding the FasterLog outbox segment + commit files. Required when
/// <see cref="Enabled"/> is <c>true</c>. In production set an <b>absolute</b> path on durable
/// storage — a relative path resolves against the host's working directory, which may change
/// across deployments.
/// </summary>
public string OutboxPath { get; init; } = "";
/// <summary>
/// Outbox commit cadence: <c>PerEntry</c> (the default) fsyncs the log before each append
/// returns (safest, no loss window); <c>Periodic</c> batches commits onto a background timer
/// every <see cref="CommitIntervalMs"/> ms (higher throughput, a bounded worst-case loss window).
/// Parsed case-insensitively against <c>HistorizationCommitMode</c>; an unrecognized value falls
/// back to <c>PerEntry</c>.
/// <para>
/// <b><c>PerEntry</c> now means one fsync per ingest <em>burst</em>, not per value.</b> The
/// recorder appends a whole value burst through
/// <c>IHistorizationOutbox.AppendBatchAsync</c>, which enqueues every entry and then issues a
/// <em>single</em> group commit — so a burst of N values costs one fsync instead of N. The
/// durability contract is unchanged: <c>AppendBatchAsync</c> does not return until that
/// commit completes, so nothing the caller has been told is buffered can be lost to an
/// unclean restart.
/// </para>
/// </summary>
public string CommitMode { get; init; } = "PerEntry";
/// <summary>Periodic-mode commit cadence in milliseconds; must be positive when
/// <see cref="CommitMode"/> is <c>Periodic</c>. Ignored under <c>PerEntry</c>.</summary>
public int CommitIntervalMs { get; init; } = 100;
/// <summary>
/// Maximum outbox entries peeked + written per drain pass; clamped to a positive value by the
/// recorder (a non-positive value falls back to the default).
/// <para>
/// Defaults to 1024. It was 64 when a pass wrote its per-tag groups <em>sequentially</em>, so
/// a wide batch cost <c>tags × latency</c> on the drain thread. Both halves of that reasoning
/// are gone: the peek is an in-memory scan of the outbox's live index (widening it costs
/// allocation, not I/O), and the pass is now bounded-parallel over
/// <see cref="DrainConcurrency"/>, so its cost is <c>⌈tags / concurrency⌉ × latency</c>
/// regardless of batch width. At 64, a drain draws down at most 64 entries every
/// <see cref="DrainIntervalSeconds"/> — below the arrival rate of even a modest historized
/// tag set — so any gateway hiccup left the outbox permanently behind, growing toward
/// <see cref="Capacity"/> and shedding oldest samples, rather than catching up once the
/// gateway recovered.
/// </para>
/// </summary>
public int DrainBatchSize { get; init; } = 1024;
/// <summary>Steady drain cadence in seconds (also the post-success reschedule). Defaults to 2.</summary>
public double DrainIntervalSeconds { get; init; } = 2;
/// <summary>
/// Maximum historian tags a single drain pass writes concurrently. Defaults to 8; clamped to at
/// least 1 by the recorder (1 restores the fully sequential drain).
/// <para>
/// One peeked batch is grouped per historian tag and each group is an independent gateway
/// round-trip, so a sequential pass costs <c>tags × latency</c> — with a wide batch that is the
/// dominant term in how fast the outbox can drain. The window bounds how many of those
/// round-trips are outstanding at once, so a large tag count cannot flood the gateway. Nothing
/// about the ack changes: the pass is still acked all-or-nothing after every group has
/// reported, and the Gitea #538 per-tag quarantine still keys on "another group wrote this
/// pass".
/// </para>
/// </summary>
public int DrainConcurrency { get; init; } = 8;
/// <summary>
/// Maximum un-acked outbox entries before the drop-oldest capacity policy kicks in; <c>0</c>
/// means unbounded.
/// <para>
/// Defaults to 500,000 (Gitea #538) — roughly 50 MB of FasterLog, and hours-to-days of
/// backlog depending on tag count and update rate. It is a disk backstop for the one case
/// the per-tag quarantine cannot resolve: a historian that is unreachable or refusing
/// <em>every</em> write, where nothing proves any particular tag is at fault, so the
/// recorder correctly retains rather than discards. Set <c>0</c> to restore unbounded
/// retention where the outbox sits on storage that can absorb an arbitrarily long outage.
/// </para>
/// </summary>
public int Capacity { get; init; } = 500_000;
/// <summary>
/// Gitea #538: consecutive failed drain passes a single historian tag may accumulate — counted
/// only across passes where <em>other</em> tags wrote successfully, so a backend outage never
/// advances it — before that tag's queued entries are discarded and the FIFO head is allowed to
/// advance. Defaults to 5; clamped to at least 1 by the recorder.
/// <para>
/// Without this, one permanently-failing tag (typically one whose historian tag was never
/// provisioned) pins the head forever and every <em>healthy</em> tag in the batch is
/// re-written to the historian on every pass, at <see cref="DrainIntervalSeconds"/> cadence,
/// indefinitely. Each abandonment logs a Warning naming the tag.
/// </para>
/// </summary>
public int MaxWriteAttempts { get; init; } = 5;
/// <summary>Initial retry backoff (seconds) after a failed drain pass. Defaults to 1.</summary>
public double MinBackoffSeconds { get; init; } = 1;
/// <summary>Cap (seconds) on the exponential retry backoff after repeated drain failures. Defaults to 30.</summary>
public double MaxBackoffSeconds { get; init; } = 30;
/// <summary>
/// Gitea #672: when <c>true</c> (the default), a redundant node whose capture is stood down for its
/// mesh-partner Primary (Gitea #537) resumes capturing as soon as the cluster failure detector reports
/// that Primary <b>unreachable</b>, instead of waiting for auto-down and a fresh redundancy snapshot.
/// <para>
/// A crashed Primary otherwise leaves a capture hole of failure-detection + auto-down (~25 s on the
/// shipped <c>akka.conf</c>): the redundancy singleton usually died with it, so the standby's last
/// snapshot keeps naming the dead node Primary. With this on, the hole shrinks to the failure-detector
/// window (~10–12 s). The cost is duplicates: during a genuine partition both nodes capture from
/// detection onward, and a briefly-unreachable-but-alive Primary overlaps the standby. SqlServer and
/// QuestDB absorb a duplicate of a source-timestamped sample idempotently; a driver with no source
/// timestamp (e.g. Modbus, stamped with each node's own poll time) writes a duplicate row on every
/// backend, and the Gateway backend stores duplicates. <c>false</c> restores the pre-#672 behaviour.
/// Only the historization capture verdict is affected — never the alarm drain or the Primary data-plane
/// gate.
/// </para>
/// </summary>
public bool CaptureWhilePrimaryUnreachable { get; init; } = true;
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> recorder
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
/// <returns>Zero or more human-readable warning messages (never carrying secret values).</returns>
public IReadOnlyList<string> Validate()
{
var warnings = new List<string>();
if (!Enabled) return warnings;
if (string.IsNullOrWhiteSpace(OutboxPath))
warnings.Add("ContinuousHistorization:OutboxPath is empty while historization is enabled — the durable outbox has no directory to persist to; host startup will fail when the outbox is constructed. Set an absolute path on durable storage.");
if (string.Equals(CommitMode, "Periodic", StringComparison.OrdinalIgnoreCase) && CommitIntervalMs <= 0)
warnings.Add($"ContinuousHistorization:CommitIntervalMs is {CommitIntervalMs} — must be > 0 in Periodic commit mode; the periodic-commit loop cannot run.");
if (DrainConcurrency < 1)
warnings.Add($"ContinuousHistorization:DrainConcurrency is {DrainConcurrency} — must be >= 1; the recorder clamps it to 1 (a fully sequential drain).");
return warnings;
}
}