fix(historian): pending-appends gauge follows the append queue back to zero (lmxopcua#746)
v2-ci / unit-tests (push) Waiting to run

otopcua.historization.recorder.pending_appends was written only from the
actor thread: when a burst was accepted (just after the increment, so
never 0) and on GetStatus, which nothing in production sends. Once ingest
stopped the gauge froze at the depth the last burst saw, so after a
backpressure episode it kept reporting a queue near the 64-burst cap that
had long drained.

The append loop now publishes the depth after every decrement
(ReportPendingAppends). The mailbox-depth gauge stays actor-thread only;
its description now says when it is sampled. New test (non-parallel
collection, the gauge is process-wide): the gauge reads 2 with one append
stuck and one queued, and 0 after the queue drains with no status
request. With the loop's report removed it fails (stays 2).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:38:02 -05:00
parent 0961b3ed93
commit b051bdc87c
3 changed files with 69 additions and 6 deletions
@@ -46,18 +46,30 @@ public static class ContinuousHistorizationMetrics
"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).");
description: "Ingest bursts queued for, or in, the recorder's durable append (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).");
description: "Messages waiting in the continuous-historization recorder's mailbox when it last accepted a burst or answered a 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>
/// <summary>The value the pending-appends gauge reports now — test/diagnostic.</summary>
internal static int PendingAppendsGauge => Volatile.Read(ref _pendingAppends);
/// <summary>
/// Publishes the recorder's append-queue depth alone. The append loop calls it after every append completes, so the
/// gauge falls back to 0 when the queue drains: the actor-thread reports (<see cref="ReportRecorder"/>) run only when
/// a burst is accepted or a status is asked — nothing asks for status in production — so without this the gauge
/// froze at the depth seen by the last burst (never 0, and still near the cap after a backpressure episode ended).
/// </summary>
/// <param name="pendingAppends">Bursts queued for or in the durable append.</param>
public static void ReportPendingAppends(int pendingAppends) => Volatile.Write(ref _pendingAppends, pendingAppends);
/// <summary>Publishes the recorder's append-queue and mailbox depth for the gauges (on each accepted burst and 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)
@@ -534,7 +534,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
catch (Exception ex) when (ex is OperationCanceledException or ChannelClosedException)
{
// Normal shutdown raced the enqueue — not a fault.
Interlocked.Decrement(ref _pendingAppends);
ContinuousHistorizationMetrics.ReportPendingAppends(Interlocked.Decrement(ref _pendingAppends));
}
}
}
@@ -586,7 +586,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
{
// Shutdown past the flush budget raced the append — not a fault.
Interlocked.Add(ref _pendingAppends, -bursts);
ContinuousHistorizationMetrics.ReportPendingAppends(Interlocked.Add(ref _pendingAppends, -bursts));
return;
}
catch (Exception ex)
@@ -600,7 +600,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
"the remainder are lost.", ex.GetType().Name, merged.Count, bursts);
}
Interlocked.Add(ref _pendingAppends, -bursts);
// Published here, not only from the actor thread: otherwise the gauge keeps the depth the last burst saw.
ContinuousHistorizationMetrics.ReportPendingAppends(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);