mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Merge pull request #80 from okieselbach/perf/backend-read-projections-sinceutc
perf(backend): index projections, windowed AppInstall reads, projected stats drain
This commit is contained in:
+179
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the AppInstall window-consistency bug: the session window and the AppInstall
|
||||
/// sinceUtc cutoff must derive from ONE shared windowStart, not two separate DateTime.UtcNow snapshots
|
||||
/// taken moments apart (the second sits slightly later because the in-between aggregates take time).
|
||||
/// If the app cutoff drifted later than the session start, a session included at the lower boundary
|
||||
/// could keep its row while its app summaries fell into the gap and were dropped — undercounting
|
||||
/// AppScripts.AvgAppsPerSession and TotalUniqueApps. These tests assert the app repo receives a
|
||||
/// cutoff identical to (and therefore <=) the session repo's window start, on both code paths.
|
||||
/// </summary>
|
||||
public class UsageMetricsWindowConsistencyTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-000000000777";
|
||||
|
||||
[Fact]
|
||||
public async Task Global_app_cutoff_equals_session_window_start()
|
||||
{
|
||||
DateTime? sessionStart = null;
|
||||
DateTime? appCutoff = null;
|
||||
|
||||
var maintenance = new Mock<IMaintenanceRepository>();
|
||||
maintenance
|
||||
.Setup(m => m.GetSessionsByDateRangeAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<string?>()))
|
||||
.Callback<DateTime, DateTime, string?>((start, _, _) => sessionStart = start)
|
||||
.ReturnsAsync(new List<SessionSummary>());
|
||||
|
||||
var metrics = new Mock<IMetricsRepository>();
|
||||
metrics
|
||||
.Setup(m => m.GetAllAppInstallSummariesAsync(It.IsAny<DateTime?>()))
|
||||
.Callback<DateTime?>(c => appCutoff = c)
|
||||
.ReturnsAsync(new List<AppInstallSummary>());
|
||||
metrics.Setup(m => m.GetAllUserActivityMetricsAsync()).ReturnsAsync(new UserActivityMetrics());
|
||||
metrics.Setup(m => m.GetPlatformStatsAsync()).ReturnsAsync((PlatformStats?)null);
|
||||
|
||||
var sut = new UsageMetricsService(metrics.Object, maintenance.Object, NullLogger<UsageMetricsService>.Instance);
|
||||
|
||||
await sut.ComputeUsageMetricsInternalAsync(days: 30);
|
||||
|
||||
Assert.NotNull(sessionStart);
|
||||
Assert.NotNull(appCutoff);
|
||||
Assert.Equal(sessionStart, appCutoff); // identical lower bound (one shared windowStart)
|
||||
Assert.True(appCutoff <= sessionStart); // ... and never later than the session start
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_app_cutoff_equals_session_window_start()
|
||||
{
|
||||
DateTime? sessionStart = null;
|
||||
DateTime? appCutoff = null;
|
||||
|
||||
var maintenance = new Mock<IMaintenanceRepository>();
|
||||
maintenance
|
||||
.Setup(m => m.GetSessionsByDateRangeAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<string?>()))
|
||||
.Callback<DateTime, DateTime, string?>((start, _, _) => sessionStart = start)
|
||||
.ReturnsAsync(new List<SessionSummary>());
|
||||
|
||||
var metrics = new Mock<IMetricsRepository>();
|
||||
metrics
|
||||
.Setup(m => m.GetAppInstallSummariesByTenantAsync(It.IsAny<string>(), It.IsAny<DateTime?>()))
|
||||
.Callback<string, DateTime?>((_, c) => appCutoff = c)
|
||||
.ReturnsAsync(new List<AppInstallSummary>());
|
||||
metrics.Setup(m => m.GetUserActivityMetricsAsync(It.IsAny<string>())).ReturnsAsync(new UserActivityMetrics());
|
||||
metrics.Setup(m => m.GetPlatformStatsAsync()).ReturnsAsync((PlatformStats?)null);
|
||||
|
||||
var sut = new UsageMetricsService(metrics.Object, maintenance.Object, NullLogger<UsageMetricsService>.Instance);
|
||||
|
||||
await sut.ComputeTenantUsageMetricsInternalAsync(TenantId, days: 30);
|
||||
|
||||
Assert.NotNull(sessionStart);
|
||||
Assert.NotNull(appCutoff);
|
||||
Assert.Equal(sessionStart, appCutoff);
|
||||
Assert.True(appCutoff <= sessionStart);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -69,7 +69,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
? FilterSessionsByLocation(sessions, locationKey, groupBy)
|
||||
: FilterSessionsByFields(sessions, country!, query["region"], query["city"]);
|
||||
|
||||
var appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
|
||||
// Same window as the sessions above (cutoff); BuildRows joins apps to those sessions.
|
||||
var appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff);
|
||||
var rows = BuildRows(filtered, appSummaries);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+3
-2
@@ -67,9 +67,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
? GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy)
|
||||
: GetGeographicLocationSessionsFunction.FilterSessionsByFields(sessions, country!, query["region"], query["city"]);
|
||||
|
||||
// Same window as the sessions above (cutoff); BuildRows joins apps to those sessions.
|
||||
var appSummaries = string.IsNullOrEmpty(filterTenantId)
|
||||
? await _metricsRepo.GetAllAppInstallSummariesAsync()
|
||||
: await _metricsRepo.GetAppInstallSummariesByTenantAsync(filterTenantId);
|
||||
? await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff)
|
||||
: await _metricsRepo.GetAppInstallSummariesByTenantAsync(filterTenantId, cutoff);
|
||||
var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, appSummaries);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -139,10 +139,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// App metrics: count apps per session from AppInstallSummaries table
|
||||
var sessionIdSet = new HashSet<string>(sessions.Select(s => s.SessionId));
|
||||
List<AppInstallSummary> appSummaries;
|
||||
// Bound the AppInstall scan to this snapshot's day. The session set is already
|
||||
// [targetDate, targetDate+1) and apps install during their session (StartedAt >= targetDate),
|
||||
// so relevantApps is unchanged; without sinceUtc this scanned the whole table per aggregated day.
|
||||
if (tenantId == "global")
|
||||
appSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync();
|
||||
appSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(targetDate);
|
||||
else
|
||||
appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
|
||||
appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, targetDate);
|
||||
|
||||
var relevantApps = appSummaries.Where(a => sessionIdSet.Contains(a.SessionId)).ToList();
|
||||
var appsPerSession = relevantApps.GroupBy(a => a.SessionId).Select(g => g.Count()).ToList();
|
||||
|
||||
@@ -186,7 +186,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (config.SlaNotifyOnAppInstallBreach && config.SlaTargetAppInstallSuccessRate.HasValue)
|
||||
{
|
||||
var currentWeekKey = SlaMetricsService.GetIsoWeekKey(now);
|
||||
var raw = await _metricsRepo.GetAppInstallSummariesByTenantAsync(config.TenantId);
|
||||
// Bound to a safe margin before the current ISO week (an ISO week spans <=7 days, so
|
||||
// now-8d never excludes a current-week row). The exact week match happens in-memory
|
||||
// below; this just stops the query from scanning the tenant's entire history every 2h.
|
||||
var raw = await _metricsRepo.GetAppInstallSummariesByTenantAsync(config.TenantId, now.AddDays(-8));
|
||||
appInstalls = raw
|
||||
.Where(a => (a.Status == "Succeeded" || a.Status == "Failed")
|
||||
&& SlaMetricsService.GetIsoWeekKey(a.StartedAt) == currentWeekKey)
|
||||
|
||||
@@ -125,7 +125,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
List<AppInstallSummary>? appInstalls = null;
|
||||
if (config?.SlaTargetAppInstallSuccessRate != null)
|
||||
{
|
||||
appInstalls = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
|
||||
// Push the window's lower bound server-side (startDate); the in-memory filter below
|
||||
// still applies the upper bound. Without sinceUtc this read the tenant's full history.
|
||||
appInstalls = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, startDate);
|
||||
// Filter to the time window
|
||||
appInstalls = appInstalls
|
||||
.Where(a => a.StartedAt >= startDate && a.StartedAt <= endDate)
|
||||
|
||||
@@ -338,7 +338,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
: windowBound;
|
||||
|
||||
var scannedRows = 0;
|
||||
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
// Project to only the typeahead-matched fields (+ RowKey for the SessionId fallback at
|
||||
// ExtractSessionIdFromIndexRowKey). Avoids pulling the full ~40-column SessionsIndex
|
||||
// mirror for up to QuickSearchMaxScannedRows rows on every keystroke-driven request.
|
||||
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(
|
||||
filter: filter, select: new[] { "RowKey", "SessionId", "SerialNumber", "DeviceName", "Status", "StartedAt" }))
|
||||
{
|
||||
if (++scannedRows > QuickSearchMaxScannedRows)
|
||||
break;
|
||||
@@ -1261,7 +1265,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// longer vanish from the summary). See SessionStatusBuckets in MetricsMath.cs.
|
||||
var groups = new Dictionary<string, SessionStatusBuckets>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
||||
// Project to the two columns the tally consumes. The SessionsIndex row is a full
|
||||
// ~40-column mirror (incl. the large FailureSnapshotJson), so reading it whole for a
|
||||
// status count multiplied transfer ~10-20x for nothing on this cross-partition scan.
|
||||
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(
|
||||
filter: oDataFilter, select: new[] { "PartitionKey", "Status" }))
|
||||
{
|
||||
var pk = entity.PartitionKey;
|
||||
var statusStr = entity.GetString("Status") ?? "InProgress";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,14 +104,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return days;
|
||||
}
|
||||
|
||||
private async Task<PlatformUsageMetrics> ComputeUsageMetricsInternalAsync(int days)
|
||||
internal async Task<PlatformUsageMetrics> ComputeUsageMetricsInternalAsync(int days)
|
||||
{
|
||||
// Query sessions over the requested window (days). Today/7d/30d sub-aggregates remain
|
||||
// meaningful only when they fit inside the window; clients can read WindowDays to know
|
||||
// the actual scope. "Total" counters use PlatformStats (cumulative, tracked separately).
|
||||
var allSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-days), DateTime.UtcNow.AddDays(1));
|
||||
|
||||
// One UtcNow snapshot for the whole computation: the session window lower bound and the
|
||||
// AppInstall cutoff (below) MUST share an identical windowStart. A fresh UtcNow for the
|
||||
// app query would sit milliseconds later and could drop a boundary session's app rows
|
||||
// (undercounting AvgAppsPerSession / TotalUniqueApps). Pinned by UsageMetricsWindowConsistencyTests.
|
||||
var now = DateTime.UtcNow;
|
||||
var windowStart = now.AddDays(-days);
|
||||
var allSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(windowStart, now.AddDays(1));
|
||||
|
||||
var today = now.Date;
|
||||
var last7Days = now.AddDays(-7);
|
||||
var last30Days = now.AddDays(-30);
|
||||
@@ -217,7 +222,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
|
||||
// App & Script Metrics
|
||||
var allAppSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync();
|
||||
// Reuse windowStart (NOT a fresh UtcNow) so the AppInstall lower bound is identical to the
|
||||
// session window's — see the snapshot note above. relevantApps is filtered to sessionIdSet
|
||||
// anyway, and an app installs during its session (StartedAt >= the session's), so bounding
|
||||
// here only drops rows already outside the window instead of scanning the whole table.
|
||||
var allAppSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(windowStart);
|
||||
var sessionIdSet = new HashSet<string>(allSessions.Select(s => s.SessionId));
|
||||
var relevantApps = allAppSummaries.Where(a => sessionIdSet.Contains(a.SessionId)).ToList();
|
||||
var appsPerSessionList = relevantApps.GroupBy(a => a.SessionId).Select(g => g.Count()).ToList();
|
||||
@@ -264,12 +273,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsInternalAsync(string tenantId, int days)
|
||||
internal async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsInternalAsync(string tenantId, int days)
|
||||
{
|
||||
// Query sessions for specific tenant over the requested window.
|
||||
var tenantSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-days), DateTime.UtcNow.AddDays(1), tenantId);
|
||||
|
||||
// Query sessions for specific tenant over the requested window. One UtcNow snapshot so the
|
||||
// session window lower bound and the AppInstall cutoff (below) share an identical
|
||||
// windowStart — see ComputeUsageMetricsInternalAsync for why this must not drift.
|
||||
var now = DateTime.UtcNow;
|
||||
var windowStart = now.AddDays(-days);
|
||||
var tenantSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(windowStart, now.AddDays(1), tenantId);
|
||||
|
||||
var today = now.Date;
|
||||
var last7Days = now.AddDays(-7);
|
||||
var last30Days = now.AddDays(-30);
|
||||
@@ -366,7 +378,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
|
||||
// App & Script Metrics
|
||||
var tenantAppSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
|
||||
// Reuse windowStart (identical to the session window lower bound) — joined to tenantSessionIdSet below.
|
||||
var tenantAppSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, windowStart);
|
||||
var tenantSessionIdSet = new HashSet<string>(tenantSessions.Select(s => s.SessionId));
|
||||
var relevantTenantApps = tenantAppSummaries.Where(a => tenantSessionIdSet.Contains(a.SessionId)).ToList();
|
||||
var tenantAppsPerSession = relevantTenantApps.GroupBy(a => a.SessionId).Select(g => g.Count()).ToList();
|
||||
|
||||
Reference in New Issue
Block a user