From bcd307d8a494585e03348661e66ccc5e1f523ce2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 30 Jun 2026 16:15:47 +0200 Subject: [PATCH] perf(backend): project index scans + bound AppInstall reads to window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two classes of read-efficiency fixes on hot/aggregation paths. No response shape or value changes — DTOs untouched, all 2541 backend tests pass. P2 — select projections on full-mirror SessionsIndex scans (bandwidth only): - GetMetricsSummaryAsync: cross-partition status tally now projects [PartitionKey, Status] instead of the ~40-column mirror row (incl. the large FailureSnapshotJson) -> ~10-20x less transfer. - QuickSearchSessionsAsync: typeahead scan projects only the matched fields (+ RowKey for the SessionId fallback) instead of the full row, for up to QuickSearchMaxScannedRows rows per request. P3 — push the existing sinceUtc window floor server-side at 8 AppInstall call sites across 5 files (previously each dematerialized the whole table / partition and filtered to the window in memory): - UsageMetricsService (global + tenant): now.AddDays(-days) - MaintenanceService.Aggregation (global + tenant, per aggregated day): targetDate - SlaBreachEvaluationService (current ISO week): now.AddDays(-8) margin - SlaMetricsService: startDate (matches the in-memory lower bound exactly) - Get(Global)GeographicLocationSessionsFunction: cutoff Each floor equals the session-window floor the apps are joined against (app.StartedAt >= session.StartedAt >= floor), so results are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Metrics/GetGeographicLocationSessionsFunction.cs | 3 ++- .../GetGlobalGeographicLocationSessionsFunction.cs | 5 +++-- .../Services/MaintenanceService.Aggregation.cs | 7 +++++-- .../Services/SlaBreachEvaluationService.cs | 5 ++++- .../Services/SlaMetricsService.cs | 4 +++- .../Services/TableStorageService.AgentApi.cs | 12 ++++++++++-- .../Services/UsageMetricsService.cs | 9 +++++++-- 7 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs index 792957e7..f66b2fd9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs index 00bc2d45..5b35e28d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 083423da..16c930da 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -139,10 +139,13 @@ namespace AutopilotMonitor.Functions.Services // App metrics: count apps per session from AppInstallSummaries table var sessionIdSet = new HashSet(sessions.Select(s => s.SessionId)); List 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(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 9bee75df..8b1068de 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs index bcb1a467..003171d8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs @@ -125,7 +125,9 @@ namespace AutopilotMonitor.Functions.Services List? 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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index f0803ac6..998564d5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -338,7 +338,11 @@ namespace AutopilotMonitor.Functions.Services : windowBound; var scannedRows = 0; - await foreach (var entity in indexTableClient.QueryAsync(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( + 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(StringComparer.OrdinalIgnoreCase); - await foreach (var entity in indexTableClient.QueryAsync(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( + filter: oDataFilter, select: new[] { "PartitionKey", "Status" })) { var pk = entity.PartitionKey; var statusStr = entity.GetString("Status") ?? "InProgress"; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs index 26efcb80..a0b3438c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs @@ -217,7 +217,11 @@ namespace AutopilotMonitor.Functions.Services }; // App & Script Metrics - var allAppSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(); + // Bound the AppInstall scan to the same window as the sessions above — relevantApps is + // filtered to sessionIdSet anyway, and an app installs during its session so its StartedAt + // is >= the session's. Without sinceUtc this dematerialized the entire AppInstallSummaries + // table on every cache miss just to discard everything outside the window. + var allAppSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(DateTime.UtcNow.AddDays(-days)); var sessionIdSet = new HashSet(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(); @@ -366,7 +370,8 @@ namespace AutopilotMonitor.Functions.Services }; // App & Script Metrics - var tenantAppSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId); + // Bound to the window (see ComputeUsageMetricsInternalAsync) — joined to tenantSessionIdSet below. + var tenantAppSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, DateTime.UtcNow.AddDays(-days)); var tenantSessionIdSet = new HashSet(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();