perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene
This commit is contained in:
@@ -121,6 +121,105 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_DuplicateEventIds_WithinOnePacket_ProduceOneRow_AndAreAllAcked()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// WP2.2 writes each packet as ONE set-based statement, whose anti-semi-join
|
||||
// only sees already-COMMITTED rows. Two copies of an EventId inside one
|
||||
// packet would therefore both pass it and collide on the clustered PK,
|
||||
// taking the whole packet down — the repository de-duplicates first.
|
||||
// Every id is still acked: the site's contract is "this row is now
|
||||
// present at central", which is true for both copies.
|
||||
var siteId = NewSiteId();
|
||||
var repeated = NewEvent(siteId);
|
||||
var other = NewEvent(siteId);
|
||||
var batch = new List<AuditEvent> { repeated, other, repeated, repeated };
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
actor.Tell(new IngestAuditEventsCommand(batch), TestActor);
|
||||
|
||||
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(4, reply.AcceptedEventIds.Count);
|
||||
Assert.True(
|
||||
new[] { repeated.EventId, other.EventId }.ToHashSet()
|
||||
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, rows.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_SamePacketTwice_IsIdempotent_AcrossPackets()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// A site whose ack was lost re-delivers the identical packet on the next
|
||||
// drain, and the reconciliation pull can re-deliver it a third time. Each
|
||||
// replay must ack fully (so the site can finally flip its rows to
|
||||
// Forwarded) while writing nothing new.
|
||||
var siteId = NewSiteId();
|
||||
var events = Enumerable.Range(0, 6).Select(_ => NewEvent(siteId)).ToList();
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
|
||||
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(6, reply.AcceptedEventIds.Count);
|
||||
Assert.True(
|
||||
events.Select(e => e.EventId).ToHashSet()
|
||||
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
|
||||
}
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(6, rows.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_OverlappingPackets_InsertOnlyTheNewRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Partially-overlapping packets are the normal reconciliation shape: the
|
||||
// pull cursor re-serves a tail the push already delivered. The overlap
|
||||
// must be a silent no-op and the new rows must land.
|
||||
var siteId = NewSiteId();
|
||||
var first = Enumerable.Range(0, 4).Select(_ => NewEvent(siteId)).ToList();
|
||||
var second = first.Skip(2).Concat(
|
||||
Enumerable.Range(0, 3).Select(_ => NewEvent(siteId))).ToList();
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
var actor = CreateActor(repo);
|
||||
|
||||
actor.Tell(new IngestAuditEventsCommand(first), TestActor);
|
||||
ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
|
||||
|
||||
actor.Tell(new IngestAuditEventsCommand(second), TestActor);
|
||||
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(5, reply.AcceptedEventIds.Count);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(7, rows.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user