18352442ab
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
111 lines
4.8 KiB
C#
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);
|
|
}
|
|
}
|