Bundle C of Audit Log #23 M3. Adds the ScadaLink.SiteCallAudit project + matching tests project, mirroring the ScadaLink.AuditLog scaffolding pattern (net10.0, central package management, InternalsVisibleTo to the tests assembly). SiteCallAuditActor is the central singleton entry point for Site Call Audit (#22): it receives UpsertSiteCallCommand and persists the SiteCall via ISiteCallAuditRepository.UpsertAsync (monotonic, idempotent — out-of-order or duplicate updates are silent no-ops at the repo). Audit-write failures NEVER abort the user-facing action (CLAUDE.md): repository throws are caught + logged, the actor replies Accepted=false, and the singleton stays alive (Resume supervisor strategy as defence in depth). Two constructors mirror AuditLogIngestActor: - IServiceProvider production constructor resolves the scoped EF repository from a fresh DI scope per message. - ISiteCallAuditRepository test constructor injects a concrete repository so the TestKit tests exercise the real monotonic-upsert SQL end to end. UpsertSiteCallCommand + UpsertSiteCallReply live in ScadaLink.Commons (same home as IngestAuditEventsCommand) so Bundle D's gRPC server can construct them without taking a project reference on the actor's host project. AddSiteCallAudit() is a placeholder for symmetry with AddAuditLog / AddNotificationOutbox; Bundle F will populate it with the actor's Props factory + options bindings. Tests (Akka.TestKit.Xunit2 + MsSqlMigrationFixture via project ref to ScadaLink.ConfigurationDatabase.Tests, mirroring Bundle D2): - Receive_UpsertSiteCallCommand_Persists_Replies_Accepted - Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted (idempotency) - Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive Reconciliation, KPIs, and the central->site Retry/Discard relay are deferred per CLAUDE.md scope discipline. ScadaLink.slnx updated to include both new projects. All 3 new tests pass against the running infra/mssql container; full suite (2683 tests across 27 projects) passes with no regressions.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
namespace ScadaLink.SiteCallAudit;
|
||||
|
||||
/// <summary>
|
||||
/// Central singleton for Site Call Audit (#22). Receives
|
||||
/// <see cref="UpsertSiteCallCommand"/> messages and persists each
|
||||
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> row via
|
||||
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> — idempotent monotonic
|
||||
/// upsert. Out-of-order or duplicate updates are silent no-ops at the
|
||||
/// repository layer; the actor always replies <see cref="UpsertSiteCallReply"/>
|
||||
/// with <c>Accepted=true</c> in that case because storage state is consistent
|
||||
/// and the site is free to consider its packet acked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and
|
||||
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope
|
||||
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a
|
||||
/// follow-up).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
|
||||
/// the actor catches every exception from the repository call and replies
|
||||
/// <c>Accepted=false</c> without rethrowing, so the central singleton stays
|
||||
/// alive. The <see cref="SupervisorStrategy"/> uses <c>Resume</c> so an
|
||||
/// unexpected throw before the catch (defence in depth) does not restart the
|
||||
/// actor and reset in-flight state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two constructors exist for the same reason as
|
||||
/// <c>AuditLogIngestActor</c>: production wiring (Bundle F) resolves the
|
||||
/// scoped EF repository from a fresh DI scope per message because the actor
|
||||
/// is a long-lived cluster singleton, while tests inject a concrete
|
||||
/// <see cref="ISiteCallAuditRepository"/> against a per-test MSSQL fixture
|
||||
/// so the actor exercises the real monotonic upsert SQL end to end.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly ISiteCallAuditRepository? _injectedRepository;
|
||||
private readonly ILogger<SiteCallAuditActor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Test-mode constructor — injects a concrete repository instance whose
|
||||
/// lifetime exceeds the test, so the actor reuses the same instance
|
||||
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
|
||||
/// </summary>
|
||||
public SiteCallAuditActor(
|
||||
ISiteCallAuditRepository repository,
|
||||
ILogger<SiteCallAuditActor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_injectedRepository = repository;
|
||||
_logger = logger;
|
||||
|
||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — resolves <see cref="ISiteCallAuditRepository"/>
|
||||
/// from a fresh DI scope per message because the repository is a scoped EF
|
||||
/// Core service registered by <c>AddConfigurationDatabase</c>. The actor
|
||||
/// itself is a long-lived cluster singleton, so it cannot hold a scope
|
||||
/// across messages.
|
||||
/// </summary>
|
||||
public SiteCallAuditActor(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SiteCallAuditActor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
|
||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit-write failures are best-effort by design (CLAUDE.md §Audit): a
|
||||
/// thrown exception in the upsert pipeline must not crash the actor.
|
||||
/// Resume keeps the actor's state intact so the next packet is processed
|
||||
/// against the same repository instance.
|
||||
/// </summary>
|
||||
protected override SupervisorStrategy SupervisorStrategy()
|
||||
{
|
||||
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
|
||||
Akka.Actor.SupervisorStrategy.DefaultDecider);
|
||||
}
|
||||
|
||||
private async Task OnUpsertAsync(UpsertSiteCallCommand cmd)
|
||||
{
|
||||
// Sender is captured before the first await — Akka resets Sender
|
||||
// between message dispatches, so a post-await Tell would go to
|
||||
// DeadLetters.
|
||||
var replyTo = Sender;
|
||||
var id = cmd.SiteCall.TrackedOperationId;
|
||||
|
||||
// Scope-per-message mirrors AuditLogIngestActor — production EF
|
||||
// repository is scoped; the injected-repository mode (tests) skips
|
||||
// the scope entirely.
|
||||
IServiceScope? scope = null;
|
||||
ISiteCallAuditRepository repository;
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
repository = _injectedRepository;
|
||||
}
|
||||
else
|
||||
{
|
||||
scope = _serviceProvider!.CreateScope();
|
||||
repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await repository.UpsertAsync(cmd.SiteCall).ConfigureAwait(false);
|
||||
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
|
||||
// action — log and reply Accepted=false; do NOT rethrow (the
|
||||
// central singleton MUST stay alive).
|
||||
_logger.LogError(ex, "SiteCallAudit upsert failed for {TrackedOperationId}", id);
|
||||
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: false));
|
||||
}
|
||||
finally
|
||||
{
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user