fix(kpi): classify the failover fold-race pass loss as a self-healing Information event (plan R2-04 T9)
This commit is contained in:
+100
@@ -429,10 +429,110 @@ public class KpiHistoryRepositoryTests
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user