Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/SingleFlightCacheTests.cs
T
Oliver Kieselbach e85dd082d6 perf: read caches, cross-tenant fan-out and lean MCP results (2026-09 perf audit, P1)
Backend
- SingleFlightCache<T>: per instance, TTL, single-flight, failures never cached, expiry sweep, optional single slot
- Rule catalog caches in TableStorageService.Rules.cs: gather rules / rule states / IME patterns per partition (60 s), every Store/Delete invalidates, hand-outs are clones (GatherRule/ImeLogPattern/RuleState.Clone()); GatherRuleService and ImeLogPatternService seed behind a gate and read global/tenant concurrently; GetAgentConfig reads rules and patterns concurrently
- Tenant-id snapshot (60 s) shared by every cross-tenant fan-out; the paged session list no longer rescans TenantConfiguration per page
- BoundedFanOut + DrainSessionsIndexWindowAsync: one PartitionKey-anchored, RowKey-bounded, projected query per tenant; GetAllSessionStatsAsync drains with SessionStatsProjection under a 30 s single-flight cache; GetGeoWindowSessionsAsync reads the index key range; GetSessionsByDateRangeAsync fans out per tenant
- App summaries: cross-tenant windowed projected reads fan out per tenant over a union projection and share a single-slot 60 s snapshot (Installs tab pair, app detail, usage refs); days clamp on global/metrics/app
- CancellationToken on the uncached geo/date-range reads (IMaintenanceRepository)
- EventFieldProjection accepts data.<key> slices
- BackendStartupMs measured from Main (StartupClock) instead of Process.StartTime, which carried the Flex placeholder lifetime

Web
- Dashboard stats: 30 s SignalR debounce in the GA aggregate scope (every tenant's events drove a cross-tenant drain every 3 s)

MCP
- Compact JSON tool results and resources (MCP_PRETTY_JSON=true opts back in)
- DEFAULT_FIRST_PAGE_SIZE 50 for every paginated tool; the auto-exhaust budget is row-based (2000 rows) so scan coverage does not shrink
- Lean first-page projections: get_session_events without data, query_raw_events without DataJson; announced in-band (omittedFields/omittedNote) and restorable via fields; a continuation keeps the nextLink projection
- get_session_summary fetches the timeline with the triage fields plus data.<key> slices for its guards

Docs: docs/backend/read-caches-and-cross-tenant-fanout.md (submodule 11fc393).
Deploy order: backend, web, then MCP (the summary's data.<key> projection needs the new backend).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:55:18 +02:00

103 lines
3.6 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Caching;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The per-instance read cache behind the rule catalog, tenant-id and aggregate snapshots:
/// one factory run per key per TTL, shared by every concurrent caller, failures never cached.
/// </summary>
public class SingleFlightCacheTests
{
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(60);
[Fact]
public async Task ConcurrentCallers_ShareOneFactoryRun()
{
var cache = new SingleFlightCache<int>();
var invocations = 0;
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task<int> Factory()
{
Interlocked.Increment(ref invocations);
return gate.Task.ContinueWith(_ => 42);
}
var first = cache.GetOrAddAsync("k", Ttl, Factory);
var second = cache.GetOrAddAsync("k", Ttl, Factory);
gate.SetResult(true);
Assert.Equal(42, await first);
Assert.Equal(42, await second);
Assert.Equal(1, invocations);
}
[Fact]
public async Task ExpiredEntry_IsRecomputed_AndEvictedOnAccess()
{
var now = new DateTime(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc);
var cache = new SingleFlightCache<int>(() => now);
var invocations = 0;
Task<int> Factory() => Task.FromResult(++invocations);
Assert.Equal(1, await cache.GetOrAddAsync("k", Ttl, Factory));
Assert.Equal(1, await cache.GetOrAddAsync("k", Ttl, Factory));
now = now.Add(Ttl).AddSeconds(1);
Assert.Equal(2, await cache.GetOrAddAsync("k", Ttl, Factory));
Assert.Equal(1, cache.Count);
}
[Fact]
public async Task FaultedFactory_IsNotCached()
{
var cache = new SingleFlightCache<int>();
var invocations = 0;
Task<int> Factory()
{
invocations++;
return invocations == 1
? Task.FromException<int>(new InvalidOperationException("storage down"))
: Task.FromResult(7);
}
await Assert.ThrowsAsync<InvalidOperationException>(() => cache.GetOrAddAsync("k", Ttl, Factory));
Assert.Equal(0, cache.Count);
Assert.Equal(7, await cache.GetOrAddAsync("k", Ttl, Factory));
Assert.Equal(2, invocations);
}
[Fact]
public async Task Invalidate_ForcesRecompute_ClearDropsEverything()
{
var cache = new SingleFlightCache<int>();
var invocations = 0;
Task<int> Factory() => Task.FromResult(++invocations);
await cache.GetOrAddAsync("a", Ttl, Factory);
await cache.GetOrAddAsync("b", Ttl, Factory);
cache.Invalidate("a");
Assert.Equal(3, await cache.GetOrAddAsync("a", Ttl, Factory));
Assert.Equal(2, await cache.GetOrAddAsync("b", Ttl, Factory));
cache.Clear();
Assert.Equal(0, cache.Count);
Assert.Equal(4, await cache.GetOrAddAsync("b", Ttl, Factory));
}
[Fact]
public async Task SingleSlot_NewKeyEvictsTheOtherKeys()
{
var cache = new SingleFlightCache<string>(maxEntries: 1);
Assert.Equal("one", await cache.GetOrAddAsync("w1", Ttl, () => Task.FromResult("one")));
Assert.Equal("two", await cache.GetOrAddAsync("w2", Ttl, () => Task.FromResult("two")));
Assert.Equal(1, cache.Count);
// w1 is gone: its factory runs again.
Assert.Equal("one-again", await cache.GetOrAddAsync("w1", Ttl, () => Task.FromResult("one-again")));
}
}