perf(site-runtime): coalesce native-alarm mirror persistence into batched flushes (P4)

This commit is contained in:
Joseph Doherty
2026-07-09 01:05:20 -04:00
parent d2feb92bfd
commit 326d945d85
3 changed files with 154 additions and 7 deletions
@@ -53,7 +53,21 @@ public class NativeAlarmActor : ReceiveActor
/// <summary>Buffer accumulating a snapshot replay until <see cref="AlarmTransitionKind.SnapshotComplete"/>.</summary>
private readonly Dictionary<string, NativeAlarmTransition> _snapshotBuffer = new();
/// <summary>
/// Pending mirror upserts coalesced by source reference (P4): under an alarm storm each
/// source reference keeps only its LATEST transition, flushed as one batched SQLite write on
/// a short single-shot timer rather than a write per transition. A crash loses at most one
/// flush interval of mirror writes; the (re)subscribe snapshot swap reconciles the mirror, so
/// the durability trade is safe. A <see cref="PersistDelete"/> removes the ref here so a
/// deleted condition is not resurrected by a still-pending upsert.
/// </summary>
private readonly Dictionary<string, NativeAlarmTransition> _dirtyUpserts = new();
private ICancelable? _retryTimer;
private ICancelable? _flushTimer;
/// <summary>How long transitions coalesce before a batched persist flush (P4).</summary>
private readonly TimeSpan _persistFlushInterval;
/// <summary>Initializes a new <see cref="NativeAlarmActor"/> for a single resolved source binding.</summary>
/// <param name="source">The resolved native alarm source (connection + source reference + filter).</param>
@@ -68,6 +82,8 @@ public class NativeAlarmActor : ReceiveActor
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
/// <see cref="ISiteEventLogger"/> for <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects the mirror.</param>
/// <param name="persistFlushInterval">How long native-alarm transitions coalesce before a
/// batched SQLite persist flush (P4). Defaults to 100 ms.</param>
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
string instanceName,
@@ -77,7 +93,8 @@ public class NativeAlarmActor : ReceiveActor
SiteRuntimeOptions options,
ILogger logger,
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
IServiceProvider? serviceProvider = null)
IServiceProvider? serviceProvider = null,
TimeSpan? persistFlushInterval = null)
{
_source = source;
_instanceName = instanceName;
@@ -88,12 +105,14 @@ public class NativeAlarmActor : ReceiveActor
_logger = logger;
_nativeKind = nativeKind;
_serviceProvider = serviceProvider;
_persistFlushInterval = persistFlushInterval ?? TimeSpan.FromMilliseconds(100);
Receive<RehydrationCompleted>(HandleRehydration);
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
Receive<NativeAlarmSourceUnavailable>(HandleSourceUnavailable);
Receive<SubscribeAlarmsResponse>(HandleSubscribeResponse);
Receive<RetrySubscribe>(_ => SendSubscribe());
Receive<FlushPersistence>(_ => FlushDirtyUpserts());
}
/// <inheritdoc />
@@ -117,6 +136,11 @@ public class NativeAlarmActor : ReceiveActor
protected override void PostStop()
{
_retryTimer?.Cancel();
_flushTimer?.Cancel();
// Best-effort final flush of any coalesced upserts so a graceful stop does not lose the
// last ≤ flush-interval of mirror writes (P4). Fire-and-forget — a failed final write is
// reconciled by the next (re)subscribe snapshot swap.
FlushDirtyUpserts();
base.PostStop();
}
@@ -225,7 +249,7 @@ public class NativeAlarmActor : ReceiveActor
foreach (var (sourceRef, t) in _snapshotBuffer)
{
_alarms[sourceRef] = t;
PersistUpsert(t);
MarkDirtyUpsert(t);
// A snapshot replay is a re-sync of the source's current
// active set on (re)subscribe, NOT a live transition — surface it
// upward for the DebugView but do NOT re-log an `alarm` operational
@@ -249,7 +273,7 @@ public class NativeAlarmActor : ReceiveActor
}
_alarms[t.SourceReference] = t;
PersistUpsert(t);
MarkDirtyUpsert(t);
Emit(t, t.Condition);
// Retention: a resolved condition (inactive AND acknowledged) drops out of
@@ -428,18 +452,50 @@ public class NativeAlarmActor : ReceiveActor
"alarm", severity, _instanceName, $"NativeAlarmActor:{_source.CanonicalName}", message);
}
private void PersistUpsert(NativeAlarmTransition t)
/// <summary>
/// Records the latest transition per source reference and arms the coalesced flush timer (P4).
/// The actual SQLite write is deferred to <see cref="FlushDirtyUpserts"/> so an alarm storm
/// collapses into one batched write instead of a write per transition.
/// </summary>
private void MarkDirtyUpsert(NativeAlarmTransition t)
{
var json = JsonSerializer.Serialize(t.Condition);
_storage.UpsertNativeAlarmAsync(_instanceName, _source.CanonicalName, t.SourceReference, json, t.TransitionTime)
_dirtyUpserts[t.SourceReference] = t;
if (_flushTimer == null)
{
_flushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
_persistFlushInterval, Self, FlushPersistence.Instance, Self);
}
}
/// <summary>
/// Persists all coalesced upserts in one batched transaction (P4), then disarms the flush
/// timer. Fire-and-forget with OnlyOnFaulted logging — a failed write never blocks the actor;
/// the mirror reconciles on the next (re)subscribe snapshot.
/// </summary>
private void FlushDirtyUpserts()
{
_flushTimer = null;
if (_dirtyUpserts.Count == 0)
return;
var batch = _dirtyUpserts.Values
.Select(t => (t.SourceReference, JsonSerializer.Serialize(t.Condition), t.TransitionTime))
.ToList();
_dirtyUpserts.Clear();
_storage.UpsertNativeAlarmsAsync(_instanceName, _source.CanonicalName, batch)
.ContinueWith(
task => _logger.LogWarning(task.Exception,
"Failed to persist native alarm {Ref} on {Instance}", t.SourceReference, _instanceName),
"Failed to persist {Count} native alarms on {Instance}", batch.Count, _instanceName),
TaskContinuationOptions.OnlyOnFaulted);
}
private void PersistDelete(string sourceReference)
{
// Drop any pending coalesced upsert for this ref so the delete is not resurrected by a
// still-buffered upsert on the next flush (P4).
_dirtyUpserts.Remove(sourceReference);
_storage.DeleteNativeAlarmAsync(_instanceName, _source.CanonicalName, sourceReference)
.ContinueWith(
task => _logger.LogWarning(task.Exception,
@@ -456,4 +512,11 @@ public class NativeAlarmActor : ReceiveActor
public static readonly RetrySubscribe Instance = new();
private RetrySubscribe() { }
}
/// <summary>Self-tell that fires the coalesced native-alarm persist flush (P4).</summary>
private sealed class FlushPersistence
{
public static readonly FlushPersistence Instance = new();
private FlushPersistence() { }
}
}