feat(notif): NotificationOutboxActor + CentralAuditWriter wired (#23 M4)
M4 Bundle B (B1) — add the central-only ICentralAuditWriter implementation and inject it into NotificationOutboxActor so subsequent tasks (B2/B3) can route attempt + terminal lifecycle events through the direct-write audit path. - CentralAuditWriter: thin wrapper around IAuditLogRepository.InsertIfNotExistsAsync; scope-per-call (matches AuditLogIngestActor / NotificationOutboxActor pattern); stamps IngestedAtUtc; swallows all internal failures (alog.md §13). - Registered as a singleton in AddAuditLog. - NotificationOutboxActor ctor takes ICentralAuditWriter (validated non-null). - Host wiring resolves the writer once from the root provider and passes it into the singleton's Props.Create call. - Existing TestKit fixtures updated with a NoOpCentralAuditWriter helper so tests that don't exercise audit emission still compile and pass.
This commit is contained in:
82
src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Normal file
82
src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.AuditLog.Central;
|
||||
|
||||
/// <summary>
|
||||
/// Central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
||||
/// Wraps <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> as a best-effort
|
||||
/// audit emission path for components that originate audit events ON the central
|
||||
/// node (Notification Outbox dispatch, Inbound API) — NOT for site telemetry
|
||||
/// ingest (that path is the SiteAudit → AuditLogIngestActor batched flow).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Best-effort contract.</b> Audit-write failures NEVER abort the user-facing
|
||||
/// action (alog.md §13). The writer catches every exception thrown by repository
|
||||
/// resolution or the insert call, logs at warning, and returns successfully.
|
||||
/// Callers may still wrap the call in their own try/catch (defensive — the writer
|
||||
/// is supposed to swallow).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Scope-per-call resolution.</b> <see cref="IAuditLogRepository"/> is a SCOPED
|
||||
/// EF Core service (registered by <c>ScadaLink.ConfigurationDatabase</c>). The
|
||||
/// writer itself is registered as a singleton (so all callers share one instance),
|
||||
/// so it cannot hold a scope across calls — it opens a fresh
|
||||
/// <see cref="IServiceScope"/> per <see cref="WriteAsync"/> invocation, mirroring
|
||||
/// the per-message scope pattern used by <c>AuditLogIngestActor</c> and
|
||||
/// <c>NotificationOutboxActor</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Idempotency.</b> Persistence is via <c>InsertIfNotExistsAsync</c>, so a
|
||||
/// double-emitted event (same <see cref="AuditEvent.EventId"/>) is a silent
|
||||
/// no-op — the writer is safe to call from any number of dispatch paths.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<CentralAuditWriter> _logger;
|
||||
|
||||
public CentralAuditWriter(IServiceProvider services, ILogger<CentralAuditWriter> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists <paramref name="evt"/> into the central <c>AuditLog</c> table
|
||||
/// idempotently on <see cref="AuditEvent.EventId"/>. Stamps
|
||||
/// <see cref="AuditEvent.IngestedAtUtc"/> from the central-side clock.
|
||||
/// Internal failures are logged and swallowed — never thrown.
|
||||
/// </summary>
|
||||
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (evt is null)
|
||||
{
|
||||
// Defensive — a null event is a programming bug at the caller and
|
||||
// produces no meaningful audit row. Log and return.
|
||||
_logger.LogWarning("CentralAuditWriter.WriteAsync received null event; ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var stamped = evt with { IngestedAtUtc = DateTime.UtcNow };
|
||||
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Audit failure NEVER aborts the user-facing action — swallow and log.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
||||
evt.EventId, evt.Kind, evt.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user