perf(backend): project GetSessionStatsAsync drain to the columns it tallies

GetSessionStatsAsync drained the entire window via GetSessionsAsync, which
materializes every ~40-column SessionsIndex mirror row (incl. the large
FailureSnapshotJson, all Os*/Geo*/PendingActionsJson columns) — but
AggregateSessionStats only reads Status, StartedAt and DurationSeconds.

Drain through FetchSessionsPageInternalAsync with a narrow projection
{ PartitionKey, RowKey, SessionId, Status, StartedAt, CompletedAt, DurationSeconds }:
- PartitionKey/RowKey/SessionId: TenantId map + page-cursor derivation.
- CompletedAt: ComputeEffectiveDuration's fallback for a Succeeded session
  with no stored DurationSeconds — kept so the tally value is identical.
- IsPreProvisioned/ResumedAt deliberately omitted: they only feed the
  WhiteGlove Part-2 duration branch, which is gated on status==InProgress,
  and AggregateSessionStats never reads DurationSeconds for InProgress
  sessions — so dropping them cannot change any reported value.

FetchSessionsPageInternalAsync gains an optional `select` param defaulting to
null (= full row), so its only other caller (GetSessionsPageAsync) is
unchanged. Cursor/HasMore/pre-migration fallback mechanics are untouched.

DurationSeconds has a history of display/calc bugs, so equivalence is pinned
by SessionStatsProjectionEquivalenceTests: it reduces a full mirror row to
exactly SessionStatsProjection (derived from the production array, so a future
column drop fails the test), maps both shapes through the real production
mapper, and asserts identical DurationSeconds + identical SessionStats across
a mixed fleet — including a WhiteGlove Succeeded (drops IsPreProvisioned/
ResumedAt) and a CompletedAt-fallback Succeeded. Verified the test bites:
removing CompletedAt from the projection fails it. All 2544 backend tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-30 16:24:59 +02:00
parent bcd307d8a4
commit b41796344e
2 changed files with 215 additions and 4 deletions
@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins that the column-projected SessionsIndex scan driving <c>GetSessionStatsAsync</c> produces
/// byte-identical dashboard stats to the old full-row drain — in particular that the omitted
/// columns can never shift the (historically fragile) DurationSeconds calculation.
///
/// The stats drain reads only <c>SessionStatsProjection</c>
/// { PartitionKey, RowKey, SessionId, Status, StartedAt, CompletedAt, DurationSeconds }. In real
/// Azure Table Storage a <c>$select</c> returns ONLY those properties; every other column is
/// genuinely absent from the entity. These tests reproduce that faithfully: the "projected" row is
/// built with the projected keys ONLY (no IsPreProvisioned / ResumedAt / noise), so a getter for an
/// omitted column returns null exactly as it would against the live service. We then map both shapes
/// through the production mapper (<see cref="TableStorageService.MapIndexEntityToSessionSummary"/>)
/// and through <see cref="TableStorageService.AggregateSessionStats"/> and assert equivalence.
/// </summary>
public class SessionStatsProjectionEquivalenceTests
{
private const string TenantId = "00000000-0000-0000-0000-000000000abc";
private static readonly TableStorageService Sut =
new(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
// SessionsIndex RowKey = inverted-tick prefix + "_" + sessionId (see ComputeIndexRowKey).
private static string IndexRowKey(DateTime startedAt, string sessionId)
=> $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
/// <summary>Full ~40-column mirror row as the list drain would read it (no projection).</summary>
private static TableEntity FullRow(
string sessionId, string status, DateTime startedAt,
int? durationSeconds, DateTime? completedAt,
bool isPreProvisioned, DateTime? resumedAt)
{
var e = new TableEntity(TenantId, IndexRowKey(startedAt, sessionId))
{
["SessionId"] = sessionId,
["Status"] = status,
["StartedAt"] = new DateTimeOffset(startedAt),
["IsPreProvisioned"] = isPreProvisioned,
// Representative noise that the stats never read — present on a real mirror row, absent
// on the projected one. Proves the projection drops them without changing the outcome.
["SerialNumber"] = "SN-FULL",
["DeviceName"] = "PC-FULL",
["Manufacturer"] = "Contoso",
["Model"] = "Model-X",
["OsName"] = "Windows 11",
["GeoCountry"] = "DE",
["EventCount"] = 123,
["PlatformScriptCount"] = 4,
["FailureSnapshotJson"] = "{\"big\":\"" + new string('x', 2000) + "\"}",
};
if (durationSeconds.HasValue) e["DurationSeconds"] = durationSeconds.Value;
if (completedAt.HasValue) e["CompletedAt"] = new DateTimeOffset(completedAt.Value);
if (resumedAt.HasValue) e["ResumedAt"] = new DateTimeOffset(resumedAt.Value);
return e;
}
/// <summary>
/// Reduces a full mirror row to exactly what Azure returns under the production
/// <c>$select = SessionStatsProjection</c>: the projected data columns plus the structural
/// PartitionKey/RowKey; every other property is genuinely absent. Deriving the keep-set from
/// the production array (rather than hardcoding it) means dropping a column there — e.g.
/// CompletedAt — would immediately fail the fallback test below.
/// </summary>
private static TableEntity Project(TableEntity full)
{
var keep = new HashSet<string>(TableStorageService.SessionStatsProjection, StringComparer.Ordinal);
var projected = new TableEntity(full.PartitionKey, full.RowKey);
foreach (var kv in full)
{
// PartitionKey/RowKey/Timestamp/ETag are structural (set via ctor / system-managed).
if (kv.Key is "PartitionKey" or "RowKey" or "Timestamp" or "odata.etag") continue;
if (keep.Contains(kv.Key)) projected[kv.Key] = kv.Value;
}
return projected;
}
// ── DurationSeconds equivalence — the historically fragile field ─────────────
[Fact]
public void Succeeded_whiteglove_with_stored_duration_maps_identically_despite_dropped_columns()
{
// The decisive case: a WhiteGlove (IsPreProvisioned + ResumedAt) Succeeded session. The full
// row carries both columns; the projected row drops them. They feed ONLY the Part-2 branch,
// which is gated on status==InProgress, so a Succeeded session must resolve to the stored
// duration on BOTH shapes. If the projection were unsafe this is where it would break.
var started = DateTime.UtcNow.AddHours(-3);
var sid = Guid.NewGuid().ToString();
var fullRow = FullRow(
sid, "Succeeded", started, durationSeconds: 1800, completedAt: started.AddSeconds(1800),
isPreProvisioned: true, resumedAt: started.AddMinutes(20));
var full = Sut.MapIndexEntityToSessionSummary(fullRow);
var projected = Sut.MapIndexEntityToSessionSummary(Project(fullRow));
Assert.Equal(1800, full.DurationSeconds);
Assert.Equal(full.DurationSeconds, projected.DurationSeconds);
Assert.Equal(full.Status, projected.Status);
Assert.Equal(full.StartedAt, projected.StartedAt);
Assert.Equal(sid, projected.SessionId);
}
[Fact]
public void Succeeded_without_stored_duration_falls_back_to_completedAt_on_both_shapes()
{
// No stored DurationSeconds → ComputeEffectiveDuration uses (CompletedAt - StartedAt).
// CompletedAt IS in the projection precisely so this fallback stays identical; this test
// would fail if CompletedAt were ever dropped from SessionStatsProjection.
var started = DateTime.UtcNow.AddHours(-2);
var completed = started.AddSeconds(1200);
var sid = Guid.NewGuid().ToString();
var fullRow = FullRow(
sid, "Succeeded", started, durationSeconds: null, completedAt: completed,
isPreProvisioned: false, resumedAt: null);
var full = Sut.MapIndexEntityToSessionSummary(fullRow);
var projected = Sut.MapIndexEntityToSessionSummary(Project(fullRow));
Assert.Equal(1200, full.DurationSeconds);
Assert.Equal(full.DurationSeconds, projected.DurationSeconds);
}
// ── End-to-end: projected drain yields identical SessionStats ────────────────
[Fact]
public void AggregateSessionStats_is_identical_for_full_vs_projected_fleet()
{
var now = DateTime.UtcNow;
// A representative mixed fleet covering every status the cards count, including a WhiteGlove
// Succeeded (drops IsPreProvisioned/ResumedAt), a CompletedAt-fallback Succeeded, and an
// InProgress WhiteGlove whose per-row duration DOES diverge between shapes but which the
// tally never reads — so the aggregate must still match exactly.
var fleet = new (string Status, DateTime Started, int? Dur, DateTime? Completed, bool Wg, DateTime? Resumed)[]
{
("Succeeded", now.AddHours(-1), 1800, now.AddHours(-1).AddSeconds(1800), false, null),
("Succeeded", now.AddHours(-2), 600, now.AddHours(-2).AddSeconds(600), true, now.AddHours(-2).AddMinutes(15)), // WhiteGlove
("Succeeded", now.AddHours(-3), null, now.AddHours(-3).AddSeconds(900), false, null), // CompletedAt fallback
("Failed", now.AddHours(-4), 7200, now.AddHours(-4).AddSeconds(7200), false, null), // duration ignored for Failed
("InProgress", now.AddMinutes(-30), 60, null, true, now.AddMinutes(-10)), // WG in-flight: per-row dur diverges, ignored
("Pending", now.AddDays(-2), null, null, true, null),
("Stalled", now.AddHours(-9), null, null, false, null),
("Succeeded", now.Date.AddHours(2), 1200, now.Date.AddHours(2).AddSeconds(1200), false, null), // today
("Failed", now.Date.AddHours(3), null, null, false, null), // today
};
var fullSummaries = new List<SessionSummary>();
var projectedSummaries = new List<SessionSummary>();
foreach (var (status, started, dur, completed, wg, resumed) in fleet)
{
var sid = Guid.NewGuid().ToString();
var fullRow = FullRow(sid, status, started, dur, completed, wg, resumed);
fullSummaries.Add(Sut.MapIndexEntityToSessionSummary(fullRow));
projectedSummaries.Add(Sut.MapIndexEntityToSessionSummary(Project(fullRow)));
}
var full = TableStorageService.AggregateSessionStats(fullSummaries, days: 30);
var projected = TableStorageService.AggregateSessionStats(projectedSummaries, days: 30);
Assert.Equal(full.Days, projected.Days);
Assert.Equal(full.ActiveCount, projected.ActiveCount);
Assert.Equal(full.TotalLastNDays, projected.TotalLastNDays);
Assert.Equal(full.SucceededLastNDays, projected.SucceededLastNDays);
Assert.Equal(full.FailedLastNDays, projected.FailedLastNDays);
Assert.Equal(full.SuccessRatePct, projected.SuccessRatePct);
Assert.Equal(full.AvgDurationMinutes, projected.AvgDurationMinutes); // the duration-sensitive card
Assert.Equal(full.TotalToday, projected.TotalToday);
Assert.Equal(full.FailedToday, projected.FailedToday);
}
}
@@ -293,7 +293,9 @@ namespace AutopilotMonitor.Functions.Services
/// The key difference from the primary-table mapping: SessionId comes from a stored property
/// instead of RowKey (which contains the inverted-tick key in the index).
/// </summary>
private SessionSummary MapIndexEntityToSessionSummary(TableEntity entity)
// internal (not private) so SessionStatsProjectionEquivalenceTests can pin that a row
// carrying only SessionStatsProjection maps to the same stat-relevant fields as a full row.
internal SessionSummary MapIndexEntityToSessionSummary(TableEntity entity)
=> MapToSessionSummary(entity, entity.GetString("SessionId") ?? ExtractSessionIdFromIndexRowKey(entity.RowKey));
// ===== SESSION MANAGEMENT METHODS =====
@@ -788,7 +790,7 @@ namespace AutopilotMonitor.Functions.Services
/// <see cref="GetSessionsPageAsync(string, int?, int, string?)"/> (single page).
/// </summary>
private async Task<(List<SessionSummary> Sessions, bool HasMore, string? NextCursor)> FetchSessionsPageInternalAsync(
string tenantId, int maxResults, string? cursor, int? days)
string tenantId, int maxResults, string? cursor, int? days, IEnumerable<string>? select = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
@@ -817,9 +819,12 @@ namespace AutopilotMonitor.Functions.Services
// mechanics are independent of the date window so a tenant with more
// than `maxResults` matching sessions remains fully reachable across
// multiple calls (was: silently capped at 10k pre-fix).
// select == null → full mirror row (every existing caller). The stats drain passes a
// narrow projection so it doesn't materialize the ~40-column row just to tally statuses.
var query = indexTableClient.QueryAsync<TableEntity>(
filter: filter,
maxPerPage: Math.Min(maxResults + 1, 1000)
maxPerPage: Math.Min(maxResults + 1, 1000),
select: select
);
var sessions = new List<SessionSummary>();
@@ -961,12 +966,39 @@ namespace AutopilotMonitor.Functions.Services
/// Computed server-side so the cards don't depend on whatever the client has
/// paginated into memory.
/// </summary>
// Columns AggregateSessionStats (and the ComputeEffectiveDuration it relies on for Succeeded
// sessions) actually reads, plus PartitionKey/RowKey/SessionId for the TenantId map + page
// cursor. Everything else on the ~40-column SessionsIndex mirror (FailureSnapshotJson, Os*,
// Geo*, PendingActionsJson, …) is never consumed by the tally, so the stats drain skips it.
// Crucially IsPreProvisioned/ResumedAt are omitted on purpose: they only feed the WhiteGlove
// Part-2 duration branch, which is gated on status==InProgress, and the stats never read
// DurationSeconds for InProgress sessions — so dropping them cannot change any reported value.
// Equivalence is pinned by SessionStatsProjectionEquivalenceTests. Non-projected columns map
// to null/default via the Safe*/`?? ` getters in MapToSessionSummary — no missing-column throw.
// internal so the equivalence test derives its simulated projected row from this exact set
// (any future column drop here is automatically reflected in the test's faithfulness).
internal static readonly string[] SessionStatsProjection =
{ "PartitionKey", "RowKey", "SessionId", "Status", "StartedAt", "CompletedAt", "DurationSeconds" };
public async Task<SessionStats> GetSessionStatsAsync(string tenantId, int days)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
if (days < 1) throw new ArgumentOutOfRangeException(nameof(days));
var sessions = await GetSessionsAsync(tenantId, days);
// Stats are a pure status/duration tally, so drain the window with a projected
// SessionsIndex scan instead of GetSessionsAsync's full-row materialization. Same
// cursor/HasMore/fallback mechanics as the list drain (via FetchSessionsPageInternalAsync),
// just a narrower column set — the returned partial summaries never leave this method.
var sessions = new List<SessionSummary>();
string? cursor = null;
do
{
var page = await FetchSessionsPageInternalAsync(
tenantId, maxResults: 1000, cursor: cursor, days: days, select: SessionStatsProjection);
sessions.AddRange(page.Sessions);
cursor = page.HasMore ? page.NextCursor : null;
} while (!string.IsNullOrEmpty(cursor));
return AggregateSessionStats(sessions, days);
}