perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene

This commit is contained in:
Joseph Doherty
2026-08-14 21:07:12 -04:00
parent ee193cd2bb
commit 5db2a810c0
29 changed files with 3790 additions and 266 deletions
@@ -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);
@@ -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]
@@ -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);
@@ -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);
}
// ------------------------------------------------------------------------
@@ -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; }
@@ -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()
{