f7e7ba0263
SQL Server's DbUpdateException for one duplicate names every entry in the statement batch, so detaching ex.Entries dropped the whole flush. On a row-level rejection the writer now asks the database which of the batch's EventIds it already holds, drops exactly those, re-saves the rest, and falls back to row-by-row only if a non-duplicate row is still refused. Fresh context + entities per attempt. Pinned against the real central SQL Server (new SqlServerTestDatabase harness) and a faithful whole-batch interceptor. Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
192 lines
8.8 KiB
C#
192 lines
8.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 AlwaysRejectBatchContaining(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>
|
|
/// Fix round 1 / I4 — a FAITHFUL SQL Server shape: the provider sends the batch as one statement batch,
|
|
/// so the <see cref="DbUpdateException"/> for one duplicate names EVERY entry in it. Detaching
|
|
/// <c>ex.Entries</c> therefore dropped the whole flush. The writer must drop only the rows whose
|
|
/// EventId is already persisted and keep the rest.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_whole_batch_rejection_drops_only_the_rows_already_persisted()
|
|
{
|
|
var dbName = Guid.NewGuid().ToString("N");
|
|
var root = new InMemoryDatabaseRoot();
|
|
var factory = new RejectingDbFactory(dbName, root, new RejectWholeBatchOnDuplicate());
|
|
var duplicate = Guid.NewGuid();
|
|
using (var seed = factory.CreateDbContext())
|
|
{
|
|
seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate });
|
|
seed.SaveChanges();
|
|
}
|
|
var actor = Sys.ActorOf(AuditWriterActor.Props(factory));
|
|
|
|
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);
|
|
ExpectTerminated(actor);
|
|
|
|
using var db = factory.CreateDbContext();
|
|
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
|
|
persisted.Count.ShouldBe(21, "the 20 new rows land beside the one that was already there");
|
|
persisted.Count(id => id == duplicate).ShouldBe(1);
|
|
db.ConfigAuditLogs.Single(r => r.EventId == duplicate).Principal.ShouldBe("earlier");
|
|
}
|
|
|
|
/// <summary>The same contract against the REAL provider and the real filtered unique index.</summary>
|
|
[Fact]
|
|
[Trait("Category", "SchemaCompliance")]
|
|
public void On_sql_server_a_duplicate_event_id_costs_only_that_row()
|
|
{
|
|
using var sql = new SqlServerTestDatabase();
|
|
var duplicate = Guid.NewGuid();
|
|
using (var seed = sql.NewContext())
|
|
{
|
|
seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate });
|
|
seed.SaveChanges();
|
|
}
|
|
var actor = Sys.ActorOf(AuditWriterActor.Props(sql.Factory()));
|
|
|
|
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);
|
|
ExpectTerminated(actor, PresenceBudget);
|
|
|
|
using var db = sql.NewContext();
|
|
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
|
|
persisted.Count.ShouldBe(21);
|
|
persisted.Count(id => id == duplicate).ShouldBe(1);
|
|
foreach (var id in ids) persisted.ShouldContain(id);
|
|
}
|
|
|
|
/// <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>
|
|
/// A row the database refuses on its own terms (not a duplicate — e.g. a per-row constraint), every
|
|
/// time it is in the save. Faithful to SQL Server's batch shape: the exception names EVERY added
|
|
/// entry, so only a row-by-row retry can isolate the offender.
|
|
/// </summary>
|
|
private sealed class AlwaysRejectBatchContaining(Guid eventId) : ISaveChangesInterceptor
|
|
{
|
|
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
|
{
|
|
if (eventData.Context is null) return result;
|
|
var added = eventData.Context.ChangeTracker.Entries<ConfigAuditLog>()
|
|
.Where(e => e.State == EntityState.Added).ToList();
|
|
if (!added.Any(e => e.Entity.EventId == eventId)) return result;
|
|
#pragma warning disable EF1001
|
|
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
|
|
#pragma warning restore EF1001
|
|
throw new DbUpdateException("The statement conflicted with a CHECK constraint (simulated, whole batch)", entries);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Models SQL Server's batched insert: when ANY added row's EventId is already stored, the whole save
|
|
/// fails with an exception naming EVERY added entry (the InMemory provider enforces no unique index,
|
|
/// so this interceptor is the constraint).
|
|
/// </summary>
|
|
private sealed class RejectWholeBatchOnDuplicate : ISaveChangesInterceptor
|
|
{
|
|
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
|
{
|
|
if (eventData.Context is not OtOpcUaConfigDbContext ctx) return result;
|
|
var added = ctx.ChangeTracker.Entries<ConfigAuditLog>().Where(e => e.State == EntityState.Added).ToList();
|
|
var ids = added.Select(e => e.Entity.EventId).Where(id => id != null).ToList();
|
|
if (ids.Count == 0 || !ctx.ConfigAuditLogs.AsNoTracking().Any(r => ids.Contains(r.EventId))) return result;
|
|
#pragma warning disable EF1001
|
|
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
|
|
#pragma warning restore EF1001
|
|
throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601, whole batch)", entries);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|