Merge branch 'worktree-agent-a3b474b485c0288de' into arch-review-remediation
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()
|
||||
{
|
||||
|
||||
@@ -28,20 +28,19 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
||||
/// <list type="number">
|
||||
/// <item>The oldest partition (Jan) is removed.</item>
|
||||
/// <item>Newer partitions (Feb + Mar) are untouched.</item>
|
||||
/// <item>The <c>UX_AuditLog_EventId</c> unique index survives the
|
||||
/// drop-and-rebuild dance.</item>
|
||||
/// <item>The switch leaves the aligned clustered <c>PK_AuditLog</c> intact and
|
||||
/// does NOT (re)create the non-aligned <c>UX_AuditLog_EventId</c>.</item>
|
||||
/// <item><see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> remains
|
||||
/// idempotent against the rebuilt index after the purge.</item>
|
||||
/// idempotent against the aligned key after the purge.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The brief calls out that direct INSERTs bypass the writer role's INSERT-only
|
||||
/// grant; the fixture connects as <c>sa</c> (see
|
||||
/// <see cref="MsSqlMigrationFixture"/>'s default admin connection string), so
|
||||
/// the seed step does not need the writer role at all. The drop-and-rebuild
|
||||
/// dance itself runs under the same admin connection because the test owns
|
||||
/// the database — the role granularity is exercised in the repository tests,
|
||||
/// not here.
|
||||
/// the seed step does not need the writer role at all. The switch batch itself
|
||||
/// runs under the same admin connection because the test owns the database —
|
||||
/// the role granularity is exercised in the repository tests, not here.
|
||||
/// </remarks>
|
||||
public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
@@ -110,22 +109,39 @@ VALUES
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that <c>UX_AuditLog_EventId</c> exists in
|
||||
/// <c>sys.indexes</c>. The drop-and-rebuild dance briefly removes the
|
||||
/// index inside its transaction; this check is meant to fire AFTER the
|
||||
/// actor's purge tick has committed so the rebuilt index is observable.
|
||||
/// Asserts the post-purge index state: the partition-ALIGNED clustered
|
||||
/// <c>PK_AuditLog</c> is intact and the non-aligned <c>UX_AuditLog_EventId</c>
|
||||
/// is absent (WP2.2 — <c>AlignAuditLogEventIdUniqueness</c>).
|
||||
/// </summary>
|
||||
private static async Task AssertUxIndexExistsAsync(SqlConnection conn)
|
||||
/// <remarks>
|
||||
/// The purge used to bracket its SWITCH with a DROP/CREATE of
|
||||
/// <c>UX_AuditLog_EventId</c>, because a non-aligned unique index blocks
|
||||
/// <c>ALTER TABLE … SWITCH PARTITION</c> — an offline whole-table index build
|
||||
/// inside the switch transaction, plus a window with no idempotency index at
|
||||
/// all. Uniqueness now rides the aligned clustered PK, so the switch is
|
||||
/// metadata-only. Asserting the index's ABSENCE is what keeps that property:
|
||||
/// anything that recreates it silently reinstates the rebuild.
|
||||
/// </remarks>
|
||||
private static async Task AssertAlignedUniquenessAsync(SqlConnection conn)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT COUNT(*)
|
||||
FROM sys.indexes
|
||||
WHERE name = 'UX_AuditLog_EventId'
|
||||
AND object_id = OBJECT_ID('dbo.AuditLog');";
|
||||
var raw = await cmd.ExecuteScalarAsync();
|
||||
var count = Convert.ToInt32(raw);
|
||||
Assert.True(count == 1, $"UX_AuditLog_EventId should be present post-purge; sys.indexes count was {count}.");
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sys.indexes
|
||||
WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) AS NonAligned,
|
||||
(SELECT COUNT(*) FROM sys.indexes
|
||||
WHERE object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1) AS ClusteredPk;";
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
var nonAligned = reader.GetInt32(0);
|
||||
var clusteredPk = reader.GetInt32(1);
|
||||
|
||||
Assert.True(
|
||||
nonAligned == 0,
|
||||
$"UX_AuditLog_EventId must NOT exist post-purge (it blocks SWITCH PARTITION); sys.indexes count was {nonAligned}.");
|
||||
Assert.True(
|
||||
clusteredPk == 1,
|
||||
$"The aligned clustered PK_AuditLog must survive the purge; sys.indexes primary-key count was {clusteredPk}.");
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(
|
||||
@@ -255,20 +271,19 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. EndToEnd_UxIndexRebuilt_AfterPurge
|
||||
// 2. EndToEnd_AlignedUniqueness_AfterPurge
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EndToEnd_UxIndexRebuilt_AfterPurge()
|
||||
public async Task EndToEnd_AlignedUniqueness_AfterPurge()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Same shape as test 1 — purge the Jan-2026 partition and then assert the
|
||||
// UX_AuditLog_EventId index is still present. RetentionDays is computed
|
||||
// dynamically so the threshold always lands near 2026-01-20 (see SeedOccurredAt()).
|
||||
// The drop-and-rebuild dance briefly removes the index inside its transaction
|
||||
// (the SWITCH PARTITION step requires the non-aligned unique index to be absent),
|
||||
// but step 5 rebuilds it before committing.
|
||||
// index state. RetentionDays is computed dynamically so the threshold always
|
||||
// lands near 2026-01-20 (see SeedOccurredAt()). Since WP2.2 the switch touches
|
||||
// no index at all: uniqueness rides the aligned clustered PK, so there is
|
||||
// nothing to drop before the SWITCH and nothing to rebuild after it.
|
||||
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var oldEventId = Guid.NewGuid();
|
||||
var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();
|
||||
@@ -305,7 +320,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
// Open a fresh connection (the actor's pool is owned by EF) and
|
||||
// assert the index is present post-purge.
|
||||
await using var check = _fixture.OpenConnection();
|
||||
await AssertUxIndexExistsAsync(check);
|
||||
await AssertAlignedUniquenessAsync(check);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -320,7 +335,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
// Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for
|
||||
// a fresh recent EventId. The second call must be a no-op (duplicate-key collision
|
||||
// swallowed by the repository, per M2 Bundle A's race-fix) — which means the
|
||||
// rebuilt UX_AuditLog_EventId unique index is functioning as intended.
|
||||
// aligned clustered PK is still enforcing uniqueness as intended.
|
||||
// RetentionDays is computed dynamically so the threshold always lands near
|
||||
// 2026-01-20 (see SeedOccurredAt()).
|
||||
var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
@@ -357,11 +372,11 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
max: TimeSpan.FromSeconds(30));
|
||||
|
||||
// Settle then exercise InsertIfNotExistsAsync twice for the same
|
||||
// EventId. The repository's idempotency relies on
|
||||
// UX_AuditLog_EventId being present so the IF NOT EXISTS … INSERT
|
||||
// race window resolves to a duplicate-key violation the repo
|
||||
// swallows. If the index were missing here, two rows would land
|
||||
// and the second InsertIfNotExistsAsync would silently double-insert.
|
||||
// EventId. The repository's idempotency relies on the aligned clustered
|
||||
// PK (EventId, OccurredAtUtc) being intact so the IF NOT EXISTS … INSERT
|
||||
// race window resolves to a duplicate-key violation the repo swallows.
|
||||
// If the switch had disturbed that key, two rows would land and the second
|
||||
// InsertIfNotExistsAsync would silently double-insert.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
var freshEventId = Guid.NewGuid();
|
||||
@@ -482,7 +497,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
/// <summary>
|
||||
/// Task 4 (arch-review 04, S2): proves the explicit-<see cref="TimeSpan"/> maintenance-timeout
|
||||
/// overload of <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> runs the real
|
||||
/// drop-and-rebuild dance to completion against SQL Server — the old row is purged, the kept row
|
||||
/// staging/switch batch to completion against SQL Server — the old row is purged, the kept row
|
||||
/// survives, and the returned sampled row-count reflects the switched partition. Exercising the
|
||||
/// timeout path end-to-end guards against a regression that only sets the timeout on the sample
|
||||
/// command and forgets the DDL batch (or vice versa).
|
||||
@@ -520,8 +535,8 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
Assert.DoesNotContain(rows, r => r.EventId == oldEventId);
|
||||
Assert.Contains(rows, r => r.EventId == keptEventId);
|
||||
|
||||
// The dance must leave the idempotency-supporting unique index rebuilt.
|
||||
// The switch must leave uniqueness enforcement exactly as it found it.
|
||||
await using var check = _fixture.OpenConnection();
|
||||
await AssertUxIndexExistsAsync(check);
|
||||
await AssertAlignedUniquenessAsync(check);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user