Files
T
Joseph Doherty a5557375ee perf(historian): recorder appends off its mailbox; Periodic commit default (lmxopcua#746)
The continuous-historization recorder awaited every outbox append (an fsync
under PerEntry) on its mailbox, so ingestion was capped at 1/fsync_latency
bursts per second while the mux kept telling into an unbounded, unmetered
mailbox. GetStatus also suspended the mailbox on an unbounded outbox count.

- Gated ingest bursts go to a bounded channel (64 bursts) drained by one
  ordered append loop, which commits every burst waiting behind a slow
  append in a single AppendBatchAsync (FIFO kept). Only a full queue holds
  the mailbox (backpressure, bounded memory). Counters are Interlocked; the
  loop nudges the drain. PostStop flushes queued bursts within the shared
  5 s stop budget.
- GetStatus reads counters on the actor thread and pipes a count bounded by
  a 10 s timeout (-1 when it does not answer); it no longer suspends the
  mailbox. RecorderStatus gains PendingAppends, MailboxDepth and
  AppendBackpressureWaits; two gauges publish pending appends and mailbox
  depth.
- ContinuousHistorization:CommitMode defaults to Periodic (100 ms). The
  Host's appsettings.json, its unparseable-value fallback and docker-dev
  still say PerEntry: filed as a Host follow-up (outside this lane).

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:40:46 -05:00

119 lines
4.7 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
/// <summary>
/// Shared doubles for the recorder tests added with the pass-flush / off-mailbox-append work: a FIFO outbox and a
/// pass-aware writer that log every call into one ordered event list, so a test can assert the ORDER of writes,
/// flushes and acks across both. Thread-safe — the recorder appends and drains off the actor thread.
/// </summary>
internal sealed class EventLog
{
private readonly Lock _gate = new();
private readonly List<string> _events = new();
public void Add(string e) { lock (_gate) _events.Add(e); }
public IReadOnlyList<string> Snapshot() { lock (_gate) return _events.ToArray(); }
}
/// <summary>FIFO outbox honouring the truncate-through contract; appends can be made to yield or block.</summary>
internal sealed class LoggingOutbox(EventLog log) : IHistorizationOutbox
{
private readonly Lock _gate = new();
private readonly List<HistorizationOutboxEntry> _entries = new();
private readonly List<HistorizationOutboxEntry> _appended = new();
private int _appendBatchCalls;
/// <summary>When set, every AppendBatchAsync waits on it (on a pool thread) before committing — a slow fsync.</summary>
public TaskCompletionSource? AppendGate { get; set; }
/// <summary>Completed by an append that has entered (before waiting on <see cref="AppendGate"/>).</summary>
public TaskCompletionSource AppendEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>When set, CountAsync blocks on it — a hung outbox.</summary>
public TaskCompletionSource? CountGate { get; set; }
public int AppendBatchCalls { get { lock (_gate) return _appendBatchCalls; } }
public long DroppedCount => 0;
public IReadOnlyList<HistorizationOutboxEntry> Entries { get { lock (_gate) return _entries.ToArray(); } }
/// <summary>Every entry ever appended, in append order (acks do not remove from it).</summary>
public IReadOnlyList<HistorizationOutboxEntry> Appended { get { lock (_gate) return _appended.ToArray(); } }
public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) => AppendBatchAsync([entry], ct);
public async ValueTask AppendBatchAsync(IReadOnlyList<HistorizationOutboxEntry> entries, CancellationToken ct)
{
lock (_gate) _appendBatchCalls++;
AppendEntered.TrySetResult();
await Task.Delay(1, ct).ConfigureAwait(false); // complete on a pool thread, as a real fsync does
if (AppendGate is { } gate) await gate.Task.WaitAsync(ct).ConfigureAwait(false);
lock (_gate)
{
_entries.AddRange(entries);
_appended.AddRange(entries);
}
log.Add($"append:{entries.Count}");
}
public ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct)
{
lock (_gate) return ValueTask.FromResult<IReadOnlyList<HistorizationOutboxEntry>>(_entries.Take(max).ToArray());
}
public ValueTask RemoveAsync(Guid id, CancellationToken ct)
{
lock (_gate)
{
var i = _entries.FindIndex(e => e.Id == id);
if (i >= 0) _entries.RemoveRange(0, i + 1);
}
log.Add("ack");
return ValueTask.CompletedTask;
}
public async ValueTask<int> CountAsync(CancellationToken ct)
{
if (CountGate is { } gate) await gate.Task.WaitAsync(ct).ConfigureAwait(false);
lock (_gate) return _entries.Count;
}
public void Dispose() { }
}
/// <summary>A pass-aware writer: logs every in-pass write and flush; plain writes are logged distinctly.</summary>
internal sealed class LoggingPassWriter(EventLog log) : IHistorianPassWriter
{
private readonly HashSet<string> _failTags = new(StringComparer.Ordinal);
public Exception? FlushThrows { get; set; }
public void Fail(string tag) { lock (_failTags) _failTags.Add(tag); }
public Task<bool> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
{
log.Add($"plain:{tag}");
return Task.FromResult(true);
}
public async Task<bool> WriteLiveValuesInPassAsync(string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
bool fail;
lock (_failTags) fail = _failTags.Contains(tag);
log.Add($"write:{tag}");
return !fail;
}
public async Task FlushAsync(CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
log.Add("flush");
if (FlushThrows is { } ex) throw ex;
}
}