perf(backend): push app-install-summary time window server-side + parallelize session join

The app dashboard and metrics endpoints loaded entire AppInstallSummaries
partitions (tenant scope) or the full table (global scope) and filtered
StartedAt >= cutoff in memory, even for days=30. The device-model join then
issued one serial GetSessionAsync point-read per distinct session.

- Add optional DateTime? sinceUtc to GetAppInstallSummariesByTenantAsync /
  GetAllAppInstallSummariesAsync: when set, a server-side StartedAt ge filter
  scopes the scan so only in-window rows are deserialized/transferred. Default
  null preserves the legacy full scan for callers that need all history.
- AppsAnalyticsHelper.LoadSummariesAsync derives sinceUtc from days; all six
  /api/apps/* functions pass it. The four metrics/app + metrics/geographic
  functions pass their existing cutoff. The in-memory Where stays as the exact
  sub-second trim (the OData filter is second-granular). MCP get_app_install_metrics
  benefits automatically (days already flows through; response shape unchanged).
- Replace the serial session point-read loop with a bounded-concurrency
  (SemaphoreSlim=10) LoadSessionLookupAsync, shared by the analytics and
  sessions builders.

AppInstallSummaries is already bounded by the session-retention cascade
(DeletionManifestBuilder) + tenant offboarding, so no separate table retention
is added. Full Backend suite: 2406 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-25 12:16:04 +02:00
parent 12dabbeab9
commit d0486de21e
17 changed files with 103 additions and 52 deletions
@@ -32,7 +32,7 @@ public class SlaMetricsServiceTests
.ReturnsAsync(sessions);
var metricsRepo = new Mock<IMetricsRepository>();
metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId))
metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId, It.IsAny<DateTime?>()))
.ReturnsAsync(appInstalls ?? new List<AppInstallSummary>());
var configService = new Mock<TenantConfigurationService>(
@@ -196,13 +196,13 @@ public class SlaTenantStatusLifecycleTests
_sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
// Default: no app installs
_metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>()))
_metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>(), It.IsAny<DateTime?>()))
.ReturnsAsync(new List<AppInstallSummary>());
}
public void SetAppInstalls(string tenantId, List<AppInstallSummary> apps)
{
_metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId)).ReturnsAsync(apps);
_metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId, It.IsAny<DateTime?>())).ReturnsAsync(apps);
}
public void SetTenants(params TenantConfiguration[] tenants)
@@ -427,7 +427,7 @@ public class SlaTenantStatusLifecycleTests
var opsSvc = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var metricsRepo = new Mock<IMetricsRepository>();
metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>()))
metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>(), It.IsAny<DateTime?>()))
.ReturnsAsync(new List<AppInstallSummary>());
var svc = new SlaBreachEvaluationService(
tenantConfigSvc, configRepoMock.Object, maintenanceRepo.Object, sessionRepo.Object,
@@ -862,7 +862,7 @@ public class SlaTenantStatusLifecycleTests
_sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
_metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>()))
_metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>(), It.IsAny<DateTime?>()))
.ReturnsAsync(new List<AppInstallSummary>());
}
@@ -873,7 +873,7 @@ public class SlaTenantStatusLifecycleTests
=> _maintenanceRepo.Setup(r => r.GetSessionsByDateRangeAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), tenantId)).ReturnsAsync(sessions);
public void SetAppInstalls(string tenantId, List<AppInstallSummary> apps)
=> _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId)).ReturnsAsync(apps);
=> _metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(tenantId, It.IsAny<DateTime?>())).ReturnsAsync(apps);
}
/// <summary>
@@ -24,9 +24,9 @@ public class UsageMetricsServiceWindowTests
.ReturnsAsync(new UserActivityMetrics());
metricsRepo.Setup(r => r.GetAllUserActivityMetricsAsync())
.ReturnsAsync(new UserActivityMetrics());
metricsRepo.Setup(r => r.GetAllAppInstallSummariesAsync())
metricsRepo.Setup(r => r.GetAllAppInstallSummariesAsync(It.IsAny<DateTime?>()))
.ReturnsAsync(new List<AppInstallSummary>());
metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>()))
metricsRepo.Setup(r => r.GetAppInstallSummariesByTenantAsync(It.IsAny<string>(), It.IsAny<DateTime?>()))
.ReturnsAsync(new List<AppInstallSummary>());
metricsRepo.Setup(r => r.GetPlatformStatsAsync())
.ReturnsAsync((PlatformStats?)null);
@@ -35,11 +35,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary)
=> _storage.StoreAppInstallSummaryAsync(summary);
public Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId)
=> _storage.GetAppInstallSummariesByTenantAsync(tenantId);
public Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null)
=> _storage.GetAppInstallSummariesByTenantAsync(tenantId, sinceUtc);
public Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync()
=> _storage.GetAllAppInstallSummariesAsync();
public Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null)
=> _storage.GetAllAppInstallSummariesAsync(sinceUtc);
public Task<PlatformStats?> GetPlatformStatsAsync()
=> _storage.GetPlatformStatsAsync();
@@ -1,4 +1,5 @@
using System.Collections.Specialized;
using System.Threading;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -66,17 +67,55 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// ── Data loaders ────────────────────────────────────────────────────
/// <summary>Caps concurrent session point-reads in the device-model join so a wide app doesn't fan out hundreds of simultaneous Table reads.</summary>
private const int SessionJoinConcurrency = 10;
/// <summary>
/// Loads app install summaries for the given scope.
/// Loads app install summaries for the given scope, scoped server-side to the last
/// <paramref name="days"/> days via a <c>StartedAt ge</c> filter (so a days=30 view does not
/// dematerialize the full StartedAt history). The cutoff is derived just before the in-memory
/// cutoff the Build* methods re-apply, so the server filter is never narrower than the in-memory
/// one — at worst it returns a few extra boundary rows that the in-memory filter trims.
/// - tenantId != null → tenant-scoped (per-tenant endpoint or global admin viewing one tenant)
/// - tenantId == null → all tenants (global admin aggregated view)
/// </summary>
public static async Task<List<AppInstallSummary>> LoadSummariesAsync(
IMetricsRepository repo, string? tenantId)
IMetricsRepository repo, string? tenantId, int days)
{
var sinceUtc = DateTime.UtcNow.AddDays(-days);
return string.IsNullOrEmpty(tenantId)
? await repo.GetAllAppInstallSummariesAsync()
: await repo.GetAppInstallSummariesByTenantAsync(tenantId);
? await repo.GetAllAppInstallSummariesAsync(sinceUtc)
: await repo.GetAppInstallSummariesByTenantAsync(tenantId, sinceUtc);
}
/// <summary>
/// Resolves the (TenantId, SessionId) → SessionSummary lookup used by the device-model join.
/// Point-reads run with bounded concurrency (<see cref="SessionJoinConcurrency"/>) rather than the
/// previous serial await-in-loop, which cost one sequential round-trip per distinct session.
/// Keys with an empty tenant or session id are skipped; misses (deleted session) are simply absent.
/// </summary>
private static async Task<Dictionary<string, SessionSummary>> LoadSessionLookupAsync(
ISessionRepository sessionRepo, IEnumerable<(string TenantId, string SessionId)> keys)
{
var distinct = keys
.Where(k => !string.IsNullOrEmpty(k.TenantId) && !string.IsNullOrEmpty(k.SessionId))
.Distinct()
.ToList();
using var gate = new SemaphoreSlim(SessionJoinConcurrency);
var tasks = distinct.Select(async key =>
{
await gate.WaitAsync();
try { return (key, sess: await sessionRepo.GetSessionAsync(key.TenantId, key.SessionId)); }
finally { gate.Release(); }
});
var results = await Task.WhenAll(tasks);
var lookup = new Dictionary<string, SessionSummary>();
foreach (var (key, sess) in results)
if (sess != null) lookup[$"{key.TenantId}|{key.SessionId}"] = sess;
return lookup;
}
// ── /apps/list ──────────────────────────────────────────────────────
@@ -308,14 +347,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// Device-model correlation: join via session lookup.
// Global-admin (no tenant filter) summaries may span multiple tenants, so we use
// each summary's own TenantId for the lookup instead of a single passed-in tenantId.
var distinctKeys = summaries.Select(s => (s.TenantId, s.SessionId)).Distinct().ToList();
var sessionLookup = new Dictionary<string, SessionSummary>();
foreach (var (tid, sid) in distinctKeys)
{
if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(sid)) continue;
var sess = await sessionRepo.GetSessionAsync(tid, sid);
if (sess != null) sessionLookup[$"{tid}|{sid}"] = sess;
}
var sessionLookup = await LoadSessionLookupAsync(
sessionRepo, summaries.Select(s => (s.TenantId, s.SessionId)));
var deviceModelBreakdown = summaries
.Where(s => sessionLookup.ContainsKey($"{s.TenantId}|{s.SessionId}"))
@@ -411,14 +444,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// Batch-fetch sessions for device info. Uses each summary's TenantId so global-admin
// aggregated view works correctly across tenants.
var distinctKeys = summaries.Select(s => (s.TenantId, s.SessionId)).Distinct().ToList();
var sessionLookup = new Dictionary<string, SessionSummary>();
foreach (var (tid, sid) in distinctKeys)
{
if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(sid)) continue;
var sess = await sessionRepo.GetSessionAsync(tid, sid);
if (sess != null) sessionLookup[$"{tid}|{sid}"] = sess;
}
var sessionLookup = await LoadSessionLookupAsync(
sessionRepo, summaries.Select(s => (s.TenantId, s.SessionId)));
if (!string.IsNullOrWhiteSpace(modelFilter))
{
@@ -50,7 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, _sessionRepo, decodedAppName, days);
@@ -64,7 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
limit = Math.Min(parsedLimit, MaxLimit);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
summaries, _sessionRepo, decodedAppName, days,
statusFilter, modelFilter, versionFilter, offset, limit);
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
return bad;
}
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
var body = AppsAnalyticsHelper.BuildAppsListResponse(
summaries, days, paging.PageSize, paging.Skip,
nextOffset => $"/api/apps/list?days={days}&pageSize={paging.PageSize}&skip={nextOffset}");
@@ -60,7 +60,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
decodedAppName, userEmail, scopedTenantId ?? "<all>", days);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, _sessionRepo, decodedAppName, days);
@@ -75,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
"Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})",
decodedAppName, userEmail, scopedTenantId ?? "<all>");
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
summaries, _sessionRepo, decodedAppName, days,
statusFilter, modelFilter, versionFilter, offset, limit);
@@ -57,7 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
return bad;
}
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
var tenantQs = string.IsNullOrEmpty(scopedTenantId) ? string.Empty : $"&tenantId={scopedTenantId}";
var body = AppsAnalyticsHelper.BuildAppsListResponse(
summaries, days, paging.PageSize, paging.Skip,
@@ -40,7 +40,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var cutoff = DateTime.UtcNow.AddDays(-days);
var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
// Push the window server-side so only in-window rows are deserialized. The in-memory
// Where below stays as the exact trim (the OData StartedAt filter is second-granular).
var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
// Aggregation (slowest/failing ranking + Delivery Optimization rollup) is shared
@@ -50,7 +50,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId);
var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId);
// Push the window server-side so only in-window rows are deserialized. The in-memory
// Where below stays as the exact trim (the OData StartedAt filter is second-granular).
var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
var result = ComputeGeographicMetrics(sessions, summaries, groupBy);
@@ -41,9 +41,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var cutoff = DateTime.UtcNow.AddDays(-days);
// Push the window server-side so only in-window rows are deserialized. The in-memory
// Where below stays as the exact trim (the OData StartedAt filter is second-granular).
var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter)
: await _metricsRepo.GetAllAppInstallSummariesAsync();
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter, cutoff)
: await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
// Aggregation (slowest/failing ranking + Delivery Optimization rollup) is shared
@@ -47,9 +47,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var sessions = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantIdFilter)
: await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1));
// Push the window server-side so only in-window rows are deserialized. The in-memory
// Where below stays as the exact trim (the OData StartedAt filter is second-granular).
var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter)
: await _metricsRepo.GetAllAppInstallSummariesAsync();
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter, cutoff)
: await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy);
@@ -328,16 +328,24 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Gets all app install summaries for a tenant (fleet-level metrics)
/// Gets app install summaries for a tenant (fleet-level metrics). When <paramref name="sinceUtc"/>
/// is supplied, a server-side <c>StartedAt ge</c> filter is applied so a windowed view (e.g. the
/// app dashboard's days=30) does not dematerialize the tenant's entire StartedAt history. The
/// filter still scans the partition (no secondary index on StartedAt), but only the in-window rows
/// are deserialized and returned over the wire. <paramref name="sinceUtc"/> is a server-derived
/// DateTime (never caller-supplied text), so interpolating it into the OData filter is injection-safe.
/// </summary>
public async Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId)
public async Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var filter = $"PartitionKey eq '{tenantId}'";
if (sinceUtc.HasValue)
filter += $" and StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var summaries = new List<AppInstallSummary>();
await foreach (var entity in query)
@@ -355,14 +363,20 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Gets all app install summaries across all tenants (for global admin mode)
/// Gets all app install summaries across all tenants (for global admin mode). When
/// <paramref name="sinceUtc"/> is supplied, a server-side <c>StartedAt ge</c> filter scopes the
/// (otherwise full-table) scan to the window so only in-window rows are deserialized.
/// <paramref name="sinceUtc"/> is server-derived, so interpolating it is injection-safe.
/// </summary>
public async Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync()
public async Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var query = tableClient.QueryAsync<TableEntity>();
var filter = sinceUtc.HasValue
? $"StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'"
: null;
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var summaries = new List<AppInstallSummary>();
await foreach (var entity in query)
@@ -21,8 +21,10 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- App Install Summaries ---
Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary);
Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId);
Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync();
/// <summary>Gets a tenant's app install summaries. Pass <paramref name="sinceUtc"/> to push the window's <c>StartedAt ge</c> filter server-side; null returns the full partition (legacy behaviour).</summary>
Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null);
/// <summary>Gets all tenants' app install summaries. Pass <paramref name="sinceUtc"/> to scope the (otherwise full-table) scan to the window; null returns everything (legacy behaviour).</summary>
Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null);
// --- Platform Stats ---
Task<PlatformStats?> GetPlatformStatsAsync();