From 23cd9050ea4895aa174b791121dd411b185801aa Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 10 Oct 2026 14:00:24 +0200 Subject: [PATCH] Global fan-out: session-derived cross-tenant reads run over enrolled tenants, GA follow-up pages fetch 100 (D-340) - TableStorageService.GetEnrolledTenantIdsCachedAsync: configured tenants with a PlatformStats counter row (RowKey 'current', TotalEnrollments > 0), 60 s single-flight; fail-open to the configured list with a Warning when the counter scan fails - Session list pages, cross-tenant stats drain, geo window and app-summary snapshot fan out over that subset (353 -> 127 partition queries per page live); fresh-install fallbacks key on the configured list, a configured set nobody enrolled in is an empty page; audit logs, QuerySessionsByDateRangeAsync and maintenance keep the full list - Web: cross-tenant follow-up pages fetch max(pageSize, 100) (resolveSessionsFetchPageSize); first page and own-tenant list unchanged - Tests: CrossTenantFanOutTests (enrolled subset, counter 0 / no row, fail-open, unconfigured counter row, empty enrolled set never the legacy scan, bound after the filter, geo + apps), DelegatedBoundedAggregateTests harness, sessionsPageSize.test.ts Co-Authored-By: Claude Fable 5.1 --- .../CrossTenantFanOutTests.cs | 129 +++++++++++++++++- .../DelegatedBoundedAggregateTests.cs | 7 + .../TableStorageService.Maintenance.cs | 10 +- .../Services/TableStorageService.Metrics.cs | 6 +- .../Services/TableStorageService.Sessions.cs | 34 +++-- .../Services/TableStorageService.cs | 58 ++++++++ .../hooks/__tests__/sessionsPageSize.test.ts | 28 ++++ .../app/dashboard/hooks/sessionsPageSize.ts | 22 +++ .../dashboard/hooks/useDashboardSessions.ts | 22 +-- 9 files changed, 282 insertions(+), 34 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/sessionsPageSize.test.ts diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantFanOutTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantFanOutTests.cs index 1d25a101..74f68835 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantFanOutTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantFanOutTests.cs @@ -26,6 +26,7 @@ public class CrossTenantFanOutTests { private const string TenantA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; private const string TenantB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + private const string TenantC = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; [Fact] public async Task SessionStats_DrainsIndexPerTenant_WithRowKeyBound_AndIsCached() @@ -33,7 +34,7 @@ public class CrossTenantFanOutTests var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 120), IndexRow(TenantB, "s2", SessionStatus.Failed, null)); var sessions = MockTableClientReturning(); var config = MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)); - var storage = BuildStorage(config, sessions, index, apps: new Mock()); + var storage = BuildStorage(config, sessions, index, apps: new Mock(), counters: Counters(TenantA, TenantB)); var stats = await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 7, allowedTenantIds: null); var again = await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 7, allowedTenantIds: null); @@ -56,7 +57,7 @@ public class CrossTenantFanOutTests { var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60)); var config = MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)); - var storage = BuildStorage(config, MockTableClientReturning(), index, apps: new Mock()); + var storage = BuildStorage(config, MockTableClientReturning(), index, apps: new Mock(), counters: Counters(TenantA, TenantB)); await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 30, allowedTenantIds: new[] { TenantB.ToUpperInvariant() }); @@ -69,7 +70,7 @@ public class CrossTenantFanOutTests { var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60)); var sessions = MockTableClientReturning(); - var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), sessions, index, apps: new Mock()); + var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), sessions, index, apps: new Mock(), counters: Counters(TenantA, TenantB)); var start = DateTime.UtcNow.AddDays(-30); var end = DateTime.UtcNow.AddDays(1); @@ -99,7 +100,7 @@ public class CrossTenantFanOutTests public async Task AppSummaries_CrossTenant_OnePartitionQueryPerTenant_SharedAcrossConcurrentCallers() { var apps = MockTableClientReturning(AppRow(TenantA, "s1", "Teams"), AppRow(TenantB, "s2", "Office")); - var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), MockTableClientReturning(), MockTableClientReturning(), apps); + var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), MockTableClientReturning(), MockTableClientReturning(), apps, counters: Counters(TenantA, TenantB)); var since = DateTime.UtcNow.AddDays(-30); // The Installs tab fires these two at once; the usage compute adds the refs projection. @@ -132,6 +133,114 @@ public class CrossTenantFanOutTests Assert.All(apps.Selects, s => Assert.Equal(TableStorageService.AppMetricsProjection, s)); } + // ── enrolled-tenant snapshot: the fan-out width is the set of tenants with an enrollment on record ── + + [Fact] + public async Task SessionList_FansOutOnlyOverEnrolledTenants_AndScansCountersOncePerTtl() + { + // Config knows three tenants: A enrolled, B's counter row stands at 0, C has no counter row at all. + var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60)); + var counters = MockTableClientReturning(CounterRow(TenantA, 5), CounterRow(TenantB, 0)); + var storage = BuildStorage( + MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB), ConfigRow(TenantC)), + MockTableClientReturning(), index, apps: new Mock(), counters: counters); + + var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null); + await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null); + + Assert.Single(page.Items); + // Two pages, one partition query each — only the enrolled tenant is ever asked. + Assert.Equal(2, index.Filters.Count); + Assert.All(index.Filters, f => Assert.StartsWith($"PartitionKey eq '{TenantA}'", f)); + // The marker is one projected property-filter read per TTL, shared by both pages. + Assert.Equal("RowKey eq 'current'", Assert.Single(counters.Filters)); + Assert.Equal(new[] { "PartitionKey", "TotalEnrollments" }, Assert.Single(counters.Selects)); + } + + [Fact] + public async Task SessionList_CounterScanFailure_FailsOpenToEveryConfiguredTenant() + { + var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60)); + var counters = new RecordingTableClient(); + counters.Setup(c => c.QueryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Throws(new RequestFailedException(503, "PlatformStats unavailable")); + var storage = BuildStorage( + MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), + MockTableClientReturning(), index, apps: new Mock(), counters: counters); + + var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null); + + // A failed marker read must never narrow the fan-out: every configured tenant is queried. + Assert.Single(page.Items); + Assert.Equal(2, index.Filters.Count); + } + + [Fact] + public async Task SessionList_CounterRowOfAnUnconfiguredTenant_IsNeverQueried() + { + // An offboarded tenant may keep its counter row and even index rows; without a config row it is out. + var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60), IndexRow(TenantB, "s2", SessionStatus.Failed, null)); + var storage = BuildStorage( + MockTableClientReturning(ConfigRow(TenantA)), + MockTableClientReturning(), index, apps: new Mock(), counters: Counters(TenantA, TenantB)); + + var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null); + + Assert.Single(page.Items); + Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(index.Filters)); + } + + [Fact] + public async Task SessionList_ConfiguredButNobodyEnrolled_IsAnEmptyPage_NeverTheLegacyScan() + { + // The primary-table scan is the EMPTY-CONFIG safety net only; "configured, nobody enrolled" is simply empty. + var sessions = MockTableClientReturning(new TableEntity(TenantA, "leak")); + var index = MockTableClientReturning(); + var storage = BuildStorage( + MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), + sessions, index, apps: new Mock(), counters: MockTableClientReturning()); + + var page = await storage.GetAllSessionsPageAsync(tenantIdFilter: null, days: null, pageSize: 10, continuation: null); + var stats = await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 7, allowedTenantIds: null); + + Assert.Empty(page.Items); + Assert.Null(page.NextRawToken); + Assert.Equal(0, stats.TotalLastNDays); + AssertNeverQueried(sessions); + AssertNeverQueried(index); + } + + [Fact] + public async Task SessionStats_BoundedCaller_IsCutToEnrolledTenants_AfterTheBound() + { + var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60), IndexRow(TenantB, "s2", SessionStatus.Succeeded, 60)); + var storage = BuildStorage( + MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), + MockTableClientReturning(), index, apps: new Mock(), counters: Counters(TenantA)); + + // The delegated caller manages both tenants; only A ever enrolled. + await storage.GetAllSessionStatsAsync(tenantIdFilter: null, days: 30, allowedTenantIds: new[] { TenantA, TenantB }); + + Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(index.Filters)); + } + + [Fact] + public async Task GeoWindowAndAppSummaries_CrossTenant_SkipTenantsWithoutEnrollment() + { + var index = MockTableClientReturning(IndexRow(TenantA, "s1", SessionStatus.Succeeded, 60)); + var apps = MockTableClientReturning(AppRow(TenantA, "s1", "Teams")); + var storage = BuildStorage( + MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), + MockTableClientReturning(), index, apps, counters: Counters(TenantA)); + + await storage.GetGeoWindowSessionsAsync(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow.AddDays(1), tenantId: null); + await storage.GetAppMetricsSummariesAsync(DateTime.UtcNow.AddDays(-30), tenantId: null); + + Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(index.Filters)); + Assert.StartsWith($"PartitionKey eq '{TenantA}'", Assert.Single(apps.Filters)); + } + [Fact] public void GeoProjection_IsFullyMirroredOnSessionsIndex() { @@ -161,16 +270,26 @@ public class CrossTenantFanOutTests private static TableEntity AppRow(string tenantId, string sessionId, string appName) => new(tenantId, $"{sessionId}_{appName}") { ["SessionId"] = sessionId, ["AppName"] = appName, ["StartedAt"] = DateTime.UtcNow.AddHours(-2) }; - private static TableStorageService BuildStorage(RecordingTableClient config, RecordingTableClient sessions, RecordingTableClient index, Mock apps) + /// PlatformStats counter rows — the enrolled-tenant marker; null = nobody has enrolled. + private static TableStorageService BuildStorage(RecordingTableClient config, RecordingTableClient sessions, RecordingTableClient index, Mock apps, RecordingTableClient? counters = null) { var serviceClient = new Mock(); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.TenantConfiguration)).Returns(config.Object); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(index.Object); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.AppInstallSummaries)).Returns(apps.Object); + serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns((counters ?? MockTableClientReturning()).Object); return new TableStorageService(serviceClient.Object, NullLogger.Instance); } + /// A per-tenant counter row (PlatformStats, RowKey "current") — the enrolled-tenant marker. + private static TableEntity CounterRow(string tenantId, long totalEnrollments) + => new(tenantId, "current") { ["TotalEnrollments"] = totalEnrollments }; + + /// Counter rows marking every given tenant as enrolled. + private static RecordingTableClient Counters(params string[] tenantIds) + => MockTableClientReturning(tenantIds.Select(t => CounterRow(t, 1)).ToArray()); + /// Mock TableClient that records the filter + select of every string-filter QueryAsync call. private sealed class RecordingTableClient : Mock { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedBoundedAggregateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedBoundedAggregateTests.cs index eda24528..ad0cde56 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedBoundedAggregateTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedBoundedAggregateTests.cs @@ -114,9 +114,16 @@ public class DelegatedBoundedAggregateTests serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.TenantConfiguration)).Returns(config.Object); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(index.Object); + // Every tenant of these tests counts as enrolled: the bounded guard under test must not lean on the + // enrolled-tenant snapshot narrowing the set (CrossTenantFanOutTests covers that snapshot). + serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns( + MockTableClientReturning(CounterRow(ConfigTenant), CounterRow(ManagedTenant), CounterRow(UnmanagedTenant)).Object); return new TableStorageService(serviceClient.Object, NullLogger.Instance); } + /// A per-tenant PlatformStats counter row — the enrolled-tenant marker of the cross-tenant fan-outs. + private static TableEntity CounterRow(string tenantId) => new(tenantId, "current") { ["TotalEnrollments"] = 1L }; + /// Mocks both QueryAsync overloads (string + expression filter) to return the given rows. private static Mock MockTableClientReturning(params TableEntity[] rows) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index f78c31c3..8e728f3a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -588,14 +588,16 @@ namespace AutopilotMonitor.Functions.Services try { - var tenantIds = !string.IsNullOrEmpty(tenantId) - ? new List { tenantId! } - : (await GetTenantIdsCachedAsync()).ToList(); + if (!string.IsNullOrEmpty(tenantId)) + return await DrainSessionsIndexWindowAsync(new List { tenantId! }, startDate, endDate, GeoMetricsSessionProjection, cancellationToken); // Empty config table (fresh install): keep the legacy Sessions scan as the safety net. - if (tenantIds.Count == 0) + if ((await GetTenantIdsCachedAsync()).Count == 0) return await QuerySessionsByDateRangeAsync(startDate, endDate, tenantId, GeoMetricsSessionProjection, cancellationToken); + // Cross-tenant: only tenants with an enrollment on record hold index rows + // (ENROLLED TENANT SNAPSHOT in TableStorageService.cs). + var tenantIds = await GetEnrolledTenantIdsCachedAsync(); return await DrainSessionsIndexWindowAsync(tenantIds, startDate, endDate, GeoMetricsSessionProjection, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index d3b491a1..b5c6bb3e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -708,13 +708,15 @@ namespace AutopilotMonitor.Functions.Services var windowFilter = AppSummaryWindowFilter(sinceUtc); var select = CrossTenantAppScanProjection.Value; - var tenantIds = await GetTenantIdsCachedAsync(); - if (tenantIds.Count == 0) + if ((await GetTenantIdsCachedAsync()).Count == 0) { // Empty config table (fresh install): the legacy cross-partition scan is the safety net. return await ScanAppInstallSummariesAsync(tableClient, windowFilter, select, cancellationToken); } + // Only tenants with an enrollment on record hold app summaries (ENROLLED TENANT SNAPSHOT + // in TableStorageService.cs); every other configured tenant would be one empty partition query. + var tenantIds = await GetEnrolledTenantIdsCachedAsync(); return await BoundedFanOut.RunAsync(tenantIds, BoundedFanOut.CrossTenantConcurrency, (tenantId, ct) => ScanAppInstallSummariesAsync(tableClient, $"PartitionKey eq '{tenantId}' and {windowFilter}", select, ct), cancellationToken); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index d52d93ef..7d187fea 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -1353,18 +1353,20 @@ namespace AutopilotMonitor.Functions.Services private async Task ComputeAllSessionStatsAsync(int days, IReadOnlyCollection? allowedTenantIds, CancellationToken cancellationToken) { - var tenantIds = ApplyTenantBound(await GetTenantIdsCachedAsync(), allowedTenantIds); - if (tenantIds.Count == 0) + // Keyed on the CONFIGURED list: on an empty config table (fresh install) an UNBOUNDED caller + // keeps the legacy primary-table drain as its safety net, while a BOUNDED (delegated/MSP) + // caller gets an empty tally — never the all-tenants scan. A non-empty config whose tenants + // have no enrollment yet is an empty tally as well: the drain below runs over the enrolled + // subset (ENROLLED TENANT SNAPSHOT in TableStorageService.cs), cut by the bound. + if ((await GetTenantIdsCachedAsync()).Count == 0) { - // A BOUNDED (delegated/MSP) caller with no resolvable tenant gets an empty tally — never - // the all-tenants scan. An UNBOUNDED caller on an empty config table (fresh install) - // keeps the legacy primary-table drain as its safety net. var sessions = allowedTenantIds != null ? new List() : await GetAllSessionsAsync(tenantIdFilter: null, days, allowedTenantIds: null); return AggregateSessionStats(sessions, days); } + var tenantIds = ApplyTenantBound(await GetEnrolledTenantIdsCachedAsync(), allowedTenantIds); var rows = await DrainSessionsIndexWindowAsync( tenantIds, startUtc: DateTime.UtcNow.AddDays(-days), endUtc: null, SessionStatsProjection, cancellationToken); return AggregateSessionStats(rows, days); @@ -1537,19 +1539,23 @@ namespace AutopilotMonitor.Functions.Services { var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex); - // Step 1: Tenant IDs from the per-instance TenantConfiguration snapshot (one row per - // tenant; cached — this used to be re-scanned for every 1000-row page of a drain). + // Step 1: Tenant IDs from the per-instance snapshots (cached — this used to be a + // TenantConfiguration re-scan for every 1000-row page of a drain). The fresh-install + // fallback keys on the CONFIGURED list; the fan-out itself runs over the tenants with an + // enrollment on record, the only ones that can hold index rows (ENROLLED TENANT SNAPSHOT + // in TableStorageService.cs) — a configured set nobody enrolled in is an empty page. // Delegated ("MSP") bound: restrict the cross-tenant fan-out to the caller's managed subset. // This is the SAME per-tenant loop the Global Admin aggregate uses — only the tenant set // shrinks, so the merge/cursor/pagination below stay identical. - var tenantIds = ApplyTenantBound(await GetTenantIdsCachedAsync(), allowedTenantIds); + var configuredTenantIds = await GetTenantIdsCachedAsync(); - if (tenantIds.Count == 0 && string.IsNullOrEmpty(cursor)) + if (configuredTenantIds.Count == 0 && string.IsNullOrEmpty(cursor)) { - // A BOUNDED (delegated/MSP) request must NEVER fall back to the unbounded primary-table - // scan: an empty allowed set (managed tenant has no config row, config momentarily empty, - // or a casing mismatch) means "none of YOUR tenants" → empty page, not ALL tenants. The - // primary-table fallback is only the GA all-tenants safety net for a fresh/empty config. + // Empty config table. A BOUNDED (delegated/MSP) request must NEVER fall back to the + // unbounded primary-table scan — "none of YOUR tenants" is an empty page, not ALL + // tenants (a managed tenant without a config row, or a casing mismatch, lands in the + // empty fan-out below for the same reason). The primary-table fallback is only the + // GA all-tenants safety net for a fresh/empty config. if (allowedTenantIds != null) return (new List(), false, null); @@ -1557,6 +1563,8 @@ namespace AutopilotMonitor.Functions.Services return (fallback.Sessions, fallback.HasMore, NextCursor: null); } + var tenantIds = ApplyTenantBound(await GetEnrolledTenantIdsCachedAsync(), allowedTenantIds); + // Step 2: Per-tenant fan-out using RowKey ordering (inverted ticks → newest first). // Parse cursor into invertedTicks prefix for cross-tenant RowKey filtering. string? cursorRowKeyPrefix = null; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index f8dc36e7..f6939ace 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -70,6 +70,64 @@ namespace AutopilotMonitor.Functions.Services return tenantIds.ToList(); } + // ===== ENROLLED TENANT SNAPSHOT (session-derived fan-outs) ===== + // + // Of the configured tenants only those that ever registered a session can hold rows in + // SessionsIndex, Sessions or AppInstallSummaries (live 2026-10-10: 127 of 353). The + // session-derived cross-tenant reads (list pages, stats, geo window, app summaries) fan + // out over this subset; the marker is the per-tenant PlatformStats counter row (D-254): + // created by the first fresh registration, raised to the live count by every 2-h + // maintenance run, so a lost first increment heals within one run. Audit logs and the + // maintenance paths keep the full list — audit rows exist for tenants without sessions, + // and maintenance prefers correctness over the saved queries. Fail-open: a failed counter + // scan yields the FULL configured list, never a narrower one — a missing marker can only + // make a tenant invisible, so the read side must not add a second way to lose it. + private readonly SingleFlightCache> _enrolledTenantIdCache = new(); + + /// + /// Configured tenants with at least one enrollment on record, in the order and spelling of + /// (see the ENROLLED TENANT SNAPSHOT note). Empty when + /// the config table is empty — callers key their fresh-install fallback on the configured + /// list, never on this one — and the full configured list when the counter scan fails. + /// + internal async Task> GetEnrolledTenantIdsCachedAsync() + { + var configured = await GetTenantIdsCachedAsync().ConfigureAwait(false); + if (configured.Count == 0) + return configured; + + try + { + return await _enrolledTenantIdCache.GetOrAddAsync(TenantIdCacheKey, TenantIdCacheTtl, + ct => QueryEnrolledTenantIdsAsync(configured, ct)).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Enrolled-tenant snapshot unavailable; cross-tenant reads fan out over all {TenantCount} configured tenants", + configured.Count); + return configured; + } + } + + private async Task> QueryEnrolledTenantIdsAsync(IReadOnlyList configured, CancellationToken cancellationToken) + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats); + var query = tableClient.QueryAsync( + filter: $"RowKey eq '{TenantStatsRowKey}'", + select: new[] { "PartitionKey", nameof(TenantStats.TotalEnrollments) }, + cancellationToken: cancellationToken); + + var enrolled = new HashSet(StringComparer.OrdinalIgnoreCase); + await foreach (var row in query) + { + if ((row.GetInt64(nameof(TenantStats.TotalEnrollments)) ?? 0) > 0) + enrolled.Add(row.PartitionKey); + } + + return configured.Where(enrolled.Contains).ToList(); + } + /// /// Applies the delegated ("MSP") bound to a tenant-id set. Null bound = unchanged. Comparison /// is case-insensitive because AllowedTenantIds is lowercased while the config PartitionKey diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/sessionsPageSize.test.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/sessionsPageSize.test.ts new file mode 100644 index 00000000..dc553e01 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/__tests__/sessionsPageSize.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { + CROSS_TENANT_FOLLOWUP_PAGE_SIZE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + resolveSessionsFetchPageSize, +} from "../sessionsPageSize"; + +describe("resolveSessionsFetchPageSize", () => { + it("keeps the first paint small in every mode", () => { + expect(resolveSessionsFetchPageSize(DEFAULT_PAGE_SIZE, { continuation: false, crossTenant: true })).toBe(DEFAULT_PAGE_SIZE); + expect(resolveSessionsFetchPageSize(DEFAULT_PAGE_SIZE, { continuation: false, crossTenant: false })).toBe(DEFAULT_PAGE_SIZE); + }); + + it("batches cross-tenant follow-up pages — every such page costs one backend query per enrolled tenant", () => { + expect(resolveSessionsFetchPageSize(DEFAULT_PAGE_SIZE, { continuation: true, crossTenant: true })).toBe(CROSS_TENANT_FOLLOWUP_PAGE_SIZE); + }); + + it("leaves the own-tenant cadence alone (one page there is one query)", () => { + expect(resolveSessionsFetchPageSize(DEFAULT_PAGE_SIZE, { continuation: true, crossTenant: false })).toBe(DEFAULT_PAGE_SIZE); + }); + + it("never shrinks a larger per-page choice and stays within the backend cap", () => { + expect(resolveSessionsFetchPageSize(250, { continuation: true, crossTenant: true })).toBe(250); + expect(resolveSessionsFetchPageSize(MAX_PAGE_SIZE, { continuation: true, crossTenant: true })).toBe(MAX_PAGE_SIZE); + expect(CROSS_TENANT_FOLLOWUP_PAGE_SIZE).toBeLessThanOrEqual(MAX_PAGE_SIZE); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/sessionsPageSize.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/sessionsPageSize.ts index c668d8a1..c85db61d 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/sessionsPageSize.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/sessionsPageSize.ts @@ -25,3 +25,25 @@ export function getInitialSessionsPageSize(): number { const value = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PAGE_SIZE; return Math.min(value, MAX_PAGE_SIZE); } + +/** + * Follow-up pages (continuation set) of the CROSS-TENANT list fetch at least this many rows. + * Every cross-tenant page costs the backend one partition query per enrolled tenant whatever + * the page size, and a sweep (load-all, auto-load-more) walks hundreds of pages at 10 — so + * follow-up pages in that mode batch 100 (audit 2026-10-10: a full sweep drops from 1,679 to + * 168 pages). The FIRST page keeps the small first-paint size, and the own-tenant list keeps + * its cadence: one page there is one query. The continuation token does not bind the page + * size (it is a RowKey watermark), so the size may change between pages. + */ +export const CROSS_TENANT_FOLLOWUP_PAGE_SIZE = 100; + +export function resolveSessionsFetchPageSize( + initial: number, + opts: { continuation: boolean; crossTenant: boolean }, +): number { + return opts.continuation && opts.crossTenant ? Math.max(initial, CROSS_TENANT_FOLLOWUP_PAGE_SIZE) : initial; +} + +export function getSessionsFetchPageSize(opts: { continuation: boolean; crossTenant: boolean }): number { + return resolveSessionsFetchPageSize(getInitialSessionsPageSize(), opts); +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index 69054db9..28ccd1da 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -15,7 +15,7 @@ import type { Session } from "../types"; import type { SignalRMessageName } from "@/lib/signalrMessages"; import type { BlockedDeviceListResponse, SearchSessionsResponse, SessionListResponse } from "@/utils/wire-types.generated"; import { ApiError, fetchJson, nullOnApiError } from "@/lib/apiClient"; -import { getInitialSessionsPageSize } from "./sessionsPageSize"; +import { getSessionsFetchPageSize } from "./sessionsPageSize"; // Server-side search sweep bounds: the backend scans up to 10 Azure pages per request // (free-text backfill) and returns only matches; the client follows nextLink until it has @@ -222,10 +222,11 @@ export function useDashboardSessions({ }, [getAccessToken, setBlockedDevicesSet, adminModeRef, globalAdminModeRef, tenantIdRef]); // Internal batch fetcher — returns data without touching state so callers can - // decide how to apply it (single append vs. progressive loop). For first-page - // calls we use getInitialSessionsPageSize() (Pattern B2: typically 10; the auth - // bootstrap seeds the same URL, see lib/dashboardSeed.ts). For load-more - // we keep the same pageSize so the user-visible cadence is uniform. + // decide how to apply it (single append vs. progressive loop). First-page calls use + // getInitialSessionsPageSize() (Pattern B2: typically 10; the auth bootstrap seeds the + // same URL, see lib/dashboardSeed.ts). Own-tenant load-more keeps that size so the + // user-visible cadence is uniform; cross-tenant follow-up pages batch larger, because + // every such page costs one backend query per enrolled tenant (see sessionsPageSize.ts). const fetchSessionsBatch = useCallback(async ( loadMoreContinuation?: string, globalTenantIdOverride?: string, @@ -240,16 +241,17 @@ export function useDashboardSessions({ // bounds this too; this just keeps the client from ever asking for an unmanaged tenant. const effectiveTenantFilter = boundTenantToDelegatedScope( asGuidOrUndefined(rawFilter), isDelegatedScopeRef.current, delegatedTenantIdsRef.current); - const pageSize = getInitialSessionsPageSize(); - const opts = loadMoreContinuation - ? { pageSize, continuation: loadMoreContinuation } - : { pageSize }; // A delegated ("MSP") caller filtering on their OWN home tenant routes to the JWT-bound member // list — their access there is member-based, and the /global/ path is bounded to the managed // set (would return empty). Mirrors the scope hooks' routeGlobal carve-out. const homeSelected = isDelegatedScopeRef.current && isHomeTenantTarget(asGuidOrUndefined(rawFilter), tenantIdRef.current ?? undefined); - const endpoint = globalAdminModeRef.current && !homeSelected + const crossTenant = globalAdminModeRef.current && !homeSelected; + const pageSize = getSessionsFetchPageSize({ continuation: !!loadMoreContinuation, crossTenant }); + const opts = loadMoreContinuation + ? { pageSize, continuation: loadMoreContinuation } + : { pageSize }; + const endpoint = crossTenant ? api.globalSessions.list(effectiveTenantFilter, undefined, opts) // Own-tenant list: the backend takes the tenant from the JWT and ignores a tenantId query // (GetSessionsFunction, ParseQuery acceptFilterTenantId:false), so none is sent — and the