using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
///
/// Binds the ContinuousHistorization configuration section that gates the continuous
/// historization of driver (non-Galaxy) tag values. When is true
/// and the ServerHistorian gateway is configured, the Host builds a durable,
/// crash-safe FasterLogHistorizationOutbox + a gateway-backed IHistorianValueWriter
/// and WithOtOpcUaRuntimeActors spawns the ;
/// otherwise no recorder is spawned and driver tag values are not historized.
///
/// 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 (WriteLiveValues) through the single ServerHistorian gateway. The
/// gateway connection (endpoint / key / TLS) is sourced from ServerHistorianOptions — this
/// section carries only the recorder + outbox knobs, never a gateway address or credential.
///
///
public sealed class ContinuousHistorizationOptions
{
/// The configuration section name this options class binds.
public const string SectionName = "ContinuousHistorization";
///
/// When true (and the ServerHistorian gateway is configured), the
/// continuous-historization recorder + its durable outbox are wired and spawned; when
/// false (the default) no recorder is spawned and driver tag values are not historized.
///
public bool Enabled { get; init; }
///
/// Directory holding the FasterLog outbox segment + commit files. Required when
/// is true. In production set an absolute path on durable
/// storage — a relative path resolves against the host's working directory, which may change
/// across deployments.
///
public string OutboxPath { get; init; } = "";
///
/// Outbox commit cadence: PerEntry (the default) fsyncs the log before each append
/// returns (safest, no loss window); Periodic batches commits onto a background timer
/// every ms (higher throughput, a bounded worst-case loss window).
/// Parsed case-insensitively against HistorizationCommitMode; an unrecognized value falls
/// back to PerEntry.
///
/// PerEntry now means one fsync per ingest burst, not per value. The
/// recorder appends a whole value burst through
/// IHistorizationOutbox.AppendBatchAsync, which enqueues every entry and then issues a
/// single group commit — so a burst of N values costs one fsync instead of N. The
/// durability contract is unchanged: AppendBatchAsync does not return until that
/// commit completes, so nothing the caller has been told is buffered can be lost to an
/// unclean restart.
///
///
public string CommitMode { get; init; } = "PerEntry";
/// Periodic-mode commit cadence in milliseconds; must be positive when
/// is Periodic. Ignored under PerEntry.
public int CommitIntervalMs { get; init; } = 100;
///
/// 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).
///
/// Defaults to 1024. It was 64 when a pass wrote its per-tag groups sequentially, so
/// a wide batch cost tags × latency 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
/// , so its cost is ⌈tags / concurrency⌉ × latency
/// regardless of batch width. At 64, a drain draws down at most 64 entries every
/// — below the arrival rate of even a modest historized
/// tag set — so any gateway hiccup left the outbox permanently behind, growing toward
/// and shedding oldest samples, rather than catching up once the
/// gateway recovered.
///
///
public int DrainBatchSize { get; init; } = 1024;
/// Steady drain cadence in seconds (also the post-success reschedule). Defaults to 2.
public double DrainIntervalSeconds { get; init; } = 2;
///
/// 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).
///
/// One peeked batch is grouped per historian tag and each group is an independent gateway
/// round-trip, so a sequential pass costs tags × latency — 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".
///
///
public int DrainConcurrency { get; init; } = 8;
///
/// Maximum un-acked outbox entries before the drop-oldest capacity policy kicks in; 0
/// means unbounded.
///
/// 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
/// every write, where nothing proves any particular tag is at fault, so the
/// recorder correctly retains rather than discards. Set 0 to restore unbounded
/// retention where the outbox sits on storage that can absorb an arbitrarily long outage.
///
///
public int Capacity { get; init; } = 500_000;
///
/// Gitea #538: consecutive failed drain passes a single historian tag may accumulate — counted
/// only across passes where other 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.
///
/// Without this, one permanently-failing tag (typically one whose historian tag was never
/// provisioned) pins the head forever and every healthy tag in the batch is
/// re-written to the historian on every pass, at cadence,
/// indefinitely. Each abandonment logs a Warning naming the tag.
///
///
public int MaxWriteAttempts { get; init; } = 5;
/// Initial retry backoff (seconds) after a failed drain pass. Defaults to 1.
public double MinBackoffSeconds { get; init; } = 1;
/// Cap (seconds) on the exponential retry backoff after repeated drain failures. Defaults to 30.
public double MaxBackoffSeconds { get; init; } = 30;
///
/// Gitea #672: when true (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 unreachable, instead of waiting for auto-down and a fresh redundancy snapshot.
///
/// A crashed Primary otherwise leaves a capture hole of failure-detection + auto-down (~25 s on the
/// shipped akka.conf): 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. false restores the pre-#672 behaviour.
/// Only the historization capture verdict is affected — never the alarm drain or the Primary data-plane
/// gate.
///
///
public bool CaptureWhilePrimaryUnreachable { get; init; } = true;
/// Returns operator-facing misconfiguration warnings for an Enabled recorder
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.
/// Zero or more human-readable warning messages (never carrying secret values).
public IReadOnlyList Validate()
{
var warnings = new List();
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;
}
}