feat(historian-gateway): FasterLog historization outbox (PerEntry/Periodic, drop-oldest)

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 17:20:06 -04:00
parent 1a6eb7efe6
commit 555bd477f1
8 changed files with 536 additions and 0 deletions
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
/// <summary>
/// Per-append durability cadence for the historization outbox. Local to the OtOpcUa abstraction
/// layer (deliberately decoupled from the gateway's internal store-forward commit-mode type).
/// </summary>
public enum HistorizationCommitMode
{
/// <summary>fsync the log before each <c>AppendAsync</c> returns — safest, no loss window.</summary>
PerEntry,
/// <summary>Batch commits onto a background timer — higher throughput, a bounded worst-case loss window.</summary>
Periodic,
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
/// <summary>
/// One durable record buffered by the continuous-historization outbox before it is written to
/// the historian. Carries the minimal payload the SQL analog live-value write path can ingest:
/// a numeric value, a quality code, and a UTC timestamp keyed by tag.
/// </summary>
/// <param name="Id">Stable identifier used to ack (remove) the entry once written. Unique per append.</param>
/// <param name="Tag">Fully-qualified historian tag name the value is recorded against.</param>
/// <param name="NumericValue">The coerced numeric sample value (the SQL write path is numeric-only).</param>
/// <param name="Quality">OPC-UA-derived quality code (e.g. 192 = Good) carried through to the historian.</param>
/// <param name="TimestampUtc">UTC source timestamp of the sample.</param>
public sealed record HistorizationOutboxEntry(
Guid Id,
string Tag,
double NumericValue,
ushort Quality,
DateTime TimestampUtc);
@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
/// <summary>
/// Durable, crash-safe FIFO buffer the continuous-historization recorder appends sampled values
/// to <em>before</em> acking the writer, so nothing is lost if the process dies mid-drain. An
/// implementation guarantees: appended entries survive an unclean restart up to its commit
/// cadence; <see cref="PeekBatchAsync"/> returns entries in append (FIFO) order; and
/// <see cref="RemoveAsync"/> durably reclaims an acked entry. A capacity-bounded implementation
/// drops the oldest entry on overflow and reflects it in <see cref="DroppedCount"/>.
/// </summary>
public interface IHistorizationOutbox : IDisposable
{
/// <summary>Lifetime count of entries dropped because an append would have exceeded capacity.</summary>
long DroppedCount { get; }
/// <summary>Appends <paramref name="entry"/> to the tail of the durable buffer.</summary>
/// <param name="entry">The value record to buffer.</param>
/// <param name="ct">Cancellation token.</param>
ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct);
/// <summary>
/// Returns up to <paramref name="max"/> oldest un-acked entries in FIFO order without removing
/// them. Removal happens via <see cref="RemoveAsync"/> once each entry is durably written.
/// </summary>
/// <param name="max">Maximum number of entries to return; must be positive.</param>
/// <param name="ct">Cancellation token.</param>
ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct);
/// <summary>
/// Durably removes the entry identified by <paramref name="id"/> (and any older entries ahead
/// of it in FIFO order), advancing the buffer head. A no-op when the id is unknown.
/// </summary>
/// <param name="id">The <see cref="HistorizationOutboxEntry.Id"/> to ack.</param>
/// <param name="ct">Cancellation token.</param>
ValueTask RemoveAsync(Guid id, CancellationToken ct);
/// <summary>Current number of un-acked entries held in the buffer.</summary>
/// <param name="ct">Cancellation token.</param>
ValueTask<int> CountAsync(CancellationToken ct);
}