Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorRejectedRowTests.cs
T
Joseph Doherty 18352442ab fix(controlplane): audit writer drops only the rejected row, not the whole batch
AuditWriterActor documented per-row dedup on the UX_ConfigAuditLog_EventId index but flushed one
statement batch: a single duplicate EventId (any at-least-once producer across a singleton
hand-over) threw for all 500 rows (R03:2.6). SaveChanges now detaches the entries a
DbUpdateException names (2601/2627 logged at Debug as the expected duplicate, anything else at
Warning) and re-saves, bounded by the batch size; a failure naming no entry stays a whole-batch
failure.

Refs #733

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:30:03 -04:00

111 lines
4.8 KiB
C#

using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Update;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:2.6 — one constraint hit used to drop the whole flush. The writer now detaches the rejected
/// row(s) and re-saves, so a duplicate <c>EventId</c> costs exactly that row.
/// </summary>
public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase
{
[Fact]
public void A_rejected_row_is_dropped_and_the_rest_of_the_batch_is_persisted()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var duplicate = Guid.NewGuid();
var rejecting = new RejectingDbFactory(dbName, root, new RejectOnce(duplicate));
var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance); // PostStop flushes
ExpectTerminated(actor);
using var db = rejecting.CreateDbContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(20, "every row but the rejected one lands");
persisted.ShouldNotContain(duplicate);
// Positive control: the rows on either side of the rejected one are present.
persisted.ShouldContain(ids[6]);
persisted.ShouldContain(ids[8]);
}
/// <summary>A failure that names no entry is a whole-batch failure, exactly as before.</summary>
[Fact]
public void A_connection_level_failure_still_drops_the_batch_without_looping()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var rejecting = new RejectingDbFactory(dbName, root, new AlwaysFailWithoutEntries());
var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting));
for (var i = 0; i < 5; i++) actor.Tell(NewEvent(Guid.NewGuid()));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor); // terminates ⇒ the retry loop did not spin
using var db = rejecting.CreateDbContext();
db.ConfigAuditLogs.Count().ShouldBe(0);
}
private static AuditEvent NewEvent(Guid eventId) => new()
{
EventId = eventId, Category = "Config", Action = "Edit", Actor = "joe",
OccurredAtUtc = DateTimeOffset.UtcNow, Outcome = AuditOutcome.Success,
DetailsJson = "{}", SourceNode = "node-a", CorrelationId = Guid.NewGuid(),
};
/// <summary>Throws a <see cref="DbUpdateException"/> naming the entry for one EventId, the first time it is saved.</summary>
private sealed class RejectOnce(Guid eventId) : ISaveChangesInterceptor
{
private int _fired;
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
if (_fired == 1 || eventData.Context is null) return result;
var entry = eventData.Context.ChangeTracker.Entries<ConfigAuditLog>()
.FirstOrDefault(e => e.Entity.EventId == eventId);
if (entry is null) return result;
_fired = 1;
// GetInfrastructure() hands back the pubternal InternalEntityEntry, which is the IUpdateEntry the
// real provider puts on the exception; the test only needs that one cast.
#pragma warning disable EF1001
var updateEntry = (IUpdateEntry)entry.GetInfrastructure();
#pragma warning restore EF1001
throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601)", new[] { updateEntry });
}
}
private sealed class AlwaysFailWithoutEntries : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) =>
throw new DbUpdateException("connection lost (simulated)");
}
private sealed class RejectingDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor interceptor)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => new(
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName, root)
.AddInterceptors(interceptor)
.Options);
}
}