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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-10 14:00:24 +02:00
parent 8615fb458f
commit 23cd9050ea
9 changed files with 282 additions and 34 deletions
@@ -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<TableClient>());
var storage = BuildStorage(config, sessions, index, apps: new Mock<TableClient>(), 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<TableClient>());
var storage = BuildStorage(config, MockTableClientReturning(), index, apps: new Mock<TableClient>(), 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<TableClient>());
var storage = BuildStorage(MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)), sessions, index, apps: new Mock<TableClient>(), 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<TableClient>(), 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<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Throws(new RequestFailedException(503, "PlatformStats unavailable"));
var storage = BuildStorage(
MockTableClientReturning(ConfigRow(TenantA), ConfigRow(TenantB)),
MockTableClientReturning(), index, apps: new Mock<TableClient>(), 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<TableClient>(), 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<TableClient>(), 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<TableClient>(), 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<TableClient> apps)
/// <param name="counters">PlatformStats counter rows — the enrolled-tenant marker; null = nobody has enrolled.</param>
private static TableStorageService BuildStorage(RecordingTableClient config, RecordingTableClient sessions, RecordingTableClient index, Mock<TableClient> apps, RecordingTableClient? counters = null)
{
var serviceClient = new Mock<TableServiceClient>();
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<TableStorageService>.Instance);
}
/// <summary>A per-tenant counter row (PlatformStats, RowKey "current") — the enrolled-tenant marker.</summary>
private static TableEntity CounterRow(string tenantId, long totalEnrollments)
=> new(tenantId, "current") { ["TotalEnrollments"] = totalEnrollments };
/// <summary>Counter rows marking every given tenant as enrolled.</summary>
private static RecordingTableClient Counters(params string[] tenantIds)
=> MockTableClientReturning(tenantIds.Select(t => CounterRow(t, 1)).ToArray());
/// <summary>Mock TableClient that records the filter + select of every string-filter QueryAsync call.</summary>
private sealed class RecordingTableClient : Mock<TableClient>
{
@@ -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<TableStorageService>.Instance);
}
/// <summary>A per-tenant PlatformStats counter row — the enrolled-tenant marker of the cross-tenant fan-outs.</summary>
private static TableEntity CounterRow(string tenantId) => new(tenantId, "current") { ["TotalEnrollments"] = 1L };
/// <summary>Mocks both QueryAsync overloads (string + expression filter) to return the given rows.</summary>
private static Mock<TableClient> MockTableClientReturning(params TableEntity[] rows)
{
@@ -588,14 +588,16 @@ namespace AutopilotMonitor.Functions.Services
try
{
var tenantIds = !string.IsNullOrEmpty(tenantId)
? new List<string> { tenantId! }
: (await GetTenantIdsCachedAsync()).ToList();
if (!string.IsNullOrEmpty(tenantId))
return await DrainSessionsIndexWindowAsync(new List<string> { 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)
@@ -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);
@@ -1353,18 +1353,20 @@ namespace AutopilotMonitor.Functions.Services
private async Task<SessionStats> ComputeAllSessionStatsAsync(int days, IReadOnlyCollection<string>? 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<SessionSummary>()
: 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<SessionSummary>(), 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;
@@ -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<IReadOnlyList<string>> _enrolledTenantIdCache = new();
/// <summary>
/// Configured tenants with at least one enrollment on record, in the order and spelling of
/// <see cref="GetTenantIdsCachedAsync"/> (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.
/// </summary>
internal async Task<IReadOnlyList<string>> 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<IReadOnlyList<string>> QueryEnrolledTenantIdsAsync(IReadOnlyList<string> configured, CancellationToken cancellationToken)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats);
var query = tableClient.QueryAsync<TableEntity>(
filter: $"RowKey eq '{TenantStatsRowKey}'",
select: new[] { "PartitionKey", nameof(TenantStats.TotalEnrollments) },
cancellationToken: cancellationToken);
var enrolled = new HashSet<string>(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();
}
/// <summary>
/// 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
@@ -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);
});
});
@@ -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);
}
@@ -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