mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
e85dd082d6
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>
103 lines
3.6 KiB
C#
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")));
|
|
}
|
|
}
|