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:
@@ -128,6 +128,13 @@ public class AkkaHostedService : IHostedService
|
||||
var rolesStr = string.Join(",", roles.Select(QuoteHocon));
|
||||
|
||||
return $@"
|
||||
audit-telemetry-dispatcher {{
|
||||
type = ForkJoinDispatcher
|
||||
throughput = 100
|
||||
dedicated-thread-pool {{
|
||||
thread-count = 2
|
||||
}}
|
||||
}}
|
||||
akka {{
|
||||
extensions = [
|
||||
""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools""
|
||||
@@ -294,6 +301,47 @@ akka {{
|
||||
commService?.SetNotificationOutbox(outboxProxy);
|
||||
_logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor");
|
||||
|
||||
// Audit Log (#23) — central singleton mirrors the Notification Outbox
|
||||
// pattern. The IngestAuditEvents gRPC handler lives on SiteStreamGrpcServer
|
||||
// (Communication.Grpc); a central node hosting that server (M6 reconciliation
|
||||
// path) hands the proxy in via SetAuditIngestActor below. When the gRPC
|
||||
// server is not registered (current central topology), the host still
|
||||
// brings the singleton up so a Bundle H in-process test (or a future
|
||||
// direct caller) can Ask the proxy without further wiring.
|
||||
// IAuditLogRepository is a SCOPED EF Core service, so the singleton
|
||||
// actor takes the root IServiceProvider and creates a fresh scope per
|
||||
// message (mirroring NotificationOutboxActor). Pre-resolving the
|
||||
// repository here would attempt to take a scoped service from the
|
||||
// root and fail under DI scope validation.
|
||||
var auditIngestLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<ScadaLink.AuditLog.Central.AuditLogIngestActor>();
|
||||
|
||||
var auditIngestSingletonProps = ClusterSingletonManager.Props(
|
||||
singletonProps: Props.Create(() => new ScadaLink.AuditLog.Central.AuditLogIngestActor(
|
||||
_serviceProvider,
|
||||
auditIngestLogger)),
|
||||
terminationMessage: PoisonPill.Instance,
|
||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||
.WithSingletonName("audit-log-ingest"));
|
||||
_actorSystem!.ActorOf(auditIngestSingletonProps, "audit-log-ingest-singleton");
|
||||
|
||||
var auditIngestProxyProps = ClusterSingletonProxy.Props(
|
||||
singletonManagerPath: "/user/audit-log-ingest-singleton",
|
||||
settings: ClusterSingletonProxySettings.Create(_actorSystem)
|
||||
.WithSingletonName("audit-log-ingest"));
|
||||
var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
|
||||
|
||||
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
|
||||
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
|
||||
// The gRPC server is currently only registered on Site nodes; on a central
|
||||
// node this resolves to null and the wiring is a no-op until M6 (which
|
||||
// brings central-hosted gRPC + a real site→central client).
|
||||
var grpcServer = _serviceProvider.GetService<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
grpcServer?.SetAuditIngestActor(auditIngestProxy);
|
||||
_logger.LogInformation(
|
||||
"AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
|
||||
grpcServer is not null);
|
||||
|
||||
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
|
||||
}
|
||||
|
||||
@@ -504,6 +552,41 @@ akka {{
|
||||
contacts.Count, _nodeOptions.SiteId);
|
||||
}
|
||||
|
||||
// Audit Log (#23) — site-side telemetry actor that drains the SQLite
|
||||
// Pending queue and pushes to central via IngestAuditEvents. Not a
|
||||
// cluster singleton: each site is its own cluster, and the actor reads
|
||||
// node-local SQLite (no replication). The Props are bound to the
|
||||
// dedicated audit-telemetry-dispatcher (defined in BuildHocon) so a
|
||||
// batch SQLite read + gRPC push never contend with the default
|
||||
// dispatcher used by hot-path actors.
|
||||
//
|
||||
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
|
||||
// collaborators through its constructor, so we resolve them from DI
|
||||
// and pass them in via Props.Create rather than relying on a future
|
||||
// FactoryProvider. This also lets the M6 follow-up swap the
|
||||
// NoOpSiteStreamAuditClient registration for the real gRPC client
|
||||
// without touching this site wiring.
|
||||
var siteAuditOptions = _serviceProvider
|
||||
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
|
||||
var siteAuditQueue = _serviceProvider
|
||||
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteAuditQueue>();
|
||||
var siteAuditClient = _serviceProvider
|
||||
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient>();
|
||||
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
|
||||
|
||||
var siteAuditTelemetryProps = Props.Create(() =>
|
||||
new ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor(
|
||||
siteAuditQueue,
|
||||
siteAuditClient,
|
||||
siteAuditOptions,
|
||||
siteAuditLogger))
|
||||
.WithDispatcher("audit-telemetry-dispatcher");
|
||||
_actorSystem.ActorOf(siteAuditTelemetryProps, "site-audit-telemetry");
|
||||
_logger.LogInformation(
|
||||
"SiteAuditTelemetryActor created (dispatcher=audit-telemetry-dispatcher, client={ClientType})",
|
||||
siteAuditClient.GetType().Name);
|
||||
|
||||
// Gate gRPC subscriptions until the actor system and SiteStreamManager are
|
||||
// initialized (REQ-HOST-7).
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user