f57f61deac
ConfigAuditLog gains two nullable columns (EventId, CorrelationId) + a filtered unique index UX_ConfigAuditLog_EventId. EF migration 20260526105027_AddConfigAuditLogEventIdColumns is additive (nullable + filtered index = legacy rows backfill cleanly). AuditWriterActor now writes EventId + CorrelationId into the dedicated columns instead of synthesising a JSON wrapper into DetailsJson. Cross-restart dedup is now real: a retry of an already-flushed batch hits the unique index and SaveChanges throws; the existing catch drops the duplicate without losing the rest of the batch. WrapDetails helper deleted — F4 (its JSON hardening) becomes moot. AuditWriterActorTests.Details_wrapper_embeds_eventId_and_correlationId renamed + rewritten to assert against the columns. All 29 ControlPlane tests pass, all 95 v2 tests green.
107 lines
3.6 KiB
C#
107 lines
3.6 KiB
C#
using Akka.Actor;
|
|
using Akka.Event;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
|
|
|
|
/// <summary>
|
|
/// Cluster-singleton actor that batches <see cref="AuditEvent"/> messages from the cluster
|
|
/// and bulk-inserts them into <c>ConfigAuditLog</c>. Flush triggers:
|
|
/// - Buffer reaches <see cref="FlushBatchSize"/> events.
|
|
/// - <see cref="FlushInterval"/> elapses with a non-empty buffer.
|
|
/// - <c>PreRestart</c> / <c>PostStop</c> (supervisor swap or coordinated shutdown).
|
|
///
|
|
/// Dedup is two-layer: in-buffer (the <see cref="Dictionary{TKey, TValue}"/> below collapses
|
|
/// duplicate EventIds before flush) and at the database via the filtered unique index
|
|
/// <c>UX_ConfigAuditLog_EventId</c> (cross-restart safety — a retry of an already-flushed
|
|
/// batch hits the constraint and we drop the duplicate insert without losing the rest of
|
|
/// the batch).
|
|
/// </summary>
|
|
public sealed class AuditWriterActor : ReceiveActor, IWithTimers
|
|
{
|
|
public const int FlushBatchSize = 500;
|
|
public static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5);
|
|
|
|
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
|
private readonly Dictionary<Guid, AuditEvent> _buffer = new();
|
|
|
|
public ITimerScheduler Timers { get; set; } = null!;
|
|
|
|
public static Props Props(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) =>
|
|
Akka.Actor.Props.Create(() => new AuditWriterActor(dbFactory));
|
|
|
|
public AuditWriterActor(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
Receive<AuditEvent>(HandleEvent);
|
|
Receive<Flush>(_ => FlushBuffer());
|
|
}
|
|
|
|
protected override void PreStart()
|
|
{
|
|
Timers.StartPeriodicTimer("flush", Flush.Instance, FlushInterval);
|
|
}
|
|
|
|
private void HandleEvent(AuditEvent evt)
|
|
{
|
|
// In-buffer dedup. Last write wins on duplicate EventId within the batch — events
|
|
// with the same EventId are by contract identical, so this is a no-op.
|
|
_buffer[evt.EventId] = evt;
|
|
if (_buffer.Count >= FlushBatchSize) FlushBuffer();
|
|
}
|
|
|
|
private void FlushBuffer()
|
|
{
|
|
if (_buffer.Count == 0) return;
|
|
|
|
var snapshot = _buffer.Values.ToList();
|
|
_buffer.Clear();
|
|
|
|
try
|
|
{
|
|
using var db = _dbFactory.CreateDbContext();
|
|
foreach (var evt in snapshot)
|
|
{
|
|
db.ConfigAuditLogs.Add(new ConfigAuditLog
|
|
{
|
|
Timestamp = evt.OccurredAtUtc,
|
|
Principal = evt.Actor,
|
|
EventType = $"{evt.Category}:{evt.Action}",
|
|
NodeId = evt.SourceNode.Value,
|
|
DetailsJson = evt.DetailsJson,
|
|
EventId = evt.EventId,
|
|
CorrelationId = evt.CorrelationId.Value,
|
|
});
|
|
}
|
|
db.SaveChanges();
|
|
_log.Debug("AuditWriter flushed {Count} events", snapshot.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Error(ex, "AuditWriter flush failed; {Count} events dropped", snapshot.Count);
|
|
}
|
|
}
|
|
|
|
protected override void PreRestart(Exception reason, object message)
|
|
{
|
|
FlushBuffer();
|
|
base.PreRestart(reason, message);
|
|
}
|
|
|
|
protected override void PostStop()
|
|
{
|
|
FlushBuffer();
|
|
base.PostStop();
|
|
}
|
|
|
|
public sealed class Flush
|
|
{
|
|
public static readonly Flush Instance = new();
|
|
private Flush() { }
|
|
}
|
|
}
|