feat(host): register SiteCallAuditActor + CachedCallTelemetry forwarder/bridge (#22, #23 M3)

M3 Bundle F (Task F1) wires the cached-call audit pipeline through the
composition roots:

- Central: register SiteCallAuditActor as a cluster singleton + proxy
  (mirrors AuditLogIngestActor and NotificationOutboxActor). Program.cs
  calls .AddSiteCallAudit() on the central role.
- Site: register ICachedCallTelemetryForwarder + CachedCallLifecycleBridge
  in AddAuditLog (lazy factory — Central nodes degrade to audit-only
  emission because IOperationTrackingStore is site-only).
- Site: bind CachedCallLifecycleBridge to ICachedCallLifecycleObserver so
  StoreAndForwardService picks it up via DI.
- Site: introduce IStoreAndForwardSiteContext + Host adapter to surface the
  site id to StoreAndForwardService without creating a
  StoreAndForward -> HealthMonitoring project-reference cycle.
- ScriptExecutionActor resolves ICachedCallTelemetryForwarder per script
  scope and threads it into ScriptRuntimeContext.

CachedCallTelemetryForwarder's IOperationTrackingStore dependency is now
nullable so Central DI validation succeeds with the lazy registration; the
forwarder's tracking-half emission is a no-op when the store is absent.

Tests:
- AkkaHostedServiceAuditWiringTests: Central host builds with
  AddSiteCallAudit and resolves ICachedCallTelemetryForwarder; Site
  resolves the forwarder + bridge + observer + IStoreAndForwardSiteContext.
- Full solution: 194 Host tests green, 241 SiteRuntime tests green, every
  other suite unchanged.
This commit is contained in:
Joseph Doherty
2026-05-20 15:10:47 -04:00
parent 047988e4c8
commit 6fe23a4d9b
11 changed files with 291 additions and 5 deletions

View File

@@ -102,6 +102,33 @@ public static class ServiceCollectionExtensions
// SiteAuditTelemetryActor's Props.Create call.
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
// Database.CachedWrite resolves this through DI and pushes one combined
// packet per lifecycle event; the forwarder writes the audit half
// through IAuditWriter and the operational half through the
// IOperationTrackingStore. The audit writer is always wired (the M2
// chain above); the operational tracking store is SITE-ONLY (registered
// by ScadaLink.SiteRuntime). On a Central composition root the tracking
// store has no registration, so the factory resolves it with GetService
// (returning null) — the forwarder degrades to "audit-only" emission,
// mirroring the lazy IAuditWriter chain established in M2.
services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry
// emitted from the S&F retry sweep lands on the same SQLite hot-path
// as the script-thread CachedSubmit row. Registered as a singleton
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
// can resolve it through DI (Bundle F StoreAndForward wiring change).
services.AddSingleton<CachedCallLifecycleBridge>();
services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
return services;
}

View File

@@ -48,17 +48,26 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
{
private readonly IAuditWriter _auditWriter;
private readonly IOperationTrackingStore _trackingStore;
private readonly IOperationTrackingStore? _trackingStore;
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
/// when null only the audit half of the packet is emitted, which matches
/// the M3 Bundle F composition-root contract on Central nodes: the
/// AuditLog DI surface registers the forwarder unconditionally (mirroring
/// the IAuditWriter chain) but the site-only tracking store has no central
/// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain.
/// </summary>
public CachedCallTelemetryForwarder(
IAuditWriter auditWriter,
IOperationTrackingStore trackingStore,
IOperationTrackingStore? trackingStore,
ILogger<CachedCallTelemetryForwarder> logger)
{
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_trackingStore = trackingStore ?? throw new ArgumentNullException(nameof(trackingStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trackingStore = trackingStore;
}
/// <summary>
@@ -100,6 +109,15 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
private async Task TryEmitTrackingAsync(CachedCallTelemetry telemetry, CancellationToken ct)
{
if (_trackingStore is null)
{
// No site-local tracking store wired — Central composition root or
// an integration-test host that skipped AddSiteRuntime. Emitting
// through the audit half is still meaningful; the tracking half
// is a no-op rather than an error.
return;
}
try
{
switch (telemetry.Audit.Kind)