perf(misc): cached hot-path lookups, bounded observer queue, alarm-priority stream path
WP2.6 (arch-review remediation, cross-cutting misc): - SiteExternalSystemRepository: name/ID-indexed ExternalSystemDefinitionCache replaces the fetch-all + reverse-map scan on every by-ID/method lookup; loaded once per redeploy, invalidated by DeploymentManagerActor after HandleDeployArtifacts applies external-system changes. Static JsonSerializerOptions for method-list parsing. - Inbound API: short-TTL ApiMethodCache fronts the per-request ApiMethod repository fetch; invalidated by name via the existing ScriptArtifactChangeSubscriber/ IScriptArtifactChangeBus pipeline, self-healing via TTL for changes the bus doesn't cover (e.g. Management API edits). - StoreAndForward: the cached-call audit-observer queue — the one unbounded channel left in the system — is now bounded (ObserverQueueCapacity, default 10,000) with DropOldest overflow and a dropped-notification counter. - SiteStreamManager: alarm state changes now travel a dedicated publish source/broadcast hub, isolated from the (far higher-volume) attribute path, so an attribute storm can no longer evict a pending alarm transition; the alarm hand-off queue is bounded with a drop counter surfaced on the site health report (SiteStreamAlarmDropCount via the new SiteStreamAlarmDropReporter), and publishing is skipped entirely at zero subscribers on either path. - CLI ManagementHttpClient: explicit 30s HttpClient.Timeout on the shared construction (was the 100s framework default), overridable via SCADABRIDGE_HTTP_TIMEOUT_SECONDS. Deviation: the failback-probe heartbeat item is NOT included — its only viable surface (CentralChannelProvider.cs / heartbeat consumers) lives entirely in the Communication project, explicitly off-limits to this work package this phase. Tests: SiteRuntime.Tests (550), InboundAPI.Tests (278), StoreAndForward.Tests (133), CLI.Tests (390), HealthMonitoring.Tests (97) — all green after full solution build.
This commit is contained in:
@@ -215,4 +215,99 @@ public class SiteRepositoryTests : IDisposable
|
||||
// (arch-review 08 §1.3/#23) along with the vestigial SiteNotificationRepository —
|
||||
// notification config is central-only and never lives on a site. The synthetic-ID
|
||||
// stability guarantee is still exercised by ExternalSystemRepository_SyntheticId_IsStableAcrossRestart.
|
||||
|
||||
// ── WP2.6a: ExternalSystemDefinitionCache (arch-review misc — site external-system resolution) ──
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6a: two repository instances sharing one <see cref="ExternalSystemDefinitionCache"/>
|
||||
/// (the production DI shape — one singleton cache, many scoped repository instances) must
|
||||
/// see the SAME cached snapshot: a row written after the cache was already populated by
|
||||
/// the first repository is invisible to the second until the cache is invalidated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExternalSystemRepository_SharedCache_HitServesStaleSnapshotUntilInvalidated()
|
||||
{
|
||||
var storage = NewStorage();
|
||||
await storage.InitializeAsync();
|
||||
await storage.StoreExternalSystemAsync("Alpha", "https://alpha.test", "None", null, null);
|
||||
|
||||
var cache = new ExternalSystemDefinitionCache();
|
||||
var repo1 = new SiteExternalSystemRepository(storage, cache);
|
||||
var repo2 = new SiteExternalSystemRepository(storage, cache);
|
||||
|
||||
// repo1 populates the shared cache.
|
||||
var initial = await repo1.GetAllExternalSystemsAsync();
|
||||
Assert.Single(initial);
|
||||
|
||||
// A new system is written directly to storage — repo2, sharing the same
|
||||
// cache, must NOT see it yet (cache hit serves the stale snapshot).
|
||||
await storage.StoreExternalSystemAsync("Beta", "https://beta.test", "None", null, null);
|
||||
var stillStale = await repo2.GetAllExternalSystemsAsync();
|
||||
Assert.Single(stillStale);
|
||||
|
||||
// After invalidation, the next read reloads from storage and sees both rows.
|
||||
cache.InvalidateAll();
|
||||
var fresh = await repo2.GetAllExternalSystemsAsync();
|
||||
Assert.Equal(2, fresh.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6a: the by-ID lookups (system and method) must resolve correctly out of the
|
||||
/// cached snapshot — proving the O(1) id-index build in <c>LoadSnapshotAsync</c> is
|
||||
/// wired correctly for both entity kinds, not just the by-name path already covered
|
||||
/// by ExternalSystemGateway-011.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExternalSystemRepository_SharedCache_ByIdLookups_ResolveSystemAndMethod()
|
||||
{
|
||||
var storage = NewStorage();
|
||||
await storage.InitializeAsync();
|
||||
var methodDefs = "[{\"Name\":\"getData\",\"HttpMethod\":\"GET\",\"Path\":\"/data\"}]";
|
||||
await storage.StoreExternalSystemAsync(
|
||||
"WeatherApi", "https://api.example.com", "ApiKey", null, methodDefs);
|
||||
|
||||
var cache = new ExternalSystemDefinitionCache();
|
||||
var repo = new SiteExternalSystemRepository(storage, cache);
|
||||
|
||||
var system = await repo.GetExternalSystemByNameAsync("WeatherApi");
|
||||
Assert.NotNull(system);
|
||||
|
||||
var byId = await repo.GetExternalSystemByIdAsync(system!.Id);
|
||||
Assert.NotNull(byId);
|
||||
Assert.Equal("WeatherApi", byId!.Name);
|
||||
|
||||
var methods = await repo.GetMethodsByExternalSystemIdAsync(system.Id);
|
||||
Assert.Single(methods);
|
||||
|
||||
var methodById = await repo.GetExternalSystemMethodByIdAsync(methods[0].Id);
|
||||
Assert.NotNull(methodById);
|
||||
Assert.Equal("getData", methodById!.Name);
|
||||
|
||||
Assert.Null(await repo.GetExternalSystemByIdAsync(-1));
|
||||
Assert.Null(await repo.GetExternalSystemMethodByIdAsync(-1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP2.6a: a repository constructed via the single-arg (no shared cache) constructor
|
||||
/// gets its own private cache — a second such repository over the same storage must
|
||||
/// see rows written after the first repository's cache was already populated,
|
||||
/// preserving the pre-cache "always fresh" behavior for callers that opt out of
|
||||
/// sharing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExternalSystemRepository_PrivateCache_DoesNotShareAcrossInstances()
|
||||
{
|
||||
var storage = NewStorage();
|
||||
await storage.InitializeAsync();
|
||||
await storage.StoreExternalSystemAsync("Alpha", "https://alpha.test", "None", null, null);
|
||||
|
||||
var repo1 = new SiteExternalSystemRepository(storage);
|
||||
Assert.Single(await repo1.GetAllExternalSystemsAsync());
|
||||
|
||||
await storage.StoreExternalSystemAsync("Beta", "https://beta.test", "None", null, null);
|
||||
|
||||
// A brand-new repository instance (its own private cache, unpopulated) sees both rows.
|
||||
var repo2 = new SiteExternalSystemRepository(storage);
|
||||
Assert.Equal(2, (await repo2.GetAllExternalSystemsAsync()).Count);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user