From 12dabbeab9fdcc571c2c2e588bd1da0935d5bc7a Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 25 Jun 2026 11:07:29 +0200 Subject: [PATCH] fix(backend): bound QuickSearch typeahead scan (recent window + row cap) QuickSearchSessionsAsync walked the full SessionsIndex partition (or the whole table for global scope) on every typeahead request, since exact-over-fuzzy ordering forbids an early break on fuzzy-only/no-match queries. Cost grew with the index. Bound the scan without a new index: - recent-window RowKey bound (RowKey lt cutoff, 90d) via the existing ComputeCutoffRowKeyPrefix; applies to tenant and global scope - hard scanned-row cap (5000) as backstop for the no-early-break case Match/ordering logic unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/TableStorageService.AgentApi.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index 7ca220d0..f0803ac6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -307,6 +307,12 @@ namespace AutopilotMonitor.Functions.Services // ===== SEARCH METHODS ===== + /// Typeahead only inspects sessions started within this many days (recent-window scan bound). + private const int QuickSearchWindowDays = 90; + + /// Hard upper bound on index rows a single typeahead request may scan (cost backstop). + private const int QuickSearchMaxScannedRows = 5000; + /// /// Lightweight typeahead search: matches SessionId, SerialNumber, or DeviceName. /// Supports exact contains match (priority) and fuzzy match (edit distance <= 2). @@ -319,13 +325,24 @@ namespace AutopilotMonitor.Functions.Services var fuzzyResults = new List(); var q = query.Trim(); - // Tenant-scoped or cross-tenant scan - string? filter = !string.IsNullOrEmpty(tenantId) - ? $"PartitionKey eq '{tenantId}'" - : null; + // Bound the scan so a single typeahead request cannot walk an ever-growing index: + // 1. Recent-window bound — RowKey is the inverted-tick prefix (newest-first), so + // `RowKey lt '{cutoff}'` keeps the scan to sessions started within the window. + // Typeahead targets recent enrollments; older sessions are out of scope. + // 2. Hard scanned-row cap — backstop for the fuzzy-only / no-match case, which + // otherwise reads every remaining row in the partition (or the whole table for + // global scope) because exact-over-fuzzy ordering forbids an early break. + var windowBound = $"RowKey lt '{ComputeCutoffRowKeyPrefix(QuickSearchWindowDays)}'"; + string filter = !string.IsNullOrEmpty(tenantId) + ? $"PartitionKey eq '{tenantId}' and {windowBound}" + : windowBound; + var scannedRows = 0; await foreach (var entity in indexTableClient.QueryAsync(filter: filter)) { + if (++scannedRows > QuickSearchMaxScannedRows) + break; + var sessionId = entity.GetString("SessionId") ?? ExtractSessionIdFromIndexRowKey(entity.RowKey); var serial = entity.GetString("SerialNumber") ?? string.Empty; var deviceName = entity.GetString("DeviceName") ?? string.Empty;