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
This commit is contained in:
@@ -42,6 +42,28 @@ public static class ContinuousHistorizationMetrics
|
||||
() => _outbox?.UnreadableCount ?? 0L,
|
||||
unit: "{entry}",
|
||||
description: "Lifetime undecodable records the continuous-historization outbox skipped.");
|
||||
OtOpcUaTelemetry.Meter.CreateObservableGauge(
|
||||
"otopcua.historization.recorder.pending_appends",
|
||||
() => (long)Volatile.Read(ref _pendingAppends),
|
||||
unit: "{burst}",
|
||||
description: "Ingest bursts queued for, or in, the recorder's durable append at its last status (backpressure nears at the in-flight cap).");
|
||||
OtOpcUaTelemetry.Meter.CreateObservableGauge(
|
||||
"otopcua.historization.recorder.mailbox_depth",
|
||||
() => (long)Volatile.Read(ref _mailboxDepth),
|
||||
unit: "{message}",
|
||||
description: "Messages waiting in the continuous-historization recorder's mailbox at its last status (-1 = unknown).");
|
||||
}
|
||||
|
||||
private static int _pendingAppends;
|
||||
private static int _mailboxDepth = -1;
|
||||
|
||||
/// <summary>Publishes the recorder's append-queue and mailbox depth for the gauges (called on each status).</summary>
|
||||
/// <param name="pendingAppends">Bursts queued for or in the durable append.</param>
|
||||
/// <param name="mailboxDepth">Messages waiting in the recorder's mailbox (-1 = unknown).</param>
|
||||
public static void ReportRecorder(int pendingAppends, int mailboxDepth)
|
||||
{
|
||||
Volatile.Write(ref _pendingAppends, pendingAppends);
|
||||
Volatile.Write(ref _mailboxDepth, mailboxDepth);
|
||||
}
|
||||
|
||||
/// <summary>Binds the process outbox the gauges observe. Called once by the Host when continuous
|
||||
|
||||
@@ -38,22 +38,20 @@ public sealed class ContinuousHistorizationOptions
|
||||
public string OutboxPath { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Outbox commit cadence: <c>PerEntry</c> (the default) fsyncs the log before each append
|
||||
/// returns (safest, no loss window); <c>Periodic</c> batches commits onto a background timer
|
||||
/// every <see cref="CommitIntervalMs"/> ms (higher throughput, a bounded worst-case loss window).
|
||||
/// Parsed case-insensitively against <c>HistorizationCommitMode</c>; an unrecognized value falls
|
||||
/// back to <c>PerEntry</c>.
|
||||
/// Outbox commit cadence: <c>Periodic</c> (the default) commits on a background timer every
|
||||
/// <see cref="CommitIntervalMs"/> ms (100 ms by default — a crash can lose at most the last interval of captured
|
||||
/// values); <c>PerEntry</c> fsyncs before each append returns (no loss window, one fsync per append). Parsed
|
||||
/// case-insensitively against <c>HistorizationCommitMode</c>.
|
||||
/// <para>
|
||||
/// <b><c>PerEntry</c> now means one fsync per ingest <em>burst</em>, not per value.</b> The
|
||||
/// recorder appends a whole value burst through
|
||||
/// <c>IHistorizationOutbox.AppendBatchAsync</c>, which enqueues every entry and then issues a
|
||||
/// <em>single</em> group commit — so a burst of N values costs one fsync instead of N. The
|
||||
/// durability contract is unchanged: <c>AppendBatchAsync</c> does not return until that
|
||||
/// commit completes, so nothing the caller has been told is buffered can be lost to an
|
||||
/// unclean restart.
|
||||
/// <b>Why Periodic.</b> Under <c>PerEntry</c> every ingest append waits for an fsync, so ingestion is capped at
|
||||
/// <c>1 / fsync_latency</c> appends per second (50 drivers × 1 Hz on a 10 ms-fsync disk kept the recorder half
|
||||
/// busy; at 100+ bursts/s it fell behind for good). The recorder now appends off its mailbox and coalesces
|
||||
/// every burst waiting behind a slow append into one group commit, which helps <c>PerEntry</c> too — but it is
|
||||
/// still one fsync per group on the critical path. <c>PerEntry</c> remains available where no loss window is
|
||||
/// acceptable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string CommitMode { get; init; } = "PerEntry";
|
||||
public string CommitMode { get; init; } = "Periodic";
|
||||
|
||||
/// <summary>Periodic-mode commit cadence in milliseconds; must be positive when
|
||||
/// <see cref="CommitMode"/> is <c>Periodic</c>. Ignored under <c>PerEntry</c>.</summary>
|
||||
|
||||
+208
-70
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Threading.Channels;
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -47,15 +48,16 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
/// <see cref="RecorderStatus.DroppedUnsupportedKind"/> rather than appended.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <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
|
||||
/// everything and reports a failed <see cref="DrainResult"/>, which backs the next attempt off
|
||||
/// (exponential, capped).
|
||||
/// <b>Batched, off-mailbox ingest; non-blocking drain.</b> A mux burst arrives as one
|
||||
/// <see cref="VirtualTagActor.DependencyValueBatch"/>; its gated entries are queued to ONE ordered append loop
|
||||
/// (FIFO preserved), which commits every burst waiting behind it with a single
|
||||
/// <see cref="IHistorizationOutbox.AppendBatchAsync"/> group commit. Up to <c>maxAppendsInFlight</c> bursts may be
|
||||
/// queued or appending; only a full queue makes the handler wait (backpressure on the mux, bounded memory). The
|
||||
/// append used to be awaited on the mailbox, so ingestion was capped at <c>1 / fsync_latency</c> bursts/s while the
|
||||
/// mux kept telling into an unbounded mailbox. The drain runs off the actor thread too and pipes its outcome back to
|
||||
/// <c>Self</c> (<see cref="DrainResult"/>). Neither ever throws into the actor: failures are counted and the next
|
||||
/// drain backs off (exponential, capped). Stopping flushes the queued bursts within
|
||||
/// <see cref="AppendFlushBudget"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
@@ -128,6 +130,14 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
/// threw — distinct from <paramref name="DroppedUnsupportedKind"/>, which counts values the backend
|
||||
/// cannot carry. Non-zero here means an ingest dependency is faulting (most
|
||||
/// plausibly the supplied capture gate), not that the data was unsuitable.</param>
|
||||
/// <param name="PendingAppends">Ingest bursts queued for, or in, the durable append (not yet recorded). Near
|
||||
/// <c>maxAppendsInFlight</c> means the outbox cannot keep up and ingestion is being backpressured.</param>
|
||||
/// <param name="MailboxDepth">Messages waiting in the recorder's mailbox when the status was taken (-1 = unknown).
|
||||
/// A growing value means values arrive faster than the recorder accepts them.</param>
|
||||
/// <param name="AppendBackpressureWaits">Lifetime count of bursts that found the append queue full and held the
|
||||
/// mailbox until room freed.</param>
|
||||
/// <remarks><paramref name="QueuedDepth"/> is <c>-1</c> when the outbox did not report its count within the status
|
||||
/// timeout.</remarks>
|
||||
public sealed record RecorderStatus(
|
||||
int QueuedDepth,
|
||||
long TotalRecorded,
|
||||
@@ -137,7 +147,22 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
bool LastDrainSucceeded,
|
||||
long SuppressedNotPrimary = 0,
|
||||
long AbandonedEntries = 0,
|
||||
long IngestFaults = 0);
|
||||
long IngestFaults = 0,
|
||||
int PendingAppends = 0,
|
||||
int MailboxDepth = -1,
|
||||
long AppendBackpressureWaits = 0);
|
||||
|
||||
/// <summary>The default cap on ingest bursts queued for or in the durable append.</summary>
|
||||
public const int DefaultMaxAppendsInFlight = 64;
|
||||
|
||||
/// <summary>The default bound on <see cref="GetStatus"/>'s outbox count.</summary>
|
||||
public static readonly TimeSpan DefaultStatusTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// How long stopping waits for the append loop to flush the bursts still queued (the runtime's shared actor-stop
|
||||
/// budget). Past it the remainder is abandoned (logged) — the same loss an unclean stop always had.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan AppendFlushBudget = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IActorRef _dependencyMux;
|
||||
private readonly IHistorianValueWriter _writer;
|
||||
@@ -183,6 +208,17 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
|
||||
/// <summary>The ordered queue of gated ingest bursts awaiting the durable append (bounded: backpressure).</summary>
|
||||
private readonly Channel<List<HistorizationOutboxEntry>> _appendQueue;
|
||||
|
||||
/// <summary>The single append loop draining <see cref="_appendQueue"/> (started in <see cref="PreStart"/>).</summary>
|
||||
private Task _appendLoop = Task.CompletedTask;
|
||||
|
||||
private readonly TimeSpan _statusTimeout;
|
||||
private int _pendingAppends;
|
||||
private long _appendBackpressureWaits;
|
||||
private volatile bool _stopping;
|
||||
|
||||
private bool _draining;
|
||||
private TimeSpan _currentBackoff;
|
||||
private DateTime _nextAllowedDrainUtc = DateTime.MinValue;
|
||||
@@ -230,6 +266,10 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
/// positive; 1 is the fully sequential drain).</param>
|
||||
/// <param name="supportedKinds">The value kinds the backend can store
|
||||
/// (<see cref="IHistorianBackendProvider.SupportedKinds"/>); null ⇒ <see cref="DefaultSupportedKinds"/>.</param>
|
||||
/// <param name="maxAppendsInFlight">Ingest bursts that may be queued for or in the durable append before the handler
|
||||
/// waits for room (backpressure); clamped to at least 1. Default <see cref="DefaultMaxAppendsInFlight"/>.</param>
|
||||
/// <param name="statusTimeout">Bound on <see cref="GetStatus"/>'s outbox count (past it the depth is reported -1);
|
||||
/// default <see cref="DefaultStatusTimeout"/>.</param>
|
||||
/// <returns>Props for the actor.</returns>
|
||||
public static Props Props(
|
||||
IActorRef dependencyMux,
|
||||
@@ -243,10 +283,12 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
Func<bool>? captureGate = null,
|
||||
int maxWriteAttempts = 5,
|
||||
int drainConcurrency = 8,
|
||||
IReadOnlySet<HistorianValueKind>? supportedKinds = null) =>
|
||||
IReadOnlySet<HistorianValueKind>? supportedKinds = null,
|
||||
int maxAppendsInFlight = DefaultMaxAppendsInFlight,
|
||||
TimeSpan? statusTimeout = null) =>
|
||||
Akka.Actor.Props.Create(() => new ContinuousHistorizationRecorder(
|
||||
dependencyMux, writer, outbox, historizedRefs, drainBatchSize, drainInterval, minBackoff, maxBackoff,
|
||||
captureGate, maxWriteAttempts, drainConcurrency, supportedKinds));
|
||||
captureGate, maxWriteAttempts, drainConcurrency, supportedKinds, maxAppendsInFlight, statusTimeout));
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ContinuousHistorizationRecorder"/> class.</summary>
|
||||
/// <param name="dependencyMux">The per-node dependency mux to register historized-ref interest with.</param>
|
||||
@@ -266,6 +308,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
/// at least 1; 1 is the fully sequential drain.</param>
|
||||
/// <param name="supportedKinds">The value kinds the backend can store; null ⇒
|
||||
/// <see cref="DefaultSupportedKinds"/>.</param>
|
||||
/// <param name="maxAppendsInFlight">Ingest bursts queued for or in the durable append before the handler waits.</param>
|
||||
/// <param name="statusTimeout">Bound on <see cref="GetStatus"/>'s outbox count.</param>
|
||||
public ContinuousHistorizationRecorder(
|
||||
IActorRef dependencyMux,
|
||||
IHistorianValueWriter writer,
|
||||
@@ -278,8 +322,18 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
Func<bool>? captureGate = null,
|
||||
int maxWriteAttempts = 5,
|
||||
int drainConcurrency = 8,
|
||||
IReadOnlySet<HistorianValueKind>? supportedKinds = null)
|
||||
IReadOnlySet<HistorianValueKind>? supportedKinds = null,
|
||||
int maxAppendsInFlight = DefaultMaxAppendsInFlight,
|
||||
TimeSpan? statusTimeout = null)
|
||||
{
|
||||
_appendQueue = Channel.CreateBounded<List<HistorizationOutboxEntry>>(
|
||||
new BoundedChannelOptions(Math.Max(1, maxAppendsInFlight))
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
});
|
||||
_statusTimeout = statusTimeout is { } st && st > TimeSpan.Zero ? st : DefaultStatusTimeout;
|
||||
_dependencyMux = dependencyMux ?? throw new ArgumentNullException(nameof(dependencyMux));
|
||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
_passWriter = writer as IHistorianPassWriter;
|
||||
@@ -314,14 +368,10 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
Receive<UpdateHistorizedRefs>(OnUpdateHistorizedRefs);
|
||||
Receive<DrainTick>(_ => OnDrainTick());
|
||||
Receive<DrainResult>(OnDrainResult);
|
||||
ReceiveAsync<GetStatus>(async _ =>
|
||||
{
|
||||
// Capture Sender before the await: although Akka restores the actor context across awaits,
|
||||
// capturing first is the robust idiom (Sender after an await is brittle).
|
||||
IActorRef replyTo = Sender;
|
||||
RecorderStatus status = await BuildStatusAsync().ConfigureAwait(false);
|
||||
replyTo.Tell(status);
|
||||
});
|
||||
// Not a ReceiveAsync: awaiting the outbox count used to suspend the mailbox for as long as the count took (a hung
|
||||
// SQLite/FasterLog lock stalled ingestion with it). The counters are read here, the count runs off the mailbox,
|
||||
// bounded by the status timeout, and the reply is piped to the asker.
|
||||
Receive<GetStatus>(_ => BuildStatusAsync().PipeTo(Sender));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -332,6 +382,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
_dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_refMap.Keys.ToList(), Self));
|
||||
// Seed the steady drain cadence; appends also nudge a prompt drain (see IngestAsync).
|
||||
Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, _drainInterval);
|
||||
IActorRef self = Self;
|
||||
_appendLoop = Task.Run(() => AppendLoopAsync(self));
|
||||
base.PreStart();
|
||||
}
|
||||
|
||||
@@ -341,6 +393,20 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
// Drop our mux interest eagerly (mirrors VirtualTagActor.PostStop) so the mux stops fanning to
|
||||
// us immediately, closing the dead-letter window between this stop and the mux's Terminated.
|
||||
_dependencyMux.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||
// Flush the bursts still queued for the append (they were accepted from the mux) within the shared stop budget,
|
||||
// then cancel whatever is left. Bounded: a stuck fsync cannot pin this dispatcher thread past the budget.
|
||||
_stopping = true;
|
||||
_appendQueue.Writer.TryComplete();
|
||||
try
|
||||
{
|
||||
if (!_appendLoop.Wait(AppendFlushBudget))
|
||||
_log.Warning("ContinuousHistorization: {Count} ingest burst(s) were still waiting for the outbox when the " +
|
||||
"recorder stopped; abandoned after {Seconds} s.", Volatile.Read(ref _pendingAppends), AppendFlushBudget.TotalSeconds);
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
// The loop never throws; defensive.
|
||||
}
|
||||
_lifetime.Cancel();
|
||||
_lifetime.Dispose();
|
||||
base.PostStop();
|
||||
@@ -411,8 +477,6 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
// 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++)
|
||||
{
|
||||
@@ -461,49 +525,92 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
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
|
||||
// Durable boundary, OFF the mailbox: the burst joins the ordered append queue. Only a full queue holds the handler
|
||||
// (and with it the mailbox) until room frees — backpressure instead of an unbounded mailbox. The append loop
|
||||
// records the outcome and nudges a drain.
|
||||
var pending = Interlocked.Increment(ref _pendingAppends);
|
||||
// The gauges' source: the queue depth and the mailbox depth as this burst is accepted (the actor thread is the
|
||||
// only place the mailbox can be read).
|
||||
ContinuousHistorizationMetrics.ReportRecorder(pending, Context is ActorCell cell ? cell.NumberOfMessages : -1);
|
||||
if (!_appendQueue.Writer.TryWrite(entries))
|
||||
{
|
||||
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 carry on.
|
||||
//
|
||||
// The meter is ONE per failed burst, whatever the burst's size — a deliberate granularity change
|
||||
// from the per-value era, where it was one per value. And it is NOT a count of values lost: the
|
||||
// outbox commits a PREFIX or nothing (never an orphan), so an unknown number of this burst's
|
||||
// entries may already be durable. Hence the message says "failed to append", not "dropped" —
|
||||
// claiming total loss here would be wrong about the prefix the drain nudge below is about to
|
||||
// flush. TotalRecorded is likewise not credited for that prefix: the outbox does not report how
|
||||
// far it got, so the counter under-counts rather than over-claims.
|
||||
_outboxAppendFailures++;
|
||||
_log.Warning(
|
||||
"ContinuousHistorization: outbox batch append failed ({Exception}); {Count} value(s) in the "
|
||||
+ "burst failed to append. Any prefix the outbox committed before the failure stays queued "
|
||||
+ "and drains normally; the remainder are lost.",
|
||||
ex.GetType().Name, entries.Count);
|
||||
Interlocked.Increment(ref _appendBackpressureWaits);
|
||||
try
|
||||
{
|
||||
await _appendQueue.Writer.WriteAsync(entries, _lifetime.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ChannelClosedException)
|
||||
{
|
||||
// Normal shutdown raced the enqueue — not a fault.
|
||||
Interlocked.Decrement(ref _pendingAppends);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
/// <summary>
|
||||
/// The single, ordered durable-append loop: takes the next queued burst plus every burst already waiting behind it,
|
||||
/// commits them with ONE <see cref="IHistorizationOutbox.AppendBatchAsync"/> (FIFO kept), records the outcome, and
|
||||
/// nudges a drain. Never throws.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Append failure is batch-wide, and it is NOT a whole-burst loss.</b> A failed group commit is metered ONCE per
|
||||
/// burst it carried (<c>OutboxAppendFailures</c>) and logged by category. What is actually lost is only what the
|
||||
/// outbox never committed: its contract is "prefix, never orphan", so anything it durably appended before the
|
||||
/// failure stays tracked and ackable and the drain nudge flushes it. That prefix does NOT credit
|
||||
/// <c>TotalRecorded</c> (the outbox does not report how far it got): the counter under-counts rather than
|
||||
/// over-claims. Runs off the actor thread, so it touches no actor state but the Interlocked counters and the
|
||||
/// captured <paramref name="self"/>.
|
||||
/// </remarks>
|
||||
private async Task AppendLoopAsync(IActorRef self)
|
||||
{
|
||||
var reader = _appendQueue.Reader;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await reader.WaitToReadAsync(CancellationToken.None).ConfigureAwait(false)) return;
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bursts = 0;
|
||||
var merged = new List<HistorizationOutboxEntry>();
|
||||
while (reader.TryRead(out var burst))
|
||||
{
|
||||
merged.AddRange(burst);
|
||||
bursts++;
|
||||
}
|
||||
if (bursts == 0) continue;
|
||||
|
||||
try
|
||||
{
|
||||
await _outbox.AppendBatchAsync(merged, _lifetime.Token).ConfigureAwait(false);
|
||||
Interlocked.Add(ref _totalRecorded, merged.Count);
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
|
||||
{
|
||||
// Shutdown past the flush budget raced the append — not a fault.
|
||||
Interlocked.Add(ref _pendingAppends, -bursts);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A durable-boundary failure (e.g. an fsync hitting disk-full / I/O error) never stops the loop: meter it
|
||||
// (category only, no value content) and carry on. One count per burst, whatever its size.
|
||||
Interlocked.Add(ref _outboxAppendFailures, bursts);
|
||||
_log.Warning(
|
||||
"ContinuousHistorization: outbox batch append failed ({Exception}); {Count} value(s) in {Bursts} burst(s) " +
|
||||
"failed to append. Any prefix the outbox committed before the failure stays queued and drains normally; " +
|
||||
"the remainder are lost.", ex.GetType().Name, merged.Count, bursts);
|
||||
}
|
||||
|
||||
Interlocked.Add(ref _pendingAppends, -bursts);
|
||||
// Sent even after a failed append: any durably-appended prefix should drain now rather than wait for the
|
||||
// steady timer. The DrainTick handler de-dups and honours an active backoff. Not while stopping (dead letters).
|
||||
if (!_stopping) self.Tell(DrainTick.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -928,19 +1035,50 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, delay);
|
||||
}
|
||||
|
||||
private async Task<RecorderStatus> BuildStatusAsync()
|
||||
/// <summary>
|
||||
/// The status reply: every counter read now (on the actor thread), the outbox count awaited off the mailbox and
|
||||
/// bounded by the status timeout — a count that does not answer in time is reported <c>-1</c> (logged).
|
||||
/// </summary>
|
||||
private Task<RecorderStatus> BuildStatusAsync()
|
||||
{
|
||||
int depth = await _outbox.CountAsync(_lifetime.Token).ConfigureAwait(false);
|
||||
return new RecorderStatus(
|
||||
depth,
|
||||
_totalRecorded,
|
||||
var mailbox = Context is ActorCell cell ? cell.NumberOfMessages : -1;
|
||||
var snapshot = new RecorderStatus(
|
||||
0,
|
||||
Interlocked.Read(ref _totalRecorded),
|
||||
_droppedUnsupportedKind,
|
||||
_outbox.DroppedCount,
|
||||
_outboxAppendFailures,
|
||||
Interlocked.Read(ref _outboxAppendFailures),
|
||||
_lastDrainSucceeded,
|
||||
_suppressedNotPrimary,
|
||||
_abandonedEntries,
|
||||
_ingestFaults);
|
||||
_ingestFaults,
|
||||
Volatile.Read(ref _pendingAppends),
|
||||
mailbox,
|
||||
Interlocked.Read(ref _appendBackpressureWaits));
|
||||
ContinuousHistorizationMetrics.ReportRecorder(snapshot.PendingAppends, mailbox);
|
||||
var token = _lifetime.Token;
|
||||
var timeout = _statusTimeout;
|
||||
var log = _log;
|
||||
return CountAsync();
|
||||
|
||||
async Task<RecorderStatus> CountAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var depth = await _outbox.CountAsync(token).AsTask().WaitAsync(timeout, token).ConfigureAwait(false);
|
||||
return snapshot with { QueuedDepth = depth };
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
log.Warning("ContinuousHistorization: the outbox did not report its depth within {Seconds} s; status depth unknown.",
|
||||
timeout.TotalSeconds);
|
||||
return snapshot with { QueuedDepth = -1 };
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return snapshot with { QueuedDepth = -1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
|
||||
|
||||
Reference in New Issue
Block a user