perf(historian): batch ingest — one group commit per value burst
This commit is contained in:
+176
-73
@@ -28,8 +28,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
/// non-coercible value (string/null/reference) is dropped and metered rather than appended.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Non-blocking drain.</b> Appends run on the actor thread (an awaited
|
||||
/// <see cref="IHistorizationOutbox.AppendAsync"/> so appends stay serialized — the durable
|
||||
/// <b>Batched ingest, non-blocking drain.</b> A mux burst arrives as one
|
||||
/// <see cref="VirtualTagActor.DependencyValueBatch"/> and is committed with ONE
|
||||
/// <see cref="IHistorizationOutbox.AppendBatchAsync"/> group commit rather than N per-entry
|
||||
/// fsyncs. Appends run on the actor thread (awaited, so appends stay serialized — the durable
|
||||
/// boundary completes before the next message), but the drain runs off the actor thread and
|
||||
/// pipes its outcome back to <c>Self</c> (<see cref="DrainResult"/>) so the mailbox is never
|
||||
/// blocked on the gateway write. A drain never throws into the actor: the drain task catches
|
||||
@@ -100,6 +102,10 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
/// the Secondary of a pair whose Primary records the same refs (Gitea #537).</param>
|
||||
/// <param name="AbandonedEntries">Lifetime count of entries discarded because their historian tag
|
||||
/// kept failing while other tags were writing successfully (Gitea #538).</param>
|
||||
/// <param name="IngestFaults">Lifetime count of values dropped because the per-value gate/coercion step
|
||||
/// threw — distinct from <paramref name="DroppedNonNumeric"/>, which counts values the historian's
|
||||
/// numeric-only write path cannot carry. Non-zero here means an ingest dependency is faulting (most
|
||||
/// plausibly the supplied capture gate), not that the data was unsuitable.</param>
|
||||
public sealed record RecorderStatus(
|
||||
int QueuedDepth,
|
||||
long TotalRecorded,
|
||||
@@ -108,7 +114,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
long OutboxAppendFailures,
|
||||
bool LastDrainSucceeded,
|
||||
long SuppressedNotPrimary = 0,
|
||||
long AbandonedEntries = 0);
|
||||
long AbandonedEntries = 0,
|
||||
long IngestFaults = 0);
|
||||
|
||||
private readonly IActorRef _dependencyMux;
|
||||
private readonly IHistorianValueWriter _writer;
|
||||
@@ -156,8 +163,14 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
private long _outboxAppendFailures;
|
||||
private long _suppressedNotPrimary;
|
||||
private long _abandonedEntries;
|
||||
private long _ingestFaults;
|
||||
private bool _lastDrainSucceeded = true;
|
||||
|
||||
/// <summary>Whether the current run of per-item ingest faults has already been reported at Warning, so
|
||||
/// a per-value fault mode (a throwing capture gate) logs once rather than once per sample. Cleared by
|
||||
/// the first item that ingests cleanly — see <see cref="IngestAsync"/>.</summary>
|
||||
private bool _ingestFaultLogged;
|
||||
|
||||
/// <summary>Last observed state of <see cref="_captureGate"/>, so a change is logged once rather
|
||||
/// than on every value. Seeded open: a gate that starts closed logs its first suppression.</summary>
|
||||
private bool _captureGateOpen = true;
|
||||
@@ -272,7 +285,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
// Register interest by mux ref (the key the mux fans DependencyValueChanged by) so it fans those
|
||||
// tags' values to us. Historian-name overrides are tracked separately for the write side.
|
||||
_dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_refMap.Keys.ToList(), Self));
|
||||
// Seed the steady drain cadence; appends also nudge a prompt drain (see OnValueChangedAsync).
|
||||
// Seed the steady drain cadence; appends also nudge a prompt drain (see IngestAsync).
|
||||
Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, _drainInterval);
|
||||
base.PreStart();
|
||||
}
|
||||
@@ -290,29 +303,161 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>
|
||||
/// The mux's batched delivery: one message carrying this recorder's whole matched slice of a driver
|
||||
/// burst. Each item is appended by the existing per-value handler, in order — the saving here is the
|
||||
/// mailbox hop, not the append (a genuinely batched append is a later change).
|
||||
/// <para>
|
||||
/// ⚠️ The await below deliberately does NOT use <c>ConfigureAwait(false)</c>. <see
|
||||
/// cref="OnValueChangedAsync"/> reads <c>Self</c> before its own first await, so it must start
|
||||
/// each item ON the actor thread; a context-dropping await here would leave item 2 onward with
|
||||
/// no ActorContext and throw <c>NotSupportedException</c> — the Gitea #491 failure, which
|
||||
/// supervision turns into a restart that silently discards every historized ref. A plain await
|
||||
/// resumes on Akka's ActorTaskScheduler context, so the context is restored before the next
|
||||
/// iteration.
|
||||
/// </para>
|
||||
/// burst. The whole burst is ingested as ONE durable append (see <see cref="IngestAsync"/>), so a
|
||||
/// 50-value scan costs one group commit instead of 50 per-entry fsyncs.
|
||||
/// </summary>
|
||||
/// <param name="msg">The batch of matched dependency values.</param>
|
||||
private async Task OnValueBatchAsync(VirtualTagActor.DependencyValueBatch msg)
|
||||
/// <returns>A task that completes when the burst has been appended (or dropped).</returns>
|
||||
private Task OnValueBatchAsync(VirtualTagActor.DependencyValueBatch msg) => IngestAsync(msg.Items);
|
||||
|
||||
/// <summary>
|
||||
/// A single mux-fanned value. Delegates to the same core as the batch path as a one-item burst, so
|
||||
/// a direct sender (and every test that Tells one value) behaves identically to a batch of one.
|
||||
/// </summary>
|
||||
/// <param name="msg">The matched dependency value.</param>
|
||||
/// <returns>A task that completes when the value has been appended (or dropped).</returns>
|
||||
private Task OnValueChangedAsync(VirtualTagActor.DependencyValueChanged msg) => IngestAsync([msg]);
|
||||
|
||||
/// <summary>
|
||||
/// Ingest one burst of mux-fanned values: run the per-value gates over every item, then append the
|
||||
/// survivors with a SINGLE <see cref="IHistorizationOutbox.AppendBatchAsync"/> group commit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The gates stay per value.</b> The ref filter, the Gitea #537 capture gate and the numeric
|
||||
/// coercion are evaluated for each item in turn — the capture gate in particular is a live read
|
||||
/// per value, never hoisted to once per batch, so a promotion resumes capture on the very next
|
||||
/// sample (pinned by <c>The_capture_gate_is_re_read_per_value_so_promotion_resumes_capture</c>).
|
||||
/// Only the durable append is batched.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One bad item cannot drop the tail.</b> The per-item gate/coercion step is wrapped: an
|
||||
/// unexpected throw (most plausibly a faulting <c>captureGate</c> delegate) is metered as an
|
||||
/// <c>IngestFaults</c>, logged by category, and the loop continues with the next item. Left
|
||||
/// unguarded it would escape the handler into supervision, discarding every item after it in the
|
||||
/// burst AND restarting the actor — which in production discards the historized-ref set (see the
|
||||
/// #491 note below).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Append failure is batch-wide.</b> A failed group commit is metered once
|
||||
/// (<c>OutboxAppendFailures</c>), logged by category, and the burst's values are dropped — the
|
||||
/// same never-restart-loop policy the per-value path had, applied to the batch. Note the outbox
|
||||
/// contract makes any partially-appended prefix fully tracked and ackable, so the trailing drain
|
||||
/// nudge still flushes it; nothing is left buffered-but-unackable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="items">The burst's matched dependency values, in fan-out order.</param>
|
||||
/// <returns>A task that completes when the burst has been appended (or dropped).</returns>
|
||||
private async Task IngestAsync(IReadOnlyList<VirtualTagActor.DependencyValueChanged> items)
|
||||
{
|
||||
IReadOnlyList<VirtualTagActor.DependencyValueChanged> items = msg.Items;
|
||||
// Capture Self BEFORE the await below. Self reads ActorBase.Context, and the append is awaited with
|
||||
// ConfigureAwait(false) — which drops Akka's ActorTaskScheduler synchronization context, so a
|
||||
// continuation that actually resumes off the actor thread finds NO ActorContext and throws
|
||||
// NotSupportedException. This is the same idiom the GetStatus handler applies to Sender.
|
||||
//
|
||||
// It only bites when the append genuinely YIELDS: the production FasterLog outbox fsyncs per group
|
||||
// commit (PerEntry commit mode), so it does; a fake outbox returning ValueTask.CompletedTask
|
||||
// completes synchronously, the continuation runs inline with the context still installed, and the
|
||||
// bug is invisible. That is exactly how it reached production — see the yielding fake in
|
||||
// ContinuousHistorizationRecorderTests. Live-caught on the docker-dev rig (Gitea #491), where the
|
||||
// throw escaped the handler into supervision: the actor restarted, and because production seeds an
|
||||
// EMPTY ref set (the deployed set arrives later via UpdateHistorizedRefs), the restart silently
|
||||
// dropped every historized ref — so historization stopped dead after the FIRST value.
|
||||
IActorRef self = Self;
|
||||
|
||||
List<HistorizationOutboxEntry>? entries = null;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
await OnValueChangedAsync(items[i]);
|
||||
try
|
||||
{
|
||||
CollectEntries(items[i], ref entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Defensive: keep one malformed item from dropping the rest of the burst (and from
|
||||
// restarting the actor). Category only — no value content is logged.
|
||||
//
|
||||
// Metered under its own counter rather than folded into DroppedNonNumeric: the realistic
|
||||
// source is a throwing _captureGate (a supplied delegate reading the redundancy role view),
|
||||
// not a value the historian cannot carry, and conflating the two would make the
|
||||
// non-numeric metric lie about a redundancy fault.
|
||||
//
|
||||
// Logged once per run of faults (the same latch idiom the capture gate uses above): the
|
||||
// plausible fault modes are per-VALUE, so an unlatched Warning would emit one line per
|
||||
// sample — at scan rates that is the log flood, not the diagnosis.
|
||||
_ingestFaults++;
|
||||
if (!_ingestFaultLogged)
|
||||
{
|
||||
_ingestFaultLogged = true;
|
||||
_log.Warning(
|
||||
"ContinuousHistorization: value ingest failed ({Exception}); value dropped. Further "
|
||||
+ "ingest faults are logged at Debug until one succeeds.", ex.GetType().Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Debug("ContinuousHistorization: value ingest failed ({Exception}); value dropped.",
|
||||
ex.GetType().Name);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A clean item re-arms the fault log, so a NEW run of faults is reported at Warning again.
|
||||
_ingestFaultLogged = false;
|
||||
}
|
||||
|
||||
if (entries is null)
|
||||
{
|
||||
// Everything was filtered, suppressed or dropped — nothing to commit, and no drain to nudge
|
||||
// (this burst added nothing; the steady timer still covers any earlier residue).
|
||||
return;
|
||||
}
|
||||
|
||||
// Durable boundary: ONE group commit for the whole burst, awaited so appends stay serialized,
|
||||
// BEFORE the values are considered captured. The outbox drops the oldest entry on capacity
|
||||
// overflow and tracks DroppedCount.
|
||||
try
|
||||
{
|
||||
await _outbox.AppendBatchAsync(entries, _lifetime.Token).ConfigureAwait(false);
|
||||
_totalRecorded += entries.Count;
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
|
||||
{
|
||||
// Normal shutdown raced the append — not a fault. Drop quietly, and do not nudge a drain on
|
||||
// an actor that is stopping.
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A durable-boundary failure (e.g. an fsync hitting disk-full / I/O error) must NEVER propagate
|
||||
// out of the handler — that would trip Akka supervision into a restart, and under a persistent
|
||||
// disk fault the actor would restart-loop (re-register → next burst → append fails → restart →
|
||||
// …). Mirror the drain path's catch-all: meter the failure (category only, no value content)
|
||||
// and drop this burst's values.
|
||||
_outboxAppendFailures++;
|
||||
_log.Warning("ContinuousHistorization: outbox batch append failed ({Exception}); {Count} value(s) dropped.",
|
||||
ex.GetType().Name, entries.Count);
|
||||
}
|
||||
|
||||
// Nudge a prompt drain attempt; the DrainTick handler de-dups (already draining) and honours
|
||||
// any active backoff window, so this never bypasses a failure cooldown. Sent even after a failed
|
||||
// append, because the outbox contract leaves any durably-appended prefix tracked and ackable —
|
||||
// it should drain now rather than wait for the steady timer. Uses the pre-await capture, never
|
||||
// `Self` — see the note above.
|
||||
self.Tell(DrainTick.Instance);
|
||||
}
|
||||
|
||||
private async Task OnValueChangedAsync(VirtualTagActor.DependencyValueChanged msg)
|
||||
/// <summary>
|
||||
/// Runs the per-value gates for one mux-fanned value and, if it survives them, adds one outbox
|
||||
/// entry per historian name to <paramref name="entries"/> (lazily created on first survivor).
|
||||
/// Purely synchronous — it runs on the actor thread with the mailbox suspended, so it may read and
|
||||
/// mutate actor state freely.
|
||||
/// </summary>
|
||||
/// <param name="msg">The matched dependency value.</param>
|
||||
/// <param name="entries">The burst's accumulating entry list; null until the first survivor.</param>
|
||||
private void CollectEntries(
|
||||
VirtualTagActor.DependencyValueChanged msg,
|
||||
ref List<HistorizationOutboxEntry>? entries)
|
||||
{
|
||||
// The mux fans values keyed by the driver ref (msg.TagId == the mux ref). Only historize refs we
|
||||
// registered interest for, and resolve the HISTORIAN NAME(s) to write under — which differ from the
|
||||
@@ -328,9 +473,10 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
// fan-out, so an ungated recorder writes every sample TWICE (measured on the docker-dev central
|
||||
// pair: the gateway's write counter advanced twice per value, and halved when one node stopped).
|
||||
// The gate closes only on a node whose pair partner is the Primary and is recording the same
|
||||
// refs; it is read PER VALUE rather than latched, so promotion resumes capture on the very next
|
||||
// sample with no restart. Note what is NOT gated: the drain. Entries already captured are this
|
||||
// node's to deliver, and a demoted node must still empty its own outbox.
|
||||
// refs; it is read PER VALUE rather than latched (and never hoisted out of this per-item step),
|
||||
// so promotion resumes capture on the very next sample with no restart. Note what is NOT gated:
|
||||
// the drain. Entries already captured are this node's to deliver, and a demoted node must still
|
||||
// empty its own outbox.
|
||||
if (!_captureGate())
|
||||
{
|
||||
_suppressedNotPrimary++;
|
||||
@@ -362,67 +508,23 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
|
||||
var timestampUtc = DateTime.SpecifyKind(msg.TimestampUtc, DateTimeKind.Utc);
|
||||
|
||||
// Capture Self BEFORE the first await. Self reads ActorBase.Context, and the awaits below use
|
||||
// ConfigureAwait(false) — which drops Akka's ActorTaskScheduler synchronization context, so any
|
||||
// continuation that actually resumes off the actor thread finds NO ActorContext and throws
|
||||
// NotSupportedException. This is the same idiom the GetStatus handler already applies to Sender.
|
||||
//
|
||||
// It only bites when the append genuinely YIELDS: the production FasterLog outbox fsyncs per entry
|
||||
// (PerEntry commit mode), so it does; a fake outbox returning ValueTask.CompletedTask completes
|
||||
// synchronously, the continuation runs inline with the context still installed, and the bug is
|
||||
// invisible. That is exactly how it reached production — see the yielding fake in
|
||||
// ContinuousHistorizationRecorderTests. Live-caught on the docker-dev rig (Gitea #491), where the
|
||||
// throw escaped the handler into supervision: the actor restarted, and because production seeds an
|
||||
// EMPTY ref set (the deployed set arrives later via UpdateHistorizedRefs), the restart silently
|
||||
// dropped every historized ref — so historization stopped dead after the FIRST value.
|
||||
IActorRef self = Self;
|
||||
entries ??= new List<HistorizationOutboxEntry>();
|
||||
|
||||
// One outbox entry PER historian name: a single mux fan-out must reach every aliased historized tag
|
||||
// (each its own historian name), not just one — collapsing to one name here would silently drop the
|
||||
// rest. Snapshot the names first: although the mailbox is suspended across the awaits below (so the
|
||||
// set cannot be mutated concurrently), iterating a snapshot is robust and clearer.
|
||||
foreach (string historianName in historianNames.ToList())
|
||||
// rest. No snapshot copy is needed: this method is synchronous, so the set cannot be mutated while
|
||||
// it is iterated.
|
||||
foreach (string historianName in historianNames)
|
||||
{
|
||||
// Record under the historian name (override-or-FullName), NOT the mux ref — the outbox entry +
|
||||
// the drain's WriteLiveValues target the historian tag the EnsureTags hook provisioned.
|
||||
var entry = new HistorizationOutboxEntry(
|
||||
entries.Add(new HistorizationOutboxEntry(
|
||||
Guid.NewGuid(),
|
||||
historianName,
|
||||
numeric,
|
||||
GoodQuality,
|
||||
timestampUtc);
|
||||
|
||||
// Durable boundary: append (awaited so appends stay serialized) BEFORE the value is considered
|
||||
// captured. The outbox drops the oldest entry on capacity overflow and tracks DroppedCount.
|
||||
try
|
||||
{
|
||||
await _outbox.AppendAsync(entry, _lifetime.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
|
||||
{
|
||||
// Normal shutdown raced the append — not a fault. Drop quietly (abandon the rest too).
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A durable-boundary failure (e.g. a PerEntry fsync hitting disk-full / I/O error) must
|
||||
// NEVER propagate out of the handler — that would trip Akka supervision into a restart, and
|
||||
// under a persistent disk fault the actor would restart-loop (re-register → next value →
|
||||
// append fails → restart → …). Mirror the drain path's catch-all: meter the failure
|
||||
// (category only, no value content), drop THIS name's value, and continue with the rest.
|
||||
_outboxAppendFailures++;
|
||||
_log.Warning("ContinuousHistorization: outbox append failed ({Exception}); value dropped.",
|
||||
ex.GetType().Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
_totalRecorded++;
|
||||
timestampUtc));
|
||||
}
|
||||
|
||||
// Nudge a prompt drain attempt; the DrainTick handler de-dups (already draining) and honours
|
||||
// any active backoff window, so this never bypasses a failure cooldown. Uses the pre-await
|
||||
// capture, never `Self` — see the note above.
|
||||
self.Tell(DrainTick.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -731,7 +833,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
_outboxAppendFailures,
|
||||
_lastDrainSucceeded,
|
||||
_suppressedNotPrimary,
|
||||
_abandonedEntries);
|
||||
_abandonedEntries,
|
||||
_ingestFaults);
|
||||
}
|
||||
|
||||
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
|
||||
|
||||
@@ -401,6 +401,18 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
|
||||
/// <summary>One batched mux delivery — every item already matched this host's registered interest.
|
||||
/// Loops the per-value handler, preserving in-burst order.</summary>
|
||||
/// <remarks>
|
||||
/// <b>Mid-batch exception policy: unguarded, deliberately.</b> An exception escaping
|
||||
/// <see cref="OnDependencyChanged"/> drops the remaining items of this burst and restarts the actor
|
||||
/// — exactly what it already did when the same values arrived as N separate messages (the tail is
|
||||
/// then lost to the mailbox flush instead of to the loop), so batching changes the blast radius by
|
||||
/// nothing. Catching per item is deliberately NOT done: the handler is a single
|
||||
/// <c>IUpstreamValueSource.Push</c> into an in-memory cache, so a throw means the upstream itself is
|
||||
/// broken and swallowing it would feed the engine a silently truncated burst. Contrast
|
||||
/// <c>ContinuousHistorizationRecorder.IngestAsync</c>, which DOES catch per item because its
|
||||
/// per-item step runs value coercion.
|
||||
/// </remarks>
|
||||
/// <param name="msg">The batch of matched dependency values.</param>
|
||||
private void OnDependencyBatch(VirtualTagActor.DependencyValueBatch msg)
|
||||
{
|
||||
var items = msg.Items;
|
||||
|
||||
@@ -73,6 +73,16 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor
|
||||
/// <summary>The mux's batched delivery: one message carrying this adapter's whole matched slice of a
|
||||
/// driver burst. The consumer contract is per value, so dispatch each item in order — the saving is the
|
||||
/// mailbox hop, not the consumer call.</summary>
|
||||
/// <remarks>
|
||||
/// <b>Mid-batch exception policy: unguarded, matching the single-value path.</b> An exception
|
||||
/// escaping <see cref="IDependencyConsumer.OnDependencyValue"/> drops the remaining items of this
|
||||
/// burst and restarts the adapter — the same outcome the single-value handler already has (there the
|
||||
/// tail is lost to the mailbox flush rather than to the loop), so batching does not widen it.
|
||||
/// Catching per item HERE only would make the batch path quietly more forgiving than the single-value
|
||||
/// one for the same consumer fault, so the two are deliberately kept identical: a consumer that needs
|
||||
/// per-value fault tolerance owns it inside its own <c>OnDependencyValue</c>.
|
||||
/// </remarks>
|
||||
/// <param name="msg">The batch of matched dependency values.</param>
|
||||
private void OnDependencyValueBatch(VirtualTagActor.DependencyValueBatch msg)
|
||||
{
|
||||
var items = msg.Items;
|
||||
|
||||
+257
@@ -718,6 +718,225 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||||
Assert.Equal(1, writer.MaxObservedConcurrency);
|
||||
}
|
||||
|
||||
// ---------------- Batched ingest: ONE group commit per value burst ----------------
|
||||
|
||||
/// <summary>
|
||||
/// A whole driver burst delivered as one <see cref="VirtualTagActor.DependencyValueBatch"/> costs
|
||||
/// exactly ONE durable append, and every value in it still reaches the writer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The load-bearing assertion is <c>AppendBatchCallCount == 1</c> paired with
|
||||
/// <c>AppendCallCount == 0</c>. "All five values arrived" was already true of the loop-over-the-
|
||||
/// per-value-handler batch path this replaces, so on its own it does not distinguish the two
|
||||
/// implementations at all; the call counts are what state the actual change — five per-entry
|
||||
/// fsyncs (production <c>FasterLogHistorizationOutbox</c> under <c>PerEntry</c> commit mode)
|
||||
/// collapsed into one group commit.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_burst_appends_once_and_drains_all_values()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter { Succeed = true };
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
var refs = new[] { "T1", "T2", "T3", "T4", "T5" };
|
||||
IActorRef rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: refs,
|
||||
drainInterval: TimeSpan.FromMilliseconds(50)));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||
|
||||
var stamp = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
|
||||
rec.Tell(new VirtualTagActor.DependencyValueBatch(
|
||||
refs.Select((r, i) => new VirtualTagActor.DependencyValueChanged(r, (double)(i + 1), stamp))
|
||||
.ToArray()));
|
||||
|
||||
await AwaitAssertAsync(
|
||||
() =>
|
||||
{
|
||||
var written = writer.Snapshot();
|
||||
Assert.Equal(5, written.Count);
|
||||
Assert.Equal(refs.OrderBy(x => x, StringComparer.Ordinal),
|
||||
written.Select(w => w.Tag).OrderBy(x => x, StringComparer.Ordinal));
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(1, outbox.AppendBatchCallCount);
|
||||
Assert.Equal(0, outbox.AppendCallCount);
|
||||
|
||||
// …and the pass acked: the whole prefix left the outbox.
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(0, status.QueuedDepth);
|
||||
Assert.Equal(5, status.TotalRecorded);
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The batched append must also survive genuinely yielding — the Gitea #491 bug class, on the
|
||||
/// path that now owns the only await in the ingest handler.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="A_yielding_append_neither_faults_the_actor_nor_loses_the_value"/>, and for
|
||||
/// the same reason: the ingest handler awaits
|
||||
/// <see cref="IHistorizationOutbox.AppendBatchAsync"/> with <c>ConfigureAwait(false)</c>, so a
|
||||
/// <c>Self</c> read after that await finds no ActorContext, throws
|
||||
/// <see cref="NotSupportedException"/> into supervision, and RESTARTS the actor — which in
|
||||
/// production discards every historized ref (the deployed set arrives later via
|
||||
/// <see cref="ContinuousHistorizationRecorder.UpdateHistorizedRefs"/>).
|
||||
/// <para>
|
||||
/// The outbox double must complete on a POOL thread (<c>Task.Delay(1).ConfigureAwait(false)</c>),
|
||||
/// which is what a real group-commit fsync does. A synchronously-completing fake — or a bare
|
||||
/// <c>Task.Yield()</c>, which posts back to the CAPTURED ActorTaskScheduler context — leaves the
|
||||
/// context installed and the defect invisible.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_yielding_batch_append_neither_faults_the_actor_nor_loses_values()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter { Succeed = true };
|
||||
var outbox = new InMemoryOutbox(yieldOnAppend: true);
|
||||
|
||||
IActorRef rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: new[] { "A", "B", "C" },
|
||||
drainInterval: TimeSpan.FromMilliseconds(200)));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||
|
||||
var stamp = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
rec.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("A", 1.0, stamp),
|
||||
new VirtualTagActor.DependencyValueChanged("B", 2.0, stamp),
|
||||
new VirtualTagActor.DependencyValueChanged("C", 3.0, stamp),
|
||||
}));
|
||||
|
||||
// Every value in the burst reaches the writer…
|
||||
await AwaitAssertAsync(
|
||||
() =>
|
||||
{
|
||||
Assert.Equal(3, writer.Snapshot().Count);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
// …and — the load-bearing half — the actor is still the SAME incarnation, holding the ref set it
|
||||
// was seeded with. A restart would reset TotalRecorded and re-register interest from PreStart.
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(3, status.TotalRecorded);
|
||||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // no PreStart re-register == no restart
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A closed capture gate suppresses every item of a batch, and appends NOTHING (Gitea #537 on the
|
||||
/// batched path).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Extends <see cref="A_closed_capture_gate_suppresses_the_value"/> to the batch handler: the
|
||||
/// rewrite runs the gate per item while collecting entries, so a batched path that hoisted the
|
||||
/// gate read (or skipped it entirely) would silently double-record on the Secondary of every
|
||||
/// redundant pair. <c>SuppressedNotPrimary == 3</c> is the positive observation — "the outbox is
|
||||
/// empty" is equally true of a recorder that never ran.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task The_capture_gate_suppresses_items_within_a_batch()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter();
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
IActorRef rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: new[] { "A", "B", "C" },
|
||||
captureGate: () => false));
|
||||
|
||||
var stamp = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
|
||||
rec.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("A", 1.0, stamp),
|
||||
new VirtualTagActor.DependencyValueChanged("B", 2.0, stamp),
|
||||
new VirtualTagActor.DependencyValueChanged("C", 3.0, stamp),
|
||||
}));
|
||||
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(3, status.SuppressedNotPrimary);
|
||||
Assert.Equal(0, status.TotalRecorded);
|
||||
Assert.Equal(0, status.QueuedDepth);
|
||||
});
|
||||
|
||||
Assert.Empty(writer.Snapshot());
|
||||
Assert.Equal(0, outbox.AppendBatchCallCount);
|
||||
Assert.Equal(0, outbox.AppendCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One item whose gate step THROWS is skipped; the rest of the burst still records, and the actor
|
||||
/// does not restart.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Batching turns a per-item throw into a tail-dropper: unguarded, it escapes the handler with the
|
||||
/// whole burst still uncommitted (the group commit happens after the loop), so every value —
|
||||
/// including the ones already collected — is lost, and supervision restarts the actor, which in
|
||||
/// production discards the historized-ref set. The realistic source is a faulting
|
||||
/// <c>captureGate</c>, which is what this drives: the gate throws on exactly the SECOND of three
|
||||
/// items.
|
||||
/// <para>
|
||||
/// The load-bearing assertion is that BOTH surviving values reached the writer — the one BEFORE
|
||||
/// the fault and the one AFTER it. The trailing one alone would not distinguish the guard from
|
||||
/// a restart that happened to re-drain, and the leading one alone is not evidence the loop
|
||||
/// continued.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_faulting_item_does_not_drop_the_rest_of_the_burst()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter { Succeed = true };
|
||||
var outbox = new InMemoryOutbox();
|
||||
var gateCalls = 0;
|
||||
|
||||
IActorRef rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: new[] { "A", "B", "C" },
|
||||
drainInterval: TimeSpan.FromMilliseconds(50),
|
||||
captureGate: () => Interlocked.Increment(ref gateCalls) == 2
|
||||
? throw new InvalidOperationException("role view unavailable")
|
||||
: true));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||
|
||||
var stamp = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
rec.Tell(new VirtualTagActor.DependencyValueBatch(new[]
|
||||
{
|
||||
new VirtualTagActor.DependencyValueChanged("A", 1.0, stamp),
|
||||
new VirtualTagActor.DependencyValueChanged("B", 2.0, stamp),
|
||||
new VirtualTagActor.DependencyValueChanged("C", 3.0, stamp),
|
||||
}));
|
||||
|
||||
await AwaitAssertAsync(
|
||||
() =>
|
||||
{
|
||||
var written = writer.Snapshot();
|
||||
Assert.Contains(written, w => w.Tag == "A");
|
||||
Assert.Contains(written, w => w.Tag == "C");
|
||||
Assert.DoesNotContain(written, w => w.Tag == "B");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(1, status.IngestFaults);
|
||||
Assert.Equal(2, status.TotalRecorded);
|
||||
// The fault is NOT a "value the historian cannot carry" — that counter must stay clean, or an
|
||||
// ingest-dependency outage is indistinguishable from bad data on the /hosts surface.
|
||||
Assert.Equal(0, status.DroppedNonNumeric);
|
||||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // no PreStart re-register == no restart
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianValueWriter"/> double that BLOCKS every call until <see cref="Release"/>,
|
||||
/// recording the peak number of calls in flight simultaneously. That peak is what distinguishes a
|
||||
@@ -874,6 +1093,8 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||||
private readonly List<HistorizationOutboxEntry> _entries = new();
|
||||
private readonly int _capacity;
|
||||
private long _dropped;
|
||||
private int _appendCalls;
|
||||
private int _appendBatchCalls;
|
||||
|
||||
private readonly bool _yieldOnAppend;
|
||||
|
||||
@@ -905,8 +1126,39 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lifetime count of single-entry <see cref="AppendAsync"/> calls. Distinguishes the
|
||||
/// batched ingest path from the per-value loop it replaced — both record the same values, so only
|
||||
/// the call shape tells them apart.</summary>
|
||||
public int AppendCallCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _appendCalls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lifetime count of <see cref="AppendBatchAsync"/> calls — one per group commit.</summary>
|
||||
public int AppendBatchCallCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _appendBatchCalls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_appendCalls++;
|
||||
}
|
||||
|
||||
if (_yieldOnAppend)
|
||||
{
|
||||
return AppendYieldingAsync(entry);
|
||||
@@ -918,6 +1170,11 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||||
|
||||
public ValueTask AppendBatchAsync(IReadOnlyList<HistorizationOutboxEntry> entries, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_appendBatchCalls++;
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user