fix(backend): excessive-events sweep warns and blocks on recounted rows

The stored EventCount doubles on replayed upload batches and was only
corrected at terminal transitions, so the sweep judged running sessions on
an inflated number. It now recounts a candidate before it records the ops
event or blocks the device, skips candidates it cannot verify, and limits
the recount to candidates with a pending action. The warn text no longer
claims an agent loop bug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-21 23:45:11 +02:00
parent 15b25ff509
commit c156350183
4 changed files with 106 additions and 3 deletions
@@ -116,6 +116,53 @@ public class MaintenanceServiceExcessiveEventAutoActionTests
Assert.False(MaintenanceService.IsAutoActionEligible(status));
}
// ── Recount gate ──────────────────────────────────────────────────────────
// The stored counter doubles on replayed batches, so every warn and every block is
// decided on a recount. The gate keeps that Events-partition scan to candidates that
// still have something to fire.
[Fact]
public void HasPendingAction_WarnNotYetFired_IsPending()
{
Assert.True(MaintenanceService.HasPendingExcessiveEventAction(
storedEventCount: 2001, SessionStatus.Succeeded, alreadyAlerted: false, alreadyAutoActioned: false,
warnThreshold: 2000, autoMode: "Off", autoThreshold: 2500));
}
[Fact]
public void HasPendingAction_BlockNotYetFiredOnRunningSession_IsPending()
{
Assert.True(MaintenanceService.HasPendingExcessiveEventAction(
storedEventCount: 2501, SessionStatus.InProgress, alreadyAlerted: true, alreadyAutoActioned: false,
warnThreshold: 2000, autoMode: "Block", autoThreshold: 2500));
}
[Fact]
public void HasPendingAction_FinishedSessionAboveBlockThreshold_IsNotRescannedForever()
{
// Warned long ago, can never be blocked any more: without the eligibility term this
// row would cost a full partition scan on every sweep until retention removes it.
Assert.False(MaintenanceService.HasPendingExcessiveEventAction(
storedEventCount: 9000, SessionStatus.Incomplete, alreadyAlerted: true, alreadyAutoActioned: false,
warnThreshold: 2000, autoMode: "Block", autoThreshold: 2500));
}
[Fact]
public void HasPendingAction_BothAlreadyFired_IsNotPending()
{
Assert.False(MaintenanceService.HasPendingExcessiveEventAction(
storedEventCount: 9000, SessionStatus.InProgress, alreadyAlerted: true, alreadyAutoActioned: true,
warnThreshold: 2000, autoMode: "Block", autoThreshold: 2500));
}
[Fact]
public void HasPendingAction_WarnDisabledAndModeOff_IsNotPending()
{
Assert.False(MaintenanceService.HasPendingExcessiveEventAction(
storedEventCount: 9000, SessionStatus.InProgress, alreadyAlerted: false, alreadyAutoActioned: false,
warnThreshold: 0, autoMode: "Off", autoThreshold: 2500));
}
// ── Status parsing feeding that gate ──────────────────────────────────────
// The runaway query projects a subset of columns. SessionStatus.InProgress is ordinal 0,
// so an unparsed Status would leave every row looking in-progress and the gate above
@@ -815,6 +815,23 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
internal static bool IsAutoActionEligible(SessionStatus status) => status == SessionStatus.InProgress;
/// <summary>
/// Pure gate: would the STORED counter trigger a warn or an auto-action that has not
/// fired yet? Only then is the authoritative recount worth its Events-partition scan.
/// The auto tier counts only while the session is still eligible — otherwise a finished
/// session above the auto threshold would be rescanned on every sweep until retention.
/// </summary>
internal static bool HasPendingExcessiveEventAction(
int storedEventCount, SessionStatus status, bool alreadyAlerted, bool alreadyAutoActioned,
int warnThreshold, string? autoMode, int autoThreshold)
{
var warnPending = warnThreshold > 0 && storedEventCount > warnThreshold && !alreadyAlerted;
var autoPending = !alreadyAutoActioned
&& IsAutoActionEligible(status)
&& DecideAutoAction(storedEventCount, autoMode, autoThreshold) != null;
return warnPending || autoPending;
}
/// <summary>
/// Scans every tenant for sessions whose EventCount exceeds the configured warn
/// threshold (<see cref="AdminConfiguration.ExcessiveEventCountThreshold"/>) or the
@@ -880,8 +897,20 @@ namespace AutopilotMonitor.Functions.Services
try
{
var runaways = await _sessionRepo.GetSessionsWithEventCountAboveAsync(tenantId, queryThreshold);
foreach (var session in runaways)
foreach (var candidate in runaways)
{
if (!HasPendingExcessiveEventAction(
candidate.EventCount, candidate.Status,
candidate.ExcessiveEventsAlerted, candidate.ExcessiveEventsAutoActioned,
warnEnabled ? warnThreshold : 0, autoEnabled ? autoMode : "Off", autoThreshold))
continue;
// The stored EventCount is an at-least-once increment: replayed batches
// dedupe as rows but double the counter, and nothing corrects it while
// the session is still running. Warn and block on the recounted rows only.
var session = await RecountRunawayCandidateAsync(tenantId, candidate);
if (session == null) continue;
// Warn-tier: emit once per session, regardless of auto-action state.
if (warnEnabled && session.EventCount > warnThreshold && !session.ExcessiveEventsAlerted)
{
@@ -961,6 +990,32 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Replaces the stored counters of a runaway candidate with the authoritative Events row
/// count and returns the re-read session. Returns <c>null</c> when the count could not be
/// verified — the candidate is then left alone until the next sweep rather than warned
/// about or blocked on an unverified number.
/// </summary>
private async Task<SessionSummary?> RecountRunawayCandidateAsync(string tenantId, SessionSummary candidate)
{
var scan = await _sessionRepo.ReconcileSessionCountersAsync(tenantId, candidate.SessionId);
var recounted = scan == null ? null : await _sessionRepo.GetSessionAsync(tenantId, candidate.SessionId);
if (recounted == null)
{
_logger.LogWarning(
"Excessive-event scan: could not verify the event count of session {SessionId} (tenant {TenantId}, stored {StoredCount}); skipped until the next sweep",
candidate.SessionId, tenantId, candidate.EventCount);
return null;
}
if (recounted.EventCount != candidate.EventCount)
_logger.LogWarning(
"Excessive-event scan: session {SessionId} (tenant {TenantId}) stored EventCount {StoredCount} corrected to {ActualCount} rows",
candidate.SessionId, tenantId, candidate.EventCount, recounted.EventCount);
return recounted;
}
/// <summary>
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
@@ -984,7 +984,7 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordExcessiveSessionEventsAsync(string tenantId, string sessionId, int eventCount, int threshold)
=> WriteAsync(OpsEventCategory.Agent, OpsEventTypes.ExcessiveSessionEvents, OpsEventSeverity.Warning,
$"Session {sessionId} has {eventCount} events (threshold {threshold}) — likely agent loop bug",
$"Session {sessionId} has {eventCount} events (threshold {threshold}) — unusually high event volume",
tenantId, "System.Maintenance", new { sessionId, eventCount, threshold });
/// <summary>
@@ -178,7 +178,8 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Excessive-Event Detection ---
/// <summary>
/// Returns sessions in <paramref name="tenantId"/> whose EventCount exceeds <paramref name="threshold"/>.
/// Used by maintenance to surface runaway sessions (likely agent loop bugs).
/// Used by maintenance to find runaway candidates; the stored counter can overcount, so
/// the caller recounts before acting on it.
/// </summary>
Task<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold);