mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user