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);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-12
@@ -52,22 +52,31 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups()
|
||||
public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedClusteredKey()
|
||||
{
|
||||
// EventId remains globally unique (the idempotency key for
|
||||
// InsertIfNotExistsAsync) via a dedicated unique index independent of the
|
||||
// composite PK.
|
||||
// WP2.2 (AlignAuditLogEventIdUniqueness): the standalone non-aligned
|
||||
// UX_AuditLog_EventId is gone. EventId uniqueness now rides the clustered
|
||||
// PK (EventId, OccurredAtUtc), which is partition-aligned on
|
||||
// ps_AuditLog_Month — so ALTER TABLE ... SWITCH PARTITION no longer needs
|
||||
// an offline index drop/rebuild around every retention purge. EventId is a
|
||||
// GUID minted once at the site alongside OccurredAtUtc, so pair-uniqueness
|
||||
// is EventId-uniqueness in practice, and the idempotency probe
|
||||
// (WHERE EventId = @id) still seeks the clustered key's leading column.
|
||||
//
|
||||
// Re-declaring a single-column unique index here would silently reinstate
|
||||
// the SWITCH incompatibility, so this test pins its absence.
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var eventIdIndex = entity!.GetIndexes()
|
||||
.SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId");
|
||||
Assert.DoesNotContain(
|
||||
entity!.GetIndexes(),
|
||||
i => i.GetDatabaseName() == "UX_AuditLog_EventId");
|
||||
|
||||
Assert.NotNull(eventIdIndex);
|
||||
Assert.True(eventIdIndex!.IsUnique);
|
||||
|
||||
var indexedProperty = Assert.Single(eventIdIndex.Properties);
|
||||
Assert.Equal(nameof(AuditLogRow.EventId), indexedProperty.Name);
|
||||
var pk = entity.FindPrimaryKey();
|
||||
Assert.NotNull(pk);
|
||||
Assert.Equal(
|
||||
new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) },
|
||||
pk!.Properties.Select(p => p.Name).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -164,7 +173,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
"IX_AuditLog_ParentExecution",
|
||||
"IX_AuditLog_Site_Occurred",
|
||||
"IX_AuditLog_Target_Occurred",
|
||||
"UX_AuditLog_EventId",
|
||||
// UX_AuditLog_EventId is intentionally absent — dropped by
|
||||
// AlignAuditLogEventIdUniqueness; the aligned clustered PK carries it.
|
||||
};
|
||||
|
||||
Assert.Equal(expected, indexNames);
|
||||
|
||||
+22
-8
@@ -85,22 +85,27 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies all nine named non-clustered indexes exist on the final
|
||||
/// <c>dbo.AuditLog</c> table after all migrations have been applied.
|
||||
/// Verifies all eight named non-clustered indexes exist on the final
|
||||
/// <c>dbo.AuditLog</c> table after all migrations have been applied, and that
|
||||
/// the non-aligned <c>UX_AuditLog_EventId</c> does NOT.
|
||||
/// The original five indexes were created by <c>AddAuditLogTable</c>;
|
||||
/// the <c>CollapseAuditLogToCanonical</c> (C5, Task 2.5) migration rebuilt
|
||||
/// the table and added <c>IX_AuditLog_Execution</c>,
|
||||
/// <c>IX_AuditLog_ParentExecution</c>, <c>IX_AuditLog_Node_Occurred</c>,
|
||||
/// and <c>UX_AuditLog_EventId</c> — nine in total.
|
||||
/// <c>IX_AuditLog_ParentExecution</c> and <c>IX_AuditLog_Node_Occurred</c>.
|
||||
/// <c>UX_AuditLog_EventId</c> was created alongside them but dropped again by
|
||||
/// <c>AlignAuditLogEventIdUniqueness</c> (WP2.2) — its non-alignment forced an
|
||||
/// offline drop/rebuild around every partition-switch purge, and the clustered
|
||||
/// <c>PK_AuditLog (EventId, OccurredAtUtc)</c> already enforces the same
|
||||
/// uniqueness partition-aligned.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesNineNamedIndexes()
|
||||
public async Task AppliesMigration_CreatesEightNamedIndexes_AndNoNonAlignedUniqueIndex()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// All nine named non-clustered indexes present on dbo.AuditLog after
|
||||
// All eight named non-clustered indexes present on dbo.AuditLog after
|
||||
// the full migration history is applied (AddAuditLogTable through
|
||||
// CollapseAuditLogToCanonical).
|
||||
// AlignAuditLogEventIdUniqueness).
|
||||
var expected = new[]
|
||||
{
|
||||
// Original five (AddAuditLogTable / AddAuditLogSourceNode):
|
||||
@@ -113,7 +118,6 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
|
||||
"IX_AuditLog_Execution",
|
||||
"IX_AuditLog_ParentExecution",
|
||||
"IX_AuditLog_Node_Occurred",
|
||||
"UX_AuditLog_EventId",
|
||||
};
|
||||
|
||||
foreach (var indexName in expected)
|
||||
@@ -124,6 +128,16 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
|
||||
$"WHERE o.name = 'AuditLog' AND i.name = '{indexName}';");
|
||||
Assert.True(count == 1, $"Expected index '{indexName}' to exist on AuditLog; found {count}.");
|
||||
}
|
||||
|
||||
var nonAligned = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"WHERE o.name = 'AuditLog' AND i.name = 'UX_AuditLog_EventId';");
|
||||
Assert.True(
|
||||
nonAligned == 0,
|
||||
"UX_AuditLog_EventId must NOT exist after AlignAuditLogEventIdUniqueness — "
|
||||
+ "a non-aligned index blocks ALTER TABLE ... SWITCH PARTITION and reinstates "
|
||||
+ $"the offline drop/rebuild the purge path was freed from; found {nonAligned}.");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
|
||||
+10
@@ -50,6 +50,16 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests
|
||||
$"ComputeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2");
|
||||
// The oldest lookup must be bounded (LIMIT/TOP), never a full non-terminal SELECT.
|
||||
Assert.Contains(counter.Commands, sql => sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase));
|
||||
// WP2.2: the aggregation must be PREDICATE-RESTRICTED, not an unrestricted
|
||||
// scan. Every KPI here concerns the live queue, the parked backlog or the
|
||||
// last delivery interval; historical Delivered/Discarded rows — which are
|
||||
// the overwhelming bulk of a retained table — contribute to none of them.
|
||||
// Without the WHERE the query's cost grows with retention rather than with
|
||||
// the working set. (An index-restricted aggregation was already the shape
|
||||
// of the per-site/per-node snapshots; the global one was the odd one out.)
|
||||
Assert.All(
|
||||
counter.Commands.Where(sql => sql.Contains("COUNT", StringComparison.OrdinalIgnoreCase)),
|
||||
sql => Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(3, global.QueueDepth);
|
||||
Assert.NotNull(global.OldestPendingAge);
|
||||
|
||||
|
||||
+198
-18
@@ -560,6 +560,164 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// WP2.2: set-based InsertManyIfNotExistsAsync
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertManyIfNotExistsAsync_WritesEveryDistinctEvent()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
var baseTime = new DateTime(2026, 5, 21, 8, 0, 0, DateTimeKind.Utc);
|
||||
var events = Enumerable.Range(0, 12)
|
||||
.Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
|
||||
.ToList();
|
||||
|
||||
var inserted = await repo.InsertManyIfNotExistsAsync(events);
|
||||
|
||||
Assert.Equal(12, inserted);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(12, rows.Count);
|
||||
Assert.Equal(
|
||||
events.Select(e => e.EventId).OrderBy(g => g).ToArray(),
|
||||
rows.Select(r => r.EventId).OrderBy(g => g).ToArray());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsWithinOnePacket_CollapseToOneRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// A set-based INSERT … SELECT … WHERE NOT EXISTS only tests rows that are
|
||||
// already COMMITTED, so two copies of one EventId inside a single VALUES
|
||||
// constructor would both pass the anti-semi-join and collide on the
|
||||
// clustered PK — taking the whole statement down with them. The repository
|
||||
// de-duplicates the packet first (first-write-wins, matching the
|
||||
// single-row contract), which this pins.
|
||||
var occurred = new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc);
|
||||
var first = NewEvent(siteId, occurredAtUtc: occurred);
|
||||
var duplicate = ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
eventId: first.EventId,
|
||||
occurredAtUtc: occurred,
|
||||
sourceSiteId: siteId,
|
||||
errorMessage: "duplicate-within-packet-should-be-ignored");
|
||||
var other = NewEvent(siteId, occurredAtUtc: occurred.AddSeconds(1));
|
||||
|
||||
var inserted = await repo.InsertManyIfNotExistsAsync(
|
||||
new[] { first, duplicate, other, duplicate });
|
||||
|
||||
Assert.Equal(2, inserted);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
// First-write-wins: the surviving row is the FIRST occurrence, so the
|
||||
// duplicate's ErrorMessage never lands.
|
||||
var stored = Assert.Single(rows, r => r.EventId == first.EventId);
|
||||
Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsAcrossPackets_AreIdempotent()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
var baseTime = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc);
|
||||
var packet = Enumerable.Range(0, 5)
|
||||
.Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
|
||||
.ToList();
|
||||
|
||||
// A site retry / reconciliation pull re-delivers a packet that overlaps
|
||||
// an already-ingested one. The second call must insert only the genuinely
|
||||
// new rows and silently skip the rest (first-write-wins across packets).
|
||||
var firstInserted = await repo.InsertManyIfNotExistsAsync(packet);
|
||||
var extra = NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(99));
|
||||
var secondInserted = await repo.InsertManyIfNotExistsAsync(
|
||||
packet.Concat(new[] { extra }).ToList());
|
||||
|
||||
// A third, wholly-redundant replay writes nothing at all.
|
||||
var thirdInserted = await repo.InsertManyIfNotExistsAsync(packet);
|
||||
|
||||
Assert.Equal(5, firstInserted);
|
||||
Assert.Equal(1, secondInserted);
|
||||
Assert.Equal(0, thirdInserted);
|
||||
|
||||
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 InsertManyIfNotExistsAsync_ChunksBeyondTheParameterCeiling()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// 250 rows × 10 bound parameters = 2,500 — past SQL Server's 2,100
|
||||
// parameter ceiling for a single statement, so this only succeeds if the
|
||||
// repository chunks. Nulls in the optional columns (Target, SourceNode,
|
||||
// CorrelationId, Actor) are left null on purpose: the VALUES constructor
|
||||
// derives its column types from the parameters, so an untyped null would
|
||||
// surface here as a conversion failure rather than silently.
|
||||
var baseTime = new DateTime(2026, 5, 21, 11, 0, 0, DateTimeKind.Utc);
|
||||
var events = Enumerable.Range(0, 250)
|
||||
.Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
|
||||
.ToList();
|
||||
|
||||
var inserted = await repo.InsertManyIfNotExistsAsync(events);
|
||||
|
||||
Assert.Equal(250, inserted);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var count = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.CountAsync();
|
||||
|
||||
Assert.Equal(250, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertManyIfNotExistsAsync_EmptyBatch_IsANoOp()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty<AuditEvent>()));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId()
|
||||
{
|
||||
@@ -622,16 +780,17 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// ------------------------------------------------------------------------
|
||||
//
|
||||
// The partition-switch path replaces M1's NotSupportedException stub with
|
||||
// the production drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION →
|
||||
// DROP-staging → CREATE-INDEX dance documented in alog.md §4. These tests
|
||||
// the production CREATE-staging → SWITCH PARTITION → DROP-staging batch
|
||||
// documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to
|
||||
// bracket it: uniqueness rides the partition-ALIGNED clustered PK
|
||||
// (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests
|
||||
// verify the side effects an outsider can observe:
|
||||
// * rows in the targeted month are removed
|
||||
// * rows in OTHER months are NOT touched
|
||||
// * UX_AuditLog_EventId still exists after a successful switch
|
||||
// * no non-aligned UX_AuditLog_EventId is (re)created by a switch
|
||||
// * InsertIfNotExistsAsync's first-write-wins idempotency still holds
|
||||
// after a switch (the rebuilt index is real)
|
||||
// * a thrown SqlException leaves UX_AuditLog_EventId rebuilt (the CATCH
|
||||
// branch's recovery path runs)
|
||||
// after a switch (the aligned key really does enforce it)
|
||||
// * a thrown SqlException leaves the table intact with no orphaned staging
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept()
|
||||
@@ -669,7 +828,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_RebuildsUxIndex_AfterSwitch()
|
||||
public async Task SwitchOutPartitionAsync_LeavesNoNonAlignedUniqueIndex_AfterSwitch()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
@@ -680,12 +839,23 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// the fixture's MSSQL database) don't tread on each other.
|
||||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it
|
||||
// would reinstate the offline drop/rebuild the purge was freed from and
|
||||
// block the next SWITCH until something dropped it again.
|
||||
await using var verifyContext = CreateContext();
|
||||
var indexExists = await ScalarAsync<int>(
|
||||
verifyContext,
|
||||
"SELECT COUNT(*) FROM sys.indexes " +
|
||||
"WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
|
||||
Assert.Equal(1, indexExists);
|
||||
Assert.Equal(0, indexExists);
|
||||
|
||||
// The aligned clustered PK is what enforces uniqueness now, and the
|
||||
// switch must leave it untouched.
|
||||
var pkExists = await ScalarAsync<int>(
|
||||
verifyContext,
|
||||
"SELECT COUNT(*) FROM sys.indexes " +
|
||||
"WHERE name = 'PK_AuditLog' AND object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1;");
|
||||
Assert.Equal(1, pkExists);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -705,10 +875,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// Switch out the June 2026 partition (different month, empty).
|
||||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// Re-attempting the same EventId after the switch must STILL be a no-op
|
||||
// (UX_AuditLog_EventId is the index that enables idempotency; if the
|
||||
// rebuild left it broken, this insert would silently produce a duplicate
|
||||
// row and the count assertion below would catch it).
|
||||
// Re-attempting the same EventId after the switch must STILL be a no-op.
|
||||
// The idempotency probe now seeks the aligned clustered PK's leading
|
||||
// column instead of a dedicated unique index; if the switch had disturbed
|
||||
// that key, this insert would silently produce a duplicate row and the
|
||||
// count assertion below would catch it.
|
||||
// C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
|
||||
// (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
|
||||
var dup = ScadaBridgeAuditEventFactory.Create(
|
||||
@@ -734,7 +905,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SwitchOutPartitionAsync_PartialFailure_RebuildsUxIndex_RaisesException()
|
||||
public async Task SwitchOutPartitionAsync_PartialFailure_RaisesException_LeavesNoOrphanedStaging()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
@@ -745,8 +916,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// ALTER TABLE … SWITCH refuses to move rows out of a partition that's
|
||||
// referenced by an FK from another table, raising msg 4928
|
||||
// ("ALTER TABLE SWITCH statement failed because target table … has a
|
||||
// foreign key …"). The CATCH branch then rolls back and rebuilds the
|
||||
// unique index — which the assertion below verifies.
|
||||
// foreign key …"). The CATCH branch then rolls back and drops the staging
|
||||
// table — which the assertion below verifies.
|
||||
//
|
||||
// The probe table is uniquely named with a guid suffix so reruns of
|
||||
// this test inside the same fixture DB never collide. We clean it up
|
||||
@@ -786,14 +957,23 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// The CATCH block in the production SQL guarantees UX_AuditLog_EventId
|
||||
// is rebuilt regardless of which step failed inside the TRY.
|
||||
// The CATCH block drops the GUID-suffixed staging table regardless of
|
||||
// which step failed inside the TRY, so a failed purge leaves no orphaned
|
||||
// AuditLog_Staging_* object behind for the next tick to trip over.
|
||||
await using var verifyContext = CreateContext();
|
||||
var orphanedStaging = await ScalarAsync<int>(
|
||||
verifyContext,
|
||||
"SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';");
|
||||
Assert.Equal(0, orphanedStaging);
|
||||
|
||||
// And it must NOT have (re)created the non-aligned unique index: WP2.2
|
||||
// deleted that rebuild entirely, so a failed switch cannot reintroduce
|
||||
// the object that blocks the NEXT switch.
|
||||
var indexExists = await ScalarAsync<int>(
|
||||
verifyContext,
|
||||
"SELECT COUNT(*) FROM sys.indexes " +
|
||||
"WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
|
||||
Assert.Equal(1, indexExists);
|
||||
Assert.Equal(0, indexExists);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
+87
@@ -325,6 +325,57 @@ public class KpiHistoryRepositoryTests
|
||||
Assert.Equal(2, row.SampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FoldHourlyRollupsAsync_PreloadsExistingRollups_WithoutAPerSeriesHourProbe()
|
||||
{
|
||||
// WP2.2: the fold used to issue one FirstOrDefaultAsync existence probe per
|
||||
// (series, hour) group before writing anything — an N+1 that scaled with
|
||||
// the metric catalogue times the lookback window. It now preloads the whole
|
||||
// window in a single query and resolves each group from a dictionary.
|
||||
//
|
||||
// Six distinct series across two hours = twelve groups, so the old shape
|
||||
// issued twelve SELECTs against KpiRollupHourly. The new shape issues one
|
||||
// (plus the KpiSample read). This asserts the count stays small and
|
||||
// constant rather than tracking the group count.
|
||||
var counter = new RollupSelectCountingInterceptor();
|
||||
await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter);
|
||||
var repo = new KpiHistoryRepository(ctx);
|
||||
|
||||
var samples = new List<KpiSample>();
|
||||
for (var series = 0; series < 6; series++)
|
||||
{
|
||||
foreach (var hourOffset in new[] { 0, 1 })
|
||||
{
|
||||
samples.Add(Sample(
|
||||
"NotificationOutbox",
|
||||
"metric" + series,
|
||||
"Global",
|
||||
null,
|
||||
value: series + hourOffset,
|
||||
capturedAtUtc: Base.AddHours(hourOffset).AddMinutes(10)));
|
||||
}
|
||||
}
|
||||
|
||||
await repo.RecordSamplesAsync(samples);
|
||||
|
||||
// Seed the rollups so the SECOND fold takes the re-fold (update) path —
|
||||
// the branch that most needed the per-group probe.
|
||||
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2));
|
||||
|
||||
counter.Reset();
|
||||
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2));
|
||||
|
||||
Assert.True(
|
||||
counter.RollupSelectCount <= 1,
|
||||
$"expected the fold to preload existing rollups in a single query; it issued {counter.RollupSelectCount} SELECTs against KpiRollupHourly");
|
||||
|
||||
// And the fold is still correct: twelve series-hours, values unchanged by
|
||||
// the re-fold.
|
||||
var rollups = await ctx.KpiRollupHourly.AsNoTracking().ToListAsync();
|
||||
Assert.Equal(12, rollups.Count);
|
||||
Assert.All(rollups, r => Assert.Equal(1, r.SampleCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey()
|
||||
{
|
||||
@@ -540,6 +591,42 @@ public class KpiHistoryRepositoryTests
|
||||
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
|
||||
/// async path).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Counts reader commands that SELECT from <c>KpiRollupHourly</c>, so a test
|
||||
/// can prove the hourly fold resolves existing rows from a single preload
|
||||
/// rather than one probe per (series, hour) group.
|
||||
/// </summary>
|
||||
private sealed class RollupSelectCountingInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int RollupSelectCount { get; private set; }
|
||||
|
||||
public void Reset() => RollupSelectCount = 0;
|
||||
|
||||
private void CountIfRollupSelect(DbCommand command)
|
||||
{
|
||||
if (command.CommandText.Contains("KpiRollupHourly", StringComparison.OrdinalIgnoreCase)
|
||||
&& command.CommandText.Contains("SELECT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RollupSelectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public override InterceptionResult<DbDataReader> ReaderExecuting(
|
||||
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
|
||||
{
|
||||
CountIfRollupSelect(command);
|
||||
return base.ReaderExecuting(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CountIfRollupSelect(command);
|
||||
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DeleteCountingInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int DeleteCount { get; private set; }
|
||||
|
||||
+41
@@ -96,6 +96,47 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_RejectedMonotonicUpdate_DoesNotFallThroughToInsert()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// WP2.2 collapsed the two-round-trip "insert-if-absent then monotonic
|
||||
// update" into ONE batch that updates first and inserts only when nothing
|
||||
// matched. That makes @@ROWCOUNT = 0 ambiguous: it means "no such row"
|
||||
// AND "the monotonic guard rejected this packet". Guarding the insert on
|
||||
// @@ROWCOUNT alone would therefore let every stale/regressive packet
|
||||
// append a SECOND row for an id that already exists — silently forking
|
||||
// the mirror. The re-check for the row's existence is what prevents that,
|
||||
// and this pins it: after a rejected regressive upsert there is still
|
||||
// exactly ONE row, still carrying the advanced state.
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 3, lastError: null));
|
||||
|
||||
// Every flavour of rejection: lower rank, equal terminal rank, and an
|
||||
// equal non-terminal rank with a stale timestamp.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
|
||||
await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 9, lastError: "should-not-apply"));
|
||||
await repo.UpsertAsync(NewRow(
|
||||
id,
|
||||
status: "Delivered",
|
||||
retryCount: 99,
|
||||
updatedAtUtc: new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.TrackedOperationId == id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(loaded);
|
||||
Assert.Equal("Delivered", loaded[0].Status);
|
||||
Assert.Equal(3, loaded[0].RetryCount);
|
||||
Assert.Null(loaded[0].LastError);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -582,4 +583,122 @@ public class SiteCallAuditReconciliationTests : TestKit
|
||||
Assert.Equal(siteId, evt.SiteId);
|
||||
Assert.True(evt.Pinned, "a legacy site that ignores after_id must publish Pinned=true");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 9. WP2.2: the reconciliation drain runs OFF the mailbox, so ingest,
|
||||
// query and KPI messages are answered while a long post-outage
|
||||
// catch-up is still pulling.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Pull client whose first call blocks until released, simulating a
|
||||
/// post-outage catch-up that takes far longer than the caller's Ask timeout.
|
||||
/// </summary>
|
||||
private sealed class BlockingPullClient : IPullSiteCallsClient
|
||||
{
|
||||
private readonly TaskCompletionSource _release =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _entered =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Completes once the drain has actually started pulling.</summary>
|
||||
public Task Entered => _entered.Task;
|
||||
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
public async Task<PullSiteCallsResponse> PullAsync(
|
||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||
{
|
||||
_entered.TrySetResult();
|
||||
await _release.Task.ConfigureAwait(false);
|
||||
return new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconciliationDrain_InFlight_DoesNotBlockIngestUpsert()
|
||||
{
|
||||
// The drain used to run inside ReceiveAsync, which occupies the actor for
|
||||
// its whole duration. A post-outage catch-up (every site, many paged
|
||||
// network pulls, one upsert per row) therefore parked telemetry ingest,
|
||||
// UI queries and KPI Asks behind it — and those callers timed out rather
|
||||
// than queued, so a slow site could make central look dead. This pins the
|
||||
// fix: with the drain off-mailbox behind a single-flight guard, an ingest
|
||||
// Ask completes promptly while the pull is still blocked.
|
||||
var siteId = "siteSlow";
|
||||
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteSlow:8083"));
|
||||
var client = new BlockingPullClient();
|
||||
var repo = new RecordingRepo();
|
||||
|
||||
var actor = CreateActor(sites, client, repo, FastTickOptions());
|
||||
|
||||
// Wait until the drain is genuinely in flight and blocked inside PullAsync.
|
||||
await client.Entered.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// The mailbox must still be serving. A generous-but-finite budget: this
|
||||
// fails at the pre-fix behaviour (the reply only arrives once the pull
|
||||
// unblocks, which never happens until Release below).
|
||||
var id = TrackedOperationId.New();
|
||||
var reply = await actor.Ask<UpsertSiteCallReply>(
|
||||
new UpsertSiteCallCommand(NewRow(id, sourceSite: siteId)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
Assert.True(reply.Accepted);
|
||||
Assert.Equal(id, reply.TrackedOperationId);
|
||||
|
||||
// Let the drain finish so the actor shuts down cleanly.
|
||||
client.Release();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconciliationTicks_DoNotOverlap_WhileADrainIsInFlight()
|
||||
{
|
||||
// Single-flight guard: with a 100 ms tick and a drain blocked for far
|
||||
// longer, every subsequent tick must be dropped rather than starting a
|
||||
// second concurrent pass — overlapping passes would race on the per-site
|
||||
// cursor and pinned-latch dictionaries the drain mutates.
|
||||
var siteId = "siteSlow";
|
||||
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteSlow:8083"));
|
||||
var client = new CountingBlockingPullClient();
|
||||
var repo = new RecordingRepo();
|
||||
|
||||
CreateActor(sites, client, repo, FastTickOptions());
|
||||
|
||||
await client.Entered.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Several tick intervals elapse while the first pass is still blocked.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||
|
||||
Assert.Equal(1, client.CallCount);
|
||||
|
||||
client.Release();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="BlockingPullClient"/> that also counts invocations, so a test
|
||||
/// can prove no second pass started while the first was blocked.
|
||||
/// </summary>
|
||||
private sealed class CountingBlockingPullClient : IPullSiteCallsClient
|
||||
{
|
||||
private readonly TaskCompletionSource _release =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _entered =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _callCount;
|
||||
|
||||
public Task Entered => _entered.Task;
|
||||
|
||||
public int CallCount => Volatile.Read(ref _callCount);
|
||||
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
public async Task<PullSiteCallsResponse> PullAsync(
|
||||
string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
_entered.TrySetResult();
|
||||
await _release.Task.ConfigureAwait(false);
|
||||
return new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user