fix(controlplane): audit writer drops only already-persisted EventIds, never the whole SQL Server batch

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
This commit is contained in:
Joseph Doherty
2026-10-22 13:42:29 -04:00
parent f1c52385ed
commit f7e7ba0263
3 changed files with 255 additions and 65 deletions
@@ -26,7 +26,7 @@ public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var duplicate = Guid.NewGuid();
var rejecting = new RejectingDbFactory(dbName, root, new RejectOnce(duplicate));
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();
@@ -46,6 +46,68 @@ public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase
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()
@@ -71,24 +133,43 @@ public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase
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
/// <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
{
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.
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 updateEntry = (IUpdateEntry)entry.GetInfrastructure();
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
#pragma warning restore EF1001
throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601)", new[] { updateEntry });
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);
}
}
@@ -0,0 +1,77 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
/// <summary>
/// A migrated, throw-away config database on the central SQL Server, for the few ControlPlane behaviours
/// the InMemory provider cannot model (a SQL Server <c>DbUpdateException</c> names the WHOLE statement
/// batch; collation-ordered reads). Same server / credentials / env overrides as
/// <c>Configuration.Tests.SchemaComplianceFixture</c> (<c>OTOPCUA_CONFIG_TEST_SERVER</c>,
/// <c>OTOPCUA_CONFIG_TEST_SA_PASSWORD</c>; default the shared docker host's dev SA), and the same database
/// name shape, so that suite's 24 h orphan reaper also reclaims a database a killed run leaks here.
/// </summary>
public sealed class SqlServerTestDatabase : IDisposable
{
private const string DefaultServer = "10.100.0.35,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
/// <summary>Gets the connection string of the migrated database.</summary>
public string ConnectionString { get; }
private readonly string _databaseName;
/// <summary>Creates and migrates the database.</summary>
public SqlServerTestDatabase()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_databaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
ConnectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;";
using var ctx = NewContext();
ctx.Database.Migrate();
}
/// <summary>A new context on the database.</summary>
/// <returns>The context.</returns>
public OtOpcUaConfigDbContext NewContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseSqlServer(ConnectionString).Options);
/// <summary>A factory over <see cref="NewContext"/>.</summary>
/// <returns>The factory.</returns>
public IDbContextFactory<OtOpcUaConfigDbContext> Factory() => new ContextFactory(this);
/// <summary>Drops the database (best effort; a leak is reaped by the Configuration suite).</summary>
public void Dispose()
{
try
{
SqlConnection.ClearAllPools();
var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString;
using var conn = new SqlConnection(master);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
DECLARE @sql nvarchar(max);
IF DB_ID(@name) IS NOT NULL
BEGIN
SET @sql = N'ALTER DATABASE ' + QUOTENAME(@name) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE; ' +
N'DROP DATABASE ' + QUOTENAME(@name) + N';';
EXEC sp_executesql @sql;
END";
cmd.Parameters.Add(new SqlParameter("@name", _databaseName));
cmd.ExecuteNonQuery();
}
catch (Exception)
{
// Best effort — see the class remarks.
}
}
private sealed class ContextFactory(SqlServerTestDatabase owner) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => owner.NewContext();
}
}