perf(alarms): dirty-set coalescing — one evaluation per alarm per drain, latest values win

This commit is contained in:
Joseph Doherty
2026-08-16 04:02:20 -04:00
parent 2c708f5560
commit 5df793e79a
2 changed files with 293 additions and 9 deletions
@@ -1434,4 +1434,202 @@ public sealed class ScriptedAlarmEngineTests
.GetValue(evaluator)!;
return new WeakReference(del.Method.Module.Assembly);
}
// -------------------------------------------------------------------------
// Dirty-set coalescing (perf roadmap task 15).
//
// Pre-fix, every upstream push spawned its own fire-and-forget
// ReevaluateAsync, and each one queued on the single _evalGate. N pushes
// arriving during one slow evaluation therefore produced N serialized
// re-evaluations of the same alarms, all of them reading the SAME (latest)
// cached values — N-1 of them redundant by construction.
//
// Post-fix a push only marks its alarms dirty; a single drainer swaps the
// set and evaluates it once. The evaluation-count probe below is a
// ctx.Logger line inside the predicate counted by a Serilog sink: the store
// save-count alone cannot tell the two shapes apart, because the redundant
// passes produce no transition and therefore no save.
// -------------------------------------------------------------------------
/// <summary>Verifies that a storm of pushes arriving while an evaluation is in flight collapses
/// into a single further drain pass whose evaluation sees the LATEST pushed value, with a
/// falsification control proving the collapsed count comes from coalescing and not from dedup.</summary>
[Fact]
public async Task A_storm_of_pushes_coalesces_to_one_drain_pass()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var probe = new CountingSink("EVALPROBE");
var logger = new LoggerConfiguration().WriteTo.Sink(probe).CreateLogger();
var store = new CountingAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
await eng.LoadAsync([
Alarm("A", """ctx.Logger.Information("EVALPROBE"); return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// LoadAsync itself evaluates once (startup recovery) — start counting after it.
probe.Reset();
store.ResetCount();
// Block the save the first push's evaluation performs. That evaluation then
// sits INSIDE _evalGate for as long as the test wants, which is the window
// every subsequent push has to coalesce into.
var saveGate = new TaskCompletionSource();
store.BlockNextSave = saveGate;
up.Push("Temp", 150); // evaluation #1 → Activated → blocks in SaveAsync
await WaitForAsync(() => store.SaveInProgress, timeoutMs: 3000);
// The storm: 10 further pushes while the gate is held. The LAST one drops
// back below the threshold, so "the drain evaluated the latest values" is
// observable as the alarm ending Inactive rather than Active.
for (var i = 1; i <= 9; i++) up.Push("Temp", 150 + i);
up.Push("Temp", 50);
saveGate.SetResult();
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive, timeoutMs: 3000);
probe.Count.ShouldBeLessThanOrEqualTo(2,
"11 pushes over one blocked evaluation must produce the in-flight evaluation plus at " +
"most ONE coalesced drain pass — pre-fix this was 11 serialized re-evaluations.");
store.SaveCount.ShouldBe(2,
"only genuine transitions persist: Activated (the blocked save) + Cleared (the drain).");
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
"the coalesced drain must evaluate the LATEST cached values — latest-wins, never lost-update.");
// --- Falsification control -------------------------------------------
// Spaced pushes (each drain completes before the next push lands) must
// still produce one evaluation EACH. If this collapsed too, the counts
// above would be proving dedup/short-circuiting rather than coalescing.
probe.Reset();
store.ResetCount();
foreach (var v in new[] { 150, 50, 150, 50 })
{
up.Push("Temp", v);
var want = v > 100 ? AlarmActiveState.Active : AlarmActiveState.Inactive;
await WaitForAsync(() => eng.GetState("A")!.Active == want, timeoutMs: 3000);
}
probe.Count.ShouldBe(4,
"four pushes that each land on an idle engine must each be evaluated — the collapsed " +
"count in the storm above comes from coalescing, not from the engine skipping work.");
store.SaveCount.ShouldBe(4);
}
/// <summary>Verifies that a push landing while a drain pass holds the evaluation gate is not
/// dropped — the drainer loops and evaluates it in a following pass.</summary>
[Fact]
public async Task A_push_during_a_drain_is_not_lost()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var probe = new CountingSink("EVALPROBE");
var logger = new LoggerConfiguration().WriteTo.Sink(probe).CreateLogger();
var store = new CountingAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
await eng.LoadAsync([
Alarm("A", """ctx.Logger.Information("EVALPROBE"); return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
probe.Reset();
var saveGate = new TaskCompletionSource();
store.BlockNextSave = saveGate;
up.Push("Temp", 150); // drain pass #1 → Activated → blocks in SaveAsync
await WaitForAsync(() => store.SaveInProgress, timeoutMs: 3000);
// Lands while the drainer is inside _evalGate: it must be recorded in the
// dirty set and picked up by the drainer's next loop iteration, NOT dropped
// because "a drain is already scheduled".
up.Push("Temp", 40);
saveGate.SetResult();
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive, timeoutMs: 3000);
probe.Count.ShouldBe(2, "the in-flight pass plus exactly one follow-up pass for the late push");
}
/// <summary>
/// A Serilog sink that counts the events whose message template contains a marker string.
/// Used to count predicate EVALUATIONS (the predicate emits the marker through
/// <c>ctx.Logger</c>) — the store's save count cannot serve as that probe, because a
/// redundant re-evaluation produces no transition and therefore no save.
/// </summary>
private sealed class CountingSink : Serilog.Core.ILogEventSink
{
private readonly string _marker;
private int _count;
/// <summary>Initializes the sink with the marker substring it counts.</summary>
/// <param name="marker">Substring the message template must contain to be counted.</param>
public CountingSink(string marker) => _marker = marker;
/// <summary>Gets the number of matching events emitted since the last <see cref="Reset"/>.</summary>
public int Count => Volatile.Read(ref _count);
/// <summary>Resets the counter to zero.</summary>
public void Reset() => Interlocked.Exchange(ref _count, 0);
/// <summary>Counts the event if its message template carries the marker.</summary>
/// <param name="logEvent">The Serilog event being emitted.</param>
public void Emit(Serilog.Events.LogEvent logEvent)
{
if (logEvent.MessageTemplate.Text.Contains(_marker, StringComparison.Ordinal))
Interlocked.Increment(ref _count);
}
}
/// <summary>
/// Store decorator that counts SaveAsync calls and can hold the next save open until the
/// test releases it — the two capabilities the coalescing tests need together (block one
/// evaluation inside <c>_evalGate</c>, then count what the released drain actually did).
/// </summary>
private sealed class CountingAlarmStateStore : IAlarmStateStore
{
private readonly InMemoryAlarmStateStore _inner = new();
private int _saveCount;
/// <summary>Gets the number of SaveAsync calls since the last <see cref="ResetCount"/>.</summary>
public int SaveCount => Volatile.Read(ref _saveCount);
/// <summary>Resets the save counter to zero.</summary>
public void ResetCount() => Interlocked.Exchange(ref _saveCount, 0);
/// <summary>Gets or sets a TaskCompletionSource that, when set, blocks the next SaveAsync until signalled.</summary>
public TaskCompletionSource? BlockNextSave { get; set; }
/// <summary>Gets a value indicating whether a SaveAsync call is currently blocked waiting on BlockNextSave.</summary>
public bool SaveInProgress { get; private set; }
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
/// <param name="ct">A cancellation token.</param>
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> _inner.LoadAsync(alarmId, ct);
/// <summary>Loads all alarm condition states from the inner store.</summary>
/// <param name="ct">A cancellation token.</param>
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> _inner.LoadAllAsync(ct);
/// <summary>Counts the save, optionally blocking on the BlockNextSave gate, then delegates.</summary>
/// <param name="state">The alarm condition state to save.</param>
/// <param name="ct">A cancellation token.</param>
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
Interlocked.Increment(ref _saveCount);
var gate = BlockNextSave;
if (gate is not null)
{
BlockNextSave = null;
SaveInProgress = true;
try { await gate.Task.WaitAsync(ct).ConfigureAwait(false); }
finally { SaveInProgress = false; }
}
await _inner.SaveAsync(state, ct).ConfigureAwait(false);
}
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
/// <param name="ct">A cancellation token.</param>
public Task RemoveAsync(string alarmId, CancellationToken ct)
=> _inner.RemoveAsync(alarmId, ct);
}
}