M4 R4.1: pragmatic store-and-forward durable outbox

Adds AVEVA.Historian.Client.StoreForward — a client-side store-and-forward
layer over the historian write surface (AddHistoricalValuesAsync /
SendEventAsync). Producers enqueue writes; the writer persists them and
replays on reconnect so a transient disconnect never drops data. This is the
roadmap's recommended pragmatic outbox, NOT a bit-faithful reimplementation of
AVEVA's native SF cache (that stays deferred) — pure managed, no RE.

- HistorianOutboxEntry / HistorianOutboxEntryKind: buffered-write envelope
- IHistorianOutboxStore + InMemoryHistorianOutboxStore (tests) +
  FileHistorianOutboxStore (crash-durable: atomic temp+move JSON per entry,
  FIFO by filename sequence that resumes past on-disk max, corrupt-file
  quarantine). OutboxJson normalizes event object? properties off JsonElement.
- IHistorianWriteSink + HistorianClientWriteSink (HistorianClient-backed)
- HistorianStoreForwardWriter: enqueue, single-flight FIFO FlushAsync with
  head-of-line blocking, optional MaxDeliveryAttempts dead-lettering,
  DropOldest/Reject overflow policy, background drain loop (retry on reconnect),
  GetStatusAsync snapshot mirroring server SF Pending/Storing/ErrorOccurred.

12 unit tests (no server): durability-across-restart, reconnect-drain, FIFO
order/head-of-line, dead-letter, overflow policies, background auto-drain.
Full suite 293 green. Roadmap R4.1 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:35:30 -04:00
parent a91f126287
commit dd2aec3b8b
14 changed files with 1352 additions and 2 deletions
@@ -0,0 +1,14 @@
namespace AVEVA.Historian.Client.StoreForward;
/// <summary>
/// Discriminates the kind of buffered write an <see cref="HistorianOutboxEntry"/> carries.
/// </summary>
public enum HistorianOutboxEntryKind
{
/// <summary>Historical (backfill) values for a single tag — replays via
/// <c>HistorianClient.AddHistoricalValuesAsync</c>.</summary>
HistoricalValues = 1,
/// <summary>A single historian event — replays via <c>HistorianClient.SendEventAsync</c>.</summary>
Event = 2,
}