fix(auditlog): reconciliation abandons only row-intrinsic failures and only when a sibling row inserted (review 2026-10-21 follow-up M1)
This commit is contained in:
@@ -78,15 +78,15 @@ public class AuditIngestRejectionTests : TestKit
|
||||
|
||||
private sealed class OneRowClient : IPullAuditEventsClient
|
||||
{
|
||||
private readonly AuditEvent _row;
|
||||
public OneRowClient(AuditEvent row) => _row = row;
|
||||
private readonly AuditEvent[] _rows;
|
||||
public OneRowClient(params AuditEvent[] rows) => _rows = rows;
|
||||
public System.Collections.Concurrent.ConcurrentQueue<DateTime> Since { get; } = new();
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration.PullAuditEventsResponse> PullAsync(
|
||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||
{
|
||||
Since.Enqueue(sinceUtc);
|
||||
return Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration.PullAuditEventsResponse(
|
||||
new[] { _row }, MoreAvailable: false));
|
||||
_rows, MoreAvailable: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,4 +129,95 @@ public class AuditIngestRejectionTests : TestKit
|
||||
Arg.Is<AuditEvent>(e => e.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private SiteAuditReconciliationOptions FastOptions() => new()
|
||||
{
|
||||
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(30),
|
||||
};
|
||||
|
||||
private void RunTicks(IAuditLogRepository repo, OneRowClient client, int ticks)
|
||||
{
|
||||
var actor = Sys.ActorOf(Props.Create(() => new SiteAuditReconciliationActor(
|
||||
new OneSite(), client, StubAuditRepositoryProvider.Build(repo),
|
||||
Microsoft.Extensions.Options.Options.Create(FastOptions()),
|
||||
NullLogger<SiteAuditReconciliationActor>.Instance)));
|
||||
|
||||
AwaitCondition(() => client.Since.Count >= ticks, TimeSpan.FromSeconds(10));
|
||||
Watch(actor);
|
||||
Sys.Stop(actor);
|
||||
ExpectTerminated(actor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up M1: schema drift after a missed central migration (207 invalid column name on
|
||||
/// every insert) is not a ROW verdict — it never counts toward abandonment, so the site's
|
||||
/// rows are never flipped Reconciled and purged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reconciliation_SchemaDriftOnEveryRow_NeverAbandons()
|
||||
{
|
||||
var a = NewEvent();
|
||||
var b = NewEvent();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.InsertManyIfNotExistsAsync(Arg.Any<IReadOnlyList<AuditEvent>>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(SqlExceptionFactory.Create(207));
|
||||
repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(SqlExceptionFactory.Create(207));
|
||||
var client = new OneRowClient(a, b);
|
||||
|
||||
RunTicks(repo, client, 12);
|
||||
|
||||
Assert.All(client.Since, s => Assert.Equal(DateTime.MinValue, s));
|
||||
repo.DidNotReceive().InsertIfNotExistsAsync(
|
||||
Arg.Is<AuditEvent>(e => e.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up M1: a ROW-classified error (8114 conversion) that hits EVERY row of the page —
|
||||
/// e.g. a column whose type a migration changed — is a database problem, not one poison row.
|
||||
/// With no sibling inserting, nothing is abandoned however many ticks pass.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reconciliation_PermanentErrorOnEveryRow_NoSiblingSucceeds_NeverAbandons()
|
||||
{
|
||||
var a = NewEvent();
|
||||
var b = NewEvent();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.InsertManyIfNotExistsAsync(Arg.Any<IReadOnlyList<AuditEvent>>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(SqlExceptionFactory.Create(8114));
|
||||
repo.InsertIfNotExistsAsync(Arg.Is<AuditEvent>(e => e.EventId == a.EventId || e.EventId == b.EventId), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(SqlExceptionFactory.Create(8114));
|
||||
var client = new OneRowClient(a, b);
|
||||
|
||||
RunTicks(repo, client, 12);
|
||||
|
||||
Assert.All(client.Since, s => Assert.Equal(DateTime.MinValue, s));
|
||||
repo.DidNotReceive().InsertIfNotExistsAsync(
|
||||
Arg.Is<AuditEvent>(e => e.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up M1, positive half: the same row-classified error on ONE row while a sibling in the
|
||||
/// page inserts IS a poison row — it is abandoned (synthetic row written) after the threshold.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reconciliation_PermanentErrorOnOneRow_WithSiblingSuccess_Abandons()
|
||||
{
|
||||
var ok = NewEvent();
|
||||
var poison = NewEvent();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.InsertManyIfNotExistsAsync(Arg.Any<IReadOnlyList<AuditEvent>>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(SqlExceptionFactory.Create(8114));
|
||||
repo.InsertIfNotExistsAsync(Arg.Is<AuditEvent>(e => e.EventId == poison.EventId), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(SqlExceptionFactory.Create(8114));
|
||||
var client = new OneRowClient(ok, poison);
|
||||
|
||||
RunTicks(repo, client, 8);
|
||||
|
||||
repo.Received().InsertIfNotExistsAsync(
|
||||
Arg.Is<AuditEvent>(e => e.Action.EndsWith(".ReconciliationAbandoned", StringComparison.Ordinal)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user