M4 R4.4: client-side multi-historian redundancy

Adds AVEVA.Historian.Client.Redundancy — HistorianRedundantClient orchestrates
N single-historian members (IHistorianMember; default HistorianClientMember
over HistorianClient) as one logical client. Pure client-side, no server-side
redundancy protocol, no RE.

- Reads fail over to the next member in priority order. Streaming reads only
  fail over BEFORE the first row is observed; a mid-stream failure propagates
  (failing over mid-stream would risk duplicated/skipped rows).
- Writes fan out: WriteFanout AllMembers | PreferredOnly, with All | Any ack
  policy, returning a per-member HistorianRedundantWriteResult.
- Per-member health: FailureThreshold demotes a failing member out of the
  preferred pool; a background watchdog (PeriodicTimer) + CheckHealthAsync
  re-probe and restore recovered members. GetStatus() snapshot + ActiveMember.
- Composes with R4.1: back a member's writes with a HistorianStoreForwardWriter
  so a down member buffers and replays on recovery — the pragmatic client-side
  equivalent of native ReSyncTags.

14 unit tests (no server): failover order, mid-stream no-failover, all-fail
aggregation, probe-any-up, fan-out ack policies, PreferredOnly, soft reject,
health demotion + CheckHealthAsync restore, watchdog recovery. Full suite 307
green. Roadmap R4.4 marked shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 22:46:10 -04:00
parent a9000ec06a
commit 60b3673f01
10 changed files with 1019 additions and 2 deletions
@@ -0,0 +1,78 @@
namespace AVEVA.Historian.Client.Redundancy;
/// <summary>
/// Mutable per-member health used by <see cref="HistorianRedundantClient"/> to route reads and
/// fan out writes. Thread-safe: ops update it from multiple call sites and the watchdog loop.
/// </summary>
internal sealed class MemberState
{
private readonly Lock _lock = new();
private readonly int _failureThreshold;
private bool _isHealthy = true;
private int _consecutiveFailures;
private string? _lastError;
private DateTime? _lastSuccessUtc;
private DateTime? _lastCheckUtc;
public MemberState(IHistorianMember member, int failureThreshold)
{
Member = member;
_failureThreshold = failureThreshold;
}
public IHistorianMember Member { get; }
public bool IsHealthy
{
get
{
lock (_lock)
{
return _isHealthy;
}
}
}
public void MarkSuccess(DateTime utc)
{
lock (_lock)
{
_consecutiveFailures = 0;
_isHealthy = true;
_lastError = null;
_lastSuccessUtc = utc;
_lastCheckUtc = utc;
}
}
public void MarkFailure(string? error, DateTime utc)
{
lock (_lock)
{
_consecutiveFailures++;
_lastError = error;
_lastCheckUtc = utc;
if (_consecutiveFailures >= _failureThreshold)
{
_isHealthy = false;
}
}
}
public HistorianMemberStatus Snapshot()
{
lock (_lock)
{
return new HistorianMemberStatus
{
Name = Member.Name,
IsHealthy = _isHealthy,
ConsecutiveFailures = _consecutiveFailures,
LastError = _lastError,
LastSuccessUtc = _lastSuccessUtc,
LastCheckUtc = _lastCheckUtc,
};
}
}
}