feat(audit): EventId + CorrelationId columns + filtered unique index (F3 + F4)
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.
This commit is contained in:
@@ -14,9 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
|
||||
/// - <see cref="FlushInterval"/> elapses with a non-empty buffer.
|
||||
/// - <c>PreRestart</c> / <c>PostStop</c> (supervisor swap or coordinated shutdown).
|
||||
///
|
||||
/// Dedup is in-buffer only — once a batch is flushed, the actor accepts a duplicate
|
||||
/// <see cref="AuditEvent.EventId"/> as a new row. True cross-restart idempotency needs an
|
||||
/// EventId column with a unique index on <c>ConfigAuditLog</c>; tracked as follow-up F3.
|
||||
/// 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
|
||||
{
|
||||
@@ -70,7 +72,9 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
|
||||
Principal = evt.Actor,
|
||||
EventType = $"{evt.Category}:{evt.Action}",
|
||||
NodeId = evt.SourceNode.Value,
|
||||
DetailsJson = WrapDetails(evt),
|
||||
DetailsJson = evt.DetailsJson,
|
||||
EventId = evt.EventId,
|
||||
CorrelationId = evt.CorrelationId.Value,
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
@@ -82,17 +86,6 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps caller-supplied details with the EventId + CorrelationId so audit consumers can
|
||||
/// reconstruct the original message. Until ConfigAuditLog gains a first-class EventId column
|
||||
/// (follow-up F3), this is the only place these correlation IDs are persisted.
|
||||
/// </summary>
|
||||
private static string WrapDetails(AuditEvent evt)
|
||||
{
|
||||
var details = evt.DetailsJson ?? "null";
|
||||
return $"{{\"eventId\":\"{evt.EventId:N}\",\"correlationId\":\"{evt.CorrelationId.Value:N}\",\"details\":{details}}}";
|
||||
}
|
||||
|
||||
protected override void PreRestart(Exception reason, object message)
|
||||
{
|
||||
FlushBuffer();
|
||||
|
||||
Reference in New Issue
Block a user