merge(r2): r2-plan04

This commit is contained in:
Joseph Doherty
2026-07-13 11:09:36 -04:00
26 changed files with 3427 additions and 142 deletions
@@ -386,10 +386,153 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
[Fact]
public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, 7, Base.AddMinutes(50)),
});
ctx.ChangeTracker.Clear(); // isolate the fold's tracking behavior from the seed
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1));
// The fold READS samples and WRITES rollups; the read must not register
// thousands of read-only KpiSample entries for DetectChanges to re-scan
// (arch-review 04 round 2, R2).
Assert.Empty(ctx.ChangeTracker.Entries<KpiSample>());
}
[Fact]
public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
Assert.Null(await repo.GetLatestRollupHourAsync());
}
[Fact]
public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)),
Sample("SiteCallAudit", "buffered", "Global", null, 2, Base.AddHours(3).AddMinutes(10)),
});
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(4));
Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync());
}
// ---- Task 9: failover fold-race failure grain is the pass, not the row (R5) ----
[Fact]
public async Task Fold_LosingFailoverUpsertRace_DoesNotThrow_AndNextFoldSelfHeals()
{
// A single shared in-memory SQLite connection so a second context (the interceptor's
// side-write, and the "next tick" fold) sees the same database.
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using (var schema = ContextOn(connection))
{
await schema.Database.EnsureCreatedAsync();
}
// The old singleton incarnation's in-flight fold wins the (series, hour) upsert race:
// the interceptor inserts the conflicting rollup row on the same connection just before
// THIS fold's SaveChanges, so the unique IX_KpiRollupHourly_Series faults the loser's save.
// A non-null ScopeKey (site-scoped series) is used deliberately: SQLite treats NULLs as
// distinct in a UNIQUE index, so only a non-null key deterministically forces the conflict.
var interceptor = new InsertConflictingRollupOnFirstSave(connection, () => new KpiRollupHourly
{
Source = "NotificationOutbox", Metric = "queueDepth", Scope = "Site", ScopeKey = "plant-a",
HourStartUtc = Base, Value = -1, MinValue = -1, MaxValue = -1, SampleCount = 1,
});
await using var ctx = ContextOn(connection, interceptor);
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", 5, Base.AddMinutes(10)),
});
// Losing the race must NOT propagate DbUpdateException — the failure grain is the pass.
var ex = await Record.ExceptionAsync(() => repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)));
Assert.Null(ex);
// Next tick's fold (a fresh scope/context over the same DB) self-heals: it finds the
// conflicting row and idempotently upserts it in place to the correct folded value.
await using var ctx2 = ContextOn(connection);
var repo2 = new KpiHistoryRepository(ctx2);
await repo2.FoldHourlyRollupsAsync(Base, Base.AddHours(1));
var series = await repo2.GetHourlySeriesAsync(
"NotificationOutbox", "queueDepth", "Site", "plant-a", Base, Base.AddHours(1));
Assert.Single(series);
Assert.Equal(5, series[0].Value); // the fold's own value overwrote the side-inserted -1
}
// Local mirror of the repo's private hour-truncation, for cutoff assertions.
private static DateTime TruncateHour(DateTime utc) =>
new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);
/// <summary>Builds a SQLite test context bound to an externally-owned open connection
/// (so multiple contexts share one in-memory database), with optional interceptors.</summary>
private static ScadaBridgeDbContext ContextOn(DbConnection connection, params IInterceptor[] interceptors)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite(connection)
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.AddInterceptors(interceptors)
.Options;
return new SqliteTestDbContext(options);
}
/// <summary>
/// SaveChanges interceptor that, exactly once and only when a fold is about to insert a
/// <see cref="KpiRollupHourly"/>, inserts a conflicting (series, hour) rollup row through a
/// second context on the same connection — deterministically reproducing the failover-overlap
/// upsert race the losing fold must survive (arch-review 04 round 2, R5).
/// </summary>
private sealed class InsertConflictingRollupOnFirstSave : Microsoft.EntityFrameworkCore.Diagnostics.SaveChangesInterceptor
{
private readonly DbConnection _connection;
private readonly Func<KpiRollupHourly> _rowFactory;
private bool _fired;
public InsertConflictingRollupOnFirstSave(DbConnection connection, Func<KpiRollupHourly> rowFactory)
{
_connection = connection;
_rowFactory = rowFactory;
}
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (!_fired && eventData.Context is not null &&
eventData.Context.ChangeTracker.Entries<KpiRollupHourly>()
.Any(e => e.State == EntityState.Added))
{
_fired = true;
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite(_connection)
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options;
await using var side = new SqliteTestDbContext(options);
side.KpiRollupHourly.Add(_rowFactory());
await side.SaveChangesAsync(cancellationToken);
}
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued
/// to the store, so a test can prove the purge is sliced into multiple
@@ -1,4 +1,6 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -474,6 +476,69 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.NotNull(await repo.GetAsync(recentTerminalId));
}
[SkippableFact]
public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
var now = DateTime.UtcNow;
var cutoff = now.AddDays(-1);
// Attach a DELETE-counting interceptor so we can prove the purge issues MORE THAN ONE
// DELETE for a multi-day backlog (arch-review 04 round 2, R6 — time-sliced batching).
var deleteCounter = new DeleteCountingCommandInterceptor();
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(deleteCounter)
.Options;
await using var context = new ScadaBridgeDbContext(options);
var repo = new SiteCallAuditRepository(context);
// Three terminal rows on three DISTINCT days beyond the cutoff — a multi-day backlog.
var oldIds = new List<TrackedOperationId>();
foreach (var days in new[] { 3, 4, 5 })
{
var id = TrackedOperationId.New();
oldIds.Add(id);
var terminalAt = now.AddDays(-days);
await repo.UpsertAsync(NewRow(
id, sourceSite: site, status: "Delivered", retryCount: 0,
createdAtUtc: terminalAt.AddMinutes(-1), updatedAtUtc: terminalAt,
terminal: true, terminalAtUtc: terminalAt));
}
// A fresh terminal row inside the keep window — must survive.
var recentTerminalId = TrackedOperationId.New();
await repo.UpsertAsync(NewRow(
recentTerminalId, sourceSite: site, status: "Delivered", retryCount: 0,
createdAtUtc: now.AddHours(-2), updatedAtUtc: now.AddHours(-1),
terminal: true, terminalAtUtc: now.AddHours(-1)));
// An OLD non-terminal row — non-terminal rows are NEVER purged on age.
var oldActiveId = TrackedOperationId.New();
await repo.UpsertAsync(NewRow(
oldActiveId, sourceSite: site, status: "Attempted", retryCount: 1,
createdAtUtc: now.AddDays(-10), updatedAtUtc: now.AddDays(-10),
terminal: false));
deleteCounter.Reset();
var purged = await repo.PurgeTerminalAsync(cutoff);
// The three seeded backlog rows are gone; the fresh terminal + old non-terminal survive.
Assert.True(purged >= 3, $"Expected at least the three backlog rows purged; got {purged}.");
foreach (var id in oldIds)
{
Assert.Null(await repo.GetAsync(id));
}
Assert.NotNull(await repo.GetAsync(recentTerminalId));
Assert.NotNull(await repo.GetAsync(oldActiveId));
// Slicing: a multi-day backlog is broken into MORE THAN ONE DELETE (one per day slice).
Assert.True(deleteCounter.DeleteCount > 1,
$"Expected the multi-day purge to slice into >1 DELETE; issued {deleteCounter.DeleteCount}.");
}
// --- KPI snapshot tests -------------------------------------------------
[SkippableFact]
@@ -824,4 +889,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("Attempted", loaded.Status);
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued to the store,
/// so a test can prove the terminal purge slices a multi-day backlog into several windowed
/// DELETEs rather than one year-scale statement. <c>ExecuteSqlInterpolatedAsync</c> routes
/// through the async non-query path.
/// </summary>
private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor
{
public int DeleteCount { get; private set; }
public void Reset() => DeleteCount = 0;
private void CountIfDelete(DbCommand command)
{
if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase))
{
DeleteCount++;
}
}
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
CountIfDelete(command);
return base.NonQueryExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
CountIfDelete(command);
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
}
}
@@ -94,6 +94,22 @@ public class SchemaConfigurationTests : IDisposable
new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status) },
index.GetIncludeProperties());
}
// arch-review 04 round 2 (R6): the daily terminal retention purge filters on
// TerminalAtUtc IS NOT NULL AND TerminalAtUtc < cutoff. A filtered index over the
// terminal population lets the purge (and Task 11's MIN() slice anchor) seek the
// expired tail instead of full-scanning the 365-day table. Locked shape:
// key = TerminalAtUtc, filter = [TerminalAtUtc] IS NOT NULL.
[Fact]
public void SiteCalls_Has_Filtered_Terminal_Index()
{
var model = _context.GetService<IDesignTimeModel>().Model;
var entity = model.FindEntityType(typeof(SiteCall))!;
var index = entity.GetIndexes().Single(i => i.GetDatabaseName() == "IX_SiteCalls_Terminal");
Assert.Equal(new[] { nameof(SiteCall.TerminalAtUtc) }, index.Properties.Select(p => p.Name).ToArray());
Assert.Equal("[TerminalAtUtc] IS NOT NULL", index.GetFilter());
}
}
public class SplitQueryBehaviourTests : IDisposable