Merge branch 'worktree-agent-adf34e265d2dcae96' into arch-review-remediation
This commit is contained in:
@@ -322,6 +322,47 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(2, counter.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-row fallback must run on its OWN cancellation token, never the
|
||||
/// batch's. Sharing it meant that when the batch failed BECAUSE the 20 s
|
||||
/// ingest budget expired, every fallback insert was handed an
|
||||
/// already-cancelled token: N instant failures, N counter bumps, zero rows
|
||||
/// accepted — the fallback's entire purpose (land the good rows) defeated at
|
||||
/// the exact moment it was needed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pinned by token IDENTITY rather than by expiring a real budget: the budget
|
||||
/// is a fixed 20 s and a test that waited for it would cost 20 s of wall clock
|
||||
/// to assert something the token comparison establishes outright. Two
|
||||
/// <see cref="CancellationToken"/>s are equal iff they come from the same
|
||||
/// source, so "not equal" IS "a fresh CTS", and a fresh CTS cannot be
|
||||
/// pre-cancelled by the batch.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Receive_WhenBatchFails_PerRowFallbackUsesAFreshToken()
|
||||
{
|
||||
var repository = new TokenRecordingRepository();
|
||||
var actor = CreateActor(repository);
|
||||
|
||||
var events = Enumerable.Range(0, 3).Select(_ => NewEvent(NewSiteId())).ToList();
|
||||
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
|
||||
|
||||
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
|
||||
|
||||
// The fallback landed every row.
|
||||
Assert.Equal(3, reply.AcceptedEventIds.Count);
|
||||
|
||||
Assert.NotNull(repository.BatchToken);
|
||||
Assert.Equal(3, repository.RowTokens.Count);
|
||||
Assert.All(repository.RowTokens, rowToken =>
|
||||
{
|
||||
Assert.NotEqual(repository.BatchToken!.Value, rowToken);
|
||||
Assert.False(rowToken.IsCancellationRequested);
|
||||
});
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Counts how many times the guard's catch surfaced a write failure.</summary>
|
||||
private sealed class CountingFailureCounter : ICentralAuditWriteFailureCounter
|
||||
{
|
||||
@@ -329,6 +370,62 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
public void Increment() => Count++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails the set-based insert (as an expired budget would) and records the
|
||||
/// token handed to each write so the fallback's token can be compared with
|
||||
/// the batch's.
|
||||
/// </summary>
|
||||
private sealed class TokenRecordingRepository : IAuditLogRepository
|
||||
{
|
||||
public CancellationToken? BatchToken { get; private set; }
|
||||
|
||||
public List<CancellationToken> RowTokens { get; } = new();
|
||||
|
||||
public Task<int> InsertManyIfNotExistsAsync(
|
||||
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
{
|
||||
BatchToken = ct;
|
||||
throw new OperationCanceledException("simulated ingest-budget expiry", ct);
|
||||
}
|
||||
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
RowTokens.Add(ct);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<long> SwitchOutPartitionAsync(
|
||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny test double that delegates to a real repository but throws on a
|
||||
/// specified EventId. Used to verify per-row failure isolation: one bad
|
||||
|
||||
Reference in New Issue
Block a user