feat(host): register Audit Log #23 singletons with dedicated dispatcher (#23)

Wires Bundle E of the M2 site-sync pipeline:

- AddAuditLog extended to register the site writer chain (SqliteAuditWriter
  singleton + ISiteAuditQueue forward + RingBufferFallback + FallbackAuditWriter
  composing them) and the telemetry collaborators (SiteAuditTelemetryOptions,
  SqliteAuditWriterOptions, IAuditWriteFailureCounter NoOp default,
  ISiteStreamAuditClient NoOp default).
- AkkaHostedService central role: AuditLogIngestActor as ClusterSingletonManager
  (singleton name 'audit-log-ingest') + ClusterSingletonProxy, mirroring the
  Notification Outbox pattern. Proxy is offered to SiteStreamGrpcServer if it
  resolves (Site path only today; M6 reconciliation will host gRPC on central).
- AkkaHostedService site role: SiteAuditTelemetryActor (per-site, NOT a
  singleton because each site is its own cluster), bound to a dedicated
  audit-telemetry-dispatcher (ForkJoinDispatcher, 2 dedicated threads).
- Program.cs + SiteServiceRegistration.Configure call AddAuditLog on both roles.
- AuditLogIngestActor gains a second constructor that takes IServiceProvider so
  the cluster singleton can create a fresh scope per message — IAuditLogRepository
  is a scoped EF Core service and cannot be pre-resolved from the root. The
  IAuditLogRepository constructor remains for Bundle D's MSSQL-fixture tests.

NoOp ISiteStreamAuditClient is deliberate: no site→central gRPC channel exists
in M2 (sites talk to central via Akka ClusterClient; gRPC SiteStreamService is
hosted on sites for central→site streaming). M6 reconciliation introduces the
real gRPC site→central client + central-hosted gRPC server. Bundle H's
integration test substitutes a stub client directly via the actor's Props.

Tests:
- tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs — 11 tests (was 3): writer
  singleton, IAuditWriter as FallbackAuditWriter, ISiteAuditQueue same-instance
  as SqliteAuditWriter, options bind round-trip, NoOp default assertions.
- tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs (new) — 13
  tests: BuildHocon emits audit-telemetry-dispatcher block with the expected
  type/throughput/thread-count; Central composition root resolves the writer
  chain + options; Site composition root resolves the writer chain + options +
  NoOp client.

Verified: dotnet build clean, 23 test suites green (Host 194 + AuditLog 54).
This commit is contained in:
Joseph Doherty
2026-05-20 13:04:05 -04:00
parent 87cae88f92
commit 9bf1497f03
10 changed files with 765 additions and 42 deletions

View File

@@ -1,28 +1,42 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog.Tests;
/// <summary>
/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies
/// <c>AddAuditLog</c> registers <see cref="AuditLogOptions"/> against the
/// <c>AuditLog</c> configuration section. Bundle E ships only the scaffold;
/// the validator + full options surface land in Task 9.
/// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped
/// the options-only scaffold; M2 extends it with the site writer chain
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
/// <see cref="IAuditWriteFailureCounter"/>).
/// </summary>
public class AddAuditLogTests
{
[Fact]
public void AddAuditLog_RegistersAuditLogOptions()
private static ServiceProvider BuildProvider(IDictionary<string, string?>? settings = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.AddInMemoryCollection(settings ?? new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config);
var provider = services.BuildServiceProvider();
return services.BuildServiceProvider();
}
[Fact]
public void AddAuditLog_RegistersAuditLogOptions()
{
using var provider = BuildProvider();
var opts = provider.GetService<IOptions<AuditLogOptions>>();
@@ -47,4 +61,130 @@ public class AddAuditLogTests
Assert.Throws<ArgumentNullException>(
() => services.AddAuditLog(null!));
}
// -- Bundle E (M2 Task E1) ---------------------------------------------
[Fact]
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
// In-memory database keeps the writer's owned connection portable
// across tests; the per-instance Cache=Shared in the writer's
// default connection string ensures no on-disk file is touched.
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<SqliteAuditWriter>();
Assert.NotNull(writer);
// Singleton — same instance on a second resolve.
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
}
[Fact]
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var writer = provider.GetService<IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<FallbackAuditWriter>(writer);
}
[Fact]
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
{
// The telemetry actor reads from ISiteAuditQueue while scripts write
// through IAuditWriter → SqliteAuditWriter. Both surfaces MUST resolve
// to the same instance or pending rows will never be visible.
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var queue = provider.GetService<ISiteAuditQueue>();
var writer = provider.GetService<SqliteAuditWriter>();
Assert.NotNull(queue);
Assert.NotNull(writer);
Assert.Same(writer, queue);
}
[Fact]
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var ring = provider.GetService<RingBufferFallback>();
Assert.NotNull(ring);
Assert.Same(ring, provider.GetService<RingBufferFallback>());
}
[Fact]
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var counter = provider.GetService<IAuditWriteFailureCounter>();
Assert.NotNull(counter);
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
}
[Fact]
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
});
var client = provider.GetService<ISiteStreamAuditClient>();
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = "/tmp/test-audit.db",
["AuditLog:SiteWriter:ChannelCapacity"] = "8192",
["AuditLog:SiteWriter:BatchSize"] = "128",
["AuditLog:SiteWriter:FlushIntervalMs"] = "75",
});
var opts = provider.GetRequiredService<IOptions<SqliteAuditWriterOptions>>().Value;
Assert.Equal("/tmp/test-audit.db", opts.DatabasePath);
Assert.Equal(8192, opts.ChannelCapacity);
Assert.Equal(128, opts.BatchSize);
Assert.Equal(75, opts.FlushIntervalMs);
}
[Fact]
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
{
using var provider = BuildProvider(new Dictionary<string, string?>
{
["AuditLog:SiteTelemetry:BatchSize"] = "512",
["AuditLog:SiteTelemetry:BusyIntervalSeconds"] = "3",
["AuditLog:SiteTelemetry:IdleIntervalSeconds"] = "60",
});
var opts = provider.GetRequiredService<IOptions<SiteAuditTelemetryOptions>>().Value;
Assert.Equal(512, opts.BatchSize);
Assert.Equal(3, opts.BusyIntervalSeconds);
Assert.Equal(60, opts.IdleIntervalSeconds);
}
}