fix(reconcile): batch startup reconcile into fixed round trips, bound central work by QueryTimeout, retry failed site passes with back-off (review 2026-10-21 03/P-1)

This commit is contained in:
Joseph Doherty
2026-10-22 06:25:57 -04:00
parent d6a9a79c27
commit 7c83a4aa94
9 changed files with 638 additions and 130 deletions
@@ -203,4 +203,100 @@ public class ReconcileRepositoryTests : IDisposable
// The reconcile row must not exist
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-reconcile"));
}
// --- review 2026-10-21 03/P-1: batched reconcile reads/stages ---
private static PendingDeployment Candidate(int instanceId, string deploymentId, DateTimeOffset now) =>
new(deploymentId, instanceId, "rev-" + deploymentId, $"{{\"cfg\":\"{deploymentId}\"}}",
"tok-" + deploymentId, now, now.AddMinutes(5));
/// <summary>The gap snapshot read returns only the requested instances, untracked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedSnapshotsByInstanceIds_ReturnsRequestedOnly_Untracked()
{
var siteId = await SeedSiteAsync("SiteBatch");
var a = await SeedInstanceAsync("BatchA", siteId);
var b = await SeedInstanceAsync("BatchB", siteId);
var c = await SeedInstanceAsync("BatchC", siteId);
await SeedSnapshotAsync(a, "dep-a", "rev-a");
await SeedSnapshotAsync(b, "dep-b", "rev-b");
_context.ChangeTracker.Clear();
var snapshots = await _repository.GetDeployedSnapshotsByInstanceIdsAsync(new[] { a, c });
var only = Assert.Single(snapshots);
Assert.Equal(a, only.Key);
Assert.Equal("dep-a", only.Value.DeploymentId);
Assert.Empty(_context.ChangeTracker.Entries<DeployedConfigSnapshot>());
}
/// <summary>
/// The batch stage inserts every candidate without a live row in one call, hands back an
/// existing live row (without its JSON) instead of superseding it, and drops expired rows.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingBatchIfAbsent_StagesAbsent_KeepsLive_ReplacesExpired()
{
var siteId = await SeedSiteAsync("SiteStage");
var fresh = await SeedInstanceAsync("StageFresh", siteId);
var inFlight = await SeedInstanceAsync("StageLive", siteId);
var expired = await SeedInstanceAsync("StageExpired", siteId);
var now = DateTimeOffset.UtcNow;
_context.Set<PendingDeployment>().AddRange(
new PendingDeployment("dep-live", inFlight, "rev-live", "{\"live\":true}", "tok-live",
now.AddMinutes(-1), now.AddMinutes(4)),
// Same DeploymentId the reconcile re-stages — the expired row must go first.
new PendingDeployment("dep-exp", expired, "rev-old", "{\"old\":true}", "tok-old",
now.AddMinutes(-20), now.AddMinutes(-10)));
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var freshCandidate = Candidate(fresh, "dep-fresh", now);
var liveCandidate = Candidate(inFlight, "dep-other", now);
var expiredCandidate = Candidate(expired, "dep-exp", now);
var result = await _repository.StagePendingBatchIfAbsentAsync(
new[] { freshCandidate, liveCandidate, expiredCandidate }, now);
Assert.Equal(3, result.Count);
Assert.Same(freshCandidate, result[fresh]);
Assert.Same(expiredCandidate, result[expired]);
var keptLive = result[inFlight];
Assert.NotSame(liveCandidate, keptLive);
Assert.Equal("dep-live", keptLive.DeploymentId);
Assert.Equal("tok-live", keptLive.Token);
Assert.Equal(string.Empty, keptLive.ConfigurationJson);
Assert.Equal("tok-dep-exp", (await _repository.GetPendingDeploymentByIdAsync("dep-exp"))!.Token);
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep-fresh"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-other"));
}
/// <summary>
/// A batch whose single save fails (here: one candidate's instance was deleted, FK) falls
/// back to per-row staging, so the good rows are still staged and only the bad one is
/// omitted.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingBatchIfAbsent_OneBadRow_OthersStillStaged()
{
var siteId = await SeedSiteAsync("SiteFk");
var good = await SeedInstanceAsync("FkGood", siteId);
var now = DateTimeOffset.UtcNow;
const int deletedInstanceId = 987_654;
var goodCandidate = Candidate(good, "dep-good", now);
var result = await _repository.StagePendingBatchIfAbsentAsync(
new[] { goodCandidate, Candidate(deletedInstanceId, "dep-gone", now) }, now);
var only = Assert.Single(result);
Assert.Equal(good, only.Key);
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep-good"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-gone"));
}
}