Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ This project follows Semantic Versioning using the format `MAJOR.MINOR.PATCH`.
and a full rollback instead of a last-writer-wins or partially applied run.
* `byte[]` audit values no longer canonicalize to the literal `System.Byte[]`,
which made every binary value hash and truncate to the same constant.
* Audit reconciliation no longer loads every audit record without a mutation
batch id on each pass. Those records are now read newest first and capped by
the new `MaximumMalformedRecordsPerRun` option (default 1000, range 1-10000).

## 2.10.0 - 2026-09-19

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,22 @@ public async Task<ApplicationAuditReconciliationSummary> ReconcileAsync(

List<AuditRecord> auditRecords = await _dbContext.AuditRecords
.AsNoTracking()
.Where(record => batchIds.Contains(record.MutationBatchId) || record.MutationBatchId == string.Empty)
.Where(record => batchIds.Contains(record.MutationBatchId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

// Records without a batch id are not bounded by the batch selection above, so cap them separately;
// otherwise every such record ever written would be loaded on every reconciliation pass.
List<AuditRecord> malformedRecords = await _dbContext.AuditRecords
.AsNoTracking()
.Where(record => record.MutationBatchId == string.Empty)
.OrderByDescending(record => record.ModifiedOnUtc)
.ThenByDescending(record => record.Id)
.Take(_options.MaximumMalformedRecordsPerRun)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
auditRecords.AddRange(malformedRecords);

List<ApplicationAuditCompletionOutboxEntry> completionEntries = await _dbContext
.ApplicationAuditCompletionOutboxEntries
.AsNoTracking()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public sealed class ApplicationAuditReconciliationOptions

public int MaximumBatchesPerRun { get; set; } = 1_000;

public int MaximumMalformedRecordsPerRun { get; set; } = 1_000;

public int HealthWarningFindingCount { get; set; } = 1;

public int HealthUnhealthyFindingCount { get; set; } = 10;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public static IServiceCollection AddApplicationAuditReconciliationCore(
"The stale retry-ready threshold must be greater than zero.")
.Validate(options => options.MaximumBatchesPerRun is > 0 and <= 10_000,
"Maximum batches per reconciliation run must be between 1 and 10000.")
.Validate(options => options.MaximumMalformedRecordsPerRun is > 0 and <= 10_000,
"Maximum malformed audit records per reconciliation run must be between 1 and 10000.")
.Validate(options => options.HealthWarningFindingCount >= 0,
"The warning finding threshold must not be negative.")
.Validate(options => options.HealthUnhealthyFindingCount >= options.HealthWarningFindingCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,33 @@ public async Task RecordRemediationAsync_InvalidRequest_ThrowsBeforeWriting()
await AssertFindingUnchangedAsync(database, finding);
}

[Fact]
public async Task ReconcileAsync_RecordsWithoutBatchId_AreBoundedPerRunNewestFirst()
{
await using TestDatabase database = await TestDatabase.CreateAsync(maximumMalformedRecordsPerRun: 2);
var malformed = new List<AuditRecord>();
for (int index = 0; index < 5; index++)
{
AuditRecord record = CreateAuditRecord(string.Empty);
record.ModifiedOnUtc = _now.AddMinutes(-10 - index);
malformed.Add(record);
}

database.Context.AuditRecords.AddRange(malformed);
_ = await database.Context.SaveChangesAsync(TestContext.Current.CancellationToken);

_ = await database.Reconciler.ReconcileAsync(TestContext.Current.CancellationToken);

List<string> findingKeys = await database.Context.ApplicationAuditReconciliationFindings
.AsNoTracking()
.Where(finding => finding.ReasonCode == ApplicationAuditReconciliationReasonCodes.MalformedCorrelation)
.Select(finding => finding.MutationBatchId)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal(
malformed.Take(2).Select(record => $"missing-{record.Id:N}").Order(StringComparer.Ordinal),
findingKeys.Order(StringComparer.Ordinal));
}

[Fact]
public async Task DisabledMode_DoesNotCreateFindings()
{
Expand Down Expand Up @@ -494,7 +521,8 @@ private TestDatabase(

public static async Task<TestDatabase> CreateAsync(
bool enabled = true,
IInterceptor? interceptor = null)
IInterceptor? interceptor = null,
int maximumMalformedRecordsPerRun = 1_000)
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(TestContext.Current.CancellationToken);
Expand Down Expand Up @@ -533,7 +561,8 @@ public static async Task<TestDatabase> CreateAsync(
Enabled = enabled,
CompletionGracePeriod = TimeSpan.Zero,
StalePendingThreshold = TimeSpan.FromMinutes(15),
StaleRetryReadyThreshold = TimeSpan.FromMinutes(15)
StaleRetryReadyThreshold = TimeSpan.FromMinutes(15),
MaximumMalformedRecordsPerRun = maximumMalformedRecordsPerRun
}),
metrics,
new FixedTimeProvider(_now));
Expand Down
Loading