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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user