fix(auditlog): reconciliation pull serves only unacked rows (PushAbandoned/Rejected push-exit states), central pages within a tick with set-based inserts; refused push rows leave the queue after N attempts (review 2026-10-21 06/PERF-1, 06/COR-3, 06/IMPL-1)
This commit is contained in:
+66
-6
@@ -51,9 +51,11 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
|
||||
private static SiteAuditReconciliationOptions FastTickOptions(
|
||||
int batchSize = 256,
|
||||
int stalledAfter = 2) =>
|
||||
int stalledAfter = 2,
|
||||
int maxPagesPerTick = 50) =>
|
||||
new()
|
||||
{
|
||||
MaxPagesPerTick = maxPagesPerTick,
|
||||
// 100 ms tick keeps each test under a second. AwaitAssert covers
|
||||
// schedule jitter so a 100 ms tick has up to ~3 s to fire.
|
||||
ReconciliationIntervalSeconds = 300,
|
||||
@@ -79,6 +81,25 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
private readonly HashSet<Guid> _seen = new();
|
||||
/// <summary>Gets the number of times <c>InsertIfNotExistsAsync</c> was called.</summary>
|
||||
public int InsertCallCount { get; private set; }
|
||||
/// <summary>Gets the number of set-based <c>InsertManyIfNotExistsAsync</c> calls.</summary>
|
||||
public int BatchCallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> InsertManyIfNotExistsAsync(
|
||||
IReadOnlyList<AuditEvent> events, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||||
{
|
||||
BatchCallCount++;
|
||||
var inserted = 0;
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (_seen.Add(evt.EventId))
|
||||
{
|
||||
Inserted.Add(evt);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
return Task.FromResult(inserted);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
@@ -480,9 +501,12 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that pulled events are ingested through the repository insert-if-not-exists call.</summary>
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 06/PERF-1: a pulled page is ingested with ONE set-based
|
||||
/// <c>InsertManyIfNotExistsAsync</c> call, not one round trip per row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
|
||||
public void Tick_IngestEvents_ViaOneSetBasedInsert()
|
||||
{
|
||||
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
|
||||
var e1 = NewEvent("siteA");
|
||||
@@ -493,16 +517,50 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions());
|
||||
|
||||
AwaitAssert(() => Assert.Equal(2, repo.InsertCallCount),
|
||||
AwaitAssert(() => Assert.Equal(2, repo.Inserted.Count),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
StopReconciliationActor(actor);
|
||||
|
||||
Assert.Equal(1, repo.BatchCallCount);
|
||||
Assert.Equal(0, repo.InsertCallCount);
|
||||
Assert.Contains(repo.Inserted, e => e.EventId == e1.EventId);
|
||||
Assert.Contains(repo.Inserted, e => e.EventId == e2.EventId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 06/PERF-1: a busy but healthy site whose backlog spans several pages
|
||||
/// is drained by paging WITHIN one tick, so the stalled latch does not fire.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BusyHealthySite_PagesWithinOneTick_AndDoesNotLatchStalled()
|
||||
{
|
||||
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
|
||||
var t = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var pages = Enumerable.Range(0, 3)
|
||||
.Select(p => Enumerable.Range(0, 3).Select(i => NewEvent("siteA", t.AddSeconds(p * 10 + i))).ToArray())
|
||||
.ToArray();
|
||||
var client = new ScriptedPullClient().Script("siteA",
|
||||
new PullAuditEventsResponse(pages[0], MoreAvailable: true),
|
||||
new PullAuditEventsResponse(pages[1], MoreAvailable: true),
|
||||
new PullAuditEventsResponse(pages[2], MoreAvailable: false));
|
||||
var repo = new RecordingRepo();
|
||||
var (probe, _) = SubscribeStalled();
|
||||
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 1));
|
||||
|
||||
AwaitAssert(() => Assert.Equal(9, repo.Inserted.Count),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
StopReconciliationActor(actor);
|
||||
|
||||
// The three pages were issued back to back with an advancing cursor.
|
||||
Assert.Equal(pages[0][^1].EventId.ToString(), client.Calls[1].AfterId);
|
||||
Assert.Equal(pages[1][^1].EventId.ToString(), client.Calls[2].AfterId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. Tick_Duplicates_NotDoubleInserted (real MSSQL idempotency)
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -774,7 +832,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
var repo = new RecordingRepo();
|
||||
var (probe, _) = SubscribeStalled();
|
||||
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2));
|
||||
// One page per tick: the backlog is still MoreAvailable when the page cap is hit, which
|
||||
// is the genuine non-drain the stalled latch exists for.
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2, maxPagesPerTick: 1));
|
||||
|
||||
// Expect Stalled=true after the second non-draining tick. The probe
|
||||
// waits with its own timeout (a few seconds gives the 0 s repeat
|
||||
@@ -808,7 +868,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
var repo = new RecordingRepo();
|
||||
var (probe, _) = SubscribeStalled();
|
||||
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2));
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2, maxPagesPerTick: 1));
|
||||
|
||||
// First publication is the stalled=true transition; second is the
|
||||
// back-to-draining flip. The actor publishes ONLY on transitions so we
|
||||
|
||||
Reference in New Issue
Block a user