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:
@@ -207,6 +207,22 @@ a cancelled bulk deploy can be left holding its lock.
|
|||||||
one, so the two entry points cannot drift on deployment identity, idempotency, lock
|
one, so the two entry points cannot drift on deployment identity, idempotency, lock
|
||||||
coverage, or optimistic concurrency.
|
coverage, or optimistic concurrency.
|
||||||
|
|
||||||
|
### Startup-reconcile staging
|
||||||
|
|
||||||
|
A site node that missed a deploy reports its inventory on startup and central
|
||||||
|
(`Communication/ReconcileService`) stages fresh `PendingDeployment` fetch tokens for
|
||||||
|
the gap. The pass costs a **fixed number of round trips whatever the gap size**
|
||||||
|
(review 2026-10-21 03/P-1): the expected-set projection (no config JSON), ONE
|
||||||
|
untracked read of the gap instances' snapshot configs
|
||||||
|
(`GetDeployedSnapshotsByInstanceIdsAsync`), and ONE batched insert-if-absent
|
||||||
|
(`StagePendingBatchIfAbsentAsync` — expired rows deleted set-based, live rows from an
|
||||||
|
in-flight deploy or the peer node's concurrent reconcile handed back instead of
|
||||||
|
superseded, one `SaveChanges`; a failed batch save falls back to per-row staging so
|
||||||
|
one bad row costs only itself). Central's work is bounded by `QueryTimeout`, the
|
||||||
|
site's own deadline for the reply. On the site, `SiteReconciliationActor` retries a
|
||||||
|
failed or partially-failed pass up to **3** times with exponential back-off (30 s,
|
||||||
|
60 s) instead of waiting for the next node restart.
|
||||||
|
|
||||||
### Terminal deployment-record retention
|
### Terminal deployment-record retention
|
||||||
|
|
||||||
Deployment records are insert-only — one row per deploy attempt — so without a
|
Deployment records are insert-only — one row per deploy attempt — so without a
|
||||||
|
|||||||
+31
@@ -341,6 +341,37 @@ public interface IDeploymentManagerRepository
|
|||||||
string configurationJson, string token, DateTimeOffset createdAtUtc, DateTimeOffset expiresAtUtc,
|
string configurationJson, string token, DateTimeOffset createdAtUtc, DateTimeOffset expiresAtUtc,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the deployed config snapshots of <paramref name="instanceIds"/> in ONE untracked
|
||||||
|
/// query — the startup-reconcile gap read. Only the gap's ids should be passed: this is the
|
||||||
|
/// read that materialises each snapshot's <c>ConfigurationJson</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="instanceIds">Instances whose snapshots to read; duplicates are harmless.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||||
|
/// <returns>Instance id → snapshot, omitting instances with no snapshot (deleted race).</returns>
|
||||||
|
Task<IReadOnlyDictionary<int, DeployedConfigSnapshot>> GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
|
IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch form of <see cref="StagePendingIfAbsentAsync"/> for startup reconcile: expired
|
||||||
|
/// pending rows for the candidates' instances are deleted, then every candidate whose
|
||||||
|
/// instance has no LIVE pending row is inserted, all in one <c>SaveChanges</c>. A live row
|
||||||
|
/// (an in-flight deploy, or the peer node's concurrent reconcile) is never superseded.
|
||||||
|
/// Self-contained: commits its own writes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="candidates">One new pending row per instance (later duplicates for the same instance are ignored).</param>
|
||||||
|
/// <param name="nowUtc">"Now": rows with <c>ExpiresAtUtc <= nowUtc</c> are expired.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Instance id → the instance's live pending row after the call: the candidate itself
|
||||||
|
/// (reference-equal) when it was staged, otherwise the pre-existing live row, returned
|
||||||
|
/// WITHOUT its <c>ConfigurationJson</c> (empty string). An instance whose candidate could not
|
||||||
|
/// be staged and has no live row (e.g. the instance was deleted mid-reconcile) is omitted.
|
||||||
|
/// </returns>
|
||||||
|
Task<IReadOnlyDictionary<int, PendingDeployment>> StagePendingBatchIfAbsentAsync(
|
||||||
|
IReadOnlyList<PendingDeployment> candidates, DateTimeOffset nowUtc,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// Instance lookups for deployment pipeline
|
// Instance lookups for deployment pipeline
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets an instance by ID.
|
/// Gets an instance by ID.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||||
@@ -18,6 +19,11 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
|
|||||||
/// replicate. The node fetches each gap item's config over the existing token-gated HTTP
|
/// replicate. The node fetches each gap item's config over the existing token-gated HTTP
|
||||||
/// endpoint; this service stages those tokens as <c>PendingDeployment</c> rows.
|
/// endpoint; this service stages those tokens as <c>PendingDeployment</c> rows.
|
||||||
///
|
///
|
||||||
|
/// The whole pass is a fixed number of round trips regardless of gap size: the expected-set
|
||||||
|
/// projection, ONE untracked read of the gap's snapshot configs, and ONE batched
|
||||||
|
/// insert-if-absent of the pending rows. Work is bounded by
|
||||||
|
/// <see cref="CommunicationOptions.QueryTimeout"/> — the site's own deadline for the reply.
|
||||||
|
///
|
||||||
/// Scoped service (holds scoped repositories); resolved per-request by the actor inside a
|
/// Scoped service (holds scoped repositories); resolved per-request by the actor inside a
|
||||||
/// DI scope. The actor stays thin: it captures <c>Sender</c>, resolves this service in a
|
/// DI scope. The actor stays thin: it captures <c>Sender</c>, resolves this service in a
|
||||||
/// scope, awaits <see cref="ReconcileAsync"/>, and pipes the response back.
|
/// scope, awaits <see cref="ReconcileAsync"/>, and pipes the response back.
|
||||||
@@ -73,92 +79,96 @@ public class ReconcileService
|
|||||||
Array.Empty<ReconcileGapItem>(), Array.Empty<string>(), baseUrl);
|
Array.Empty<ReconcileGapItem>(), Array.Empty<string>(), baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Central's expected deployed set for this site (instances with a snapshot).
|
// Central does no more work than the site will wait for: the node's own Ask/gRPC
|
||||||
|
// deadline is QueryTimeout, so past it every staged token would go to a caller that
|
||||||
|
// has already given up. (The actor-lifetime token passed in is not request-scoped.)
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
if (_options.QueryTimeout > TimeSpan.Zero)
|
||||||
|
deadline.CancelAfter(_options.QueryTimeout);
|
||||||
|
var ct = deadline.Token;
|
||||||
|
|
||||||
|
// 2. Central's expected deployed set for this site (instances with a snapshot) — a
|
||||||
|
// projection carrying each snapshot's DeploymentId/RevisionHash, no config JSON.
|
||||||
var expected = await _deploymentRepository
|
var expected = await _deploymentRepository
|
||||||
.GetExpectedDeploymentsForSiteAsync(site.Id, cancellationToken)
|
.GetExpectedDeploymentsForSiteAsync(site.Id, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var localMap = request.LocalNameToRevisionHash;
|
var localMap = request.LocalNameToRevisionHash;
|
||||||
var gap = new List<ReconcileGapItem>();
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var expiresAt = now + _options.PendingDeploymentTtl;
|
|
||||||
|
|
||||||
// 3. GAP = expected items the node is MISSING (name absent locally) or STALE
|
// 3. GAP = expected items the node is MISSING (name absent locally) or STALE
|
||||||
// (local revision != expected revision). Current items are omitted.
|
// (local revision != expected revision). Current items are omitted.
|
||||||
foreach (var exp in expected)
|
var gapExpected = expected
|
||||||
{
|
.Where(exp => !localMap.TryGetValue(exp.InstanceUniqueName, out var localHash)
|
||||||
var present = localMap.TryGetValue(exp.InstanceUniqueName, out var localHash);
|
|| !string.Equals(localHash, exp.RevisionHash, StringComparison.Ordinal))
|
||||||
var stale = present && !string.Equals(localHash, exp.RevisionHash, StringComparison.Ordinal);
|
.ToList();
|
||||||
if (present && !stale)
|
|
||||||
continue; // node already has the current revision
|
|
||||||
|
|
||||||
// 4. Read the frozen snapshot config to stage. Null = the snapshot was deleted
|
var gap = new List<ReconcileGapItem>(gapExpected.Count);
|
||||||
// between the expected-set query and now (instance removed mid-reconcile);
|
if (gapExpected.Count > 0)
|
||||||
// skip it — best-effort reconcile re-runs on the next node startup.
|
{
|
||||||
var snapshot = await _deploymentRepository
|
// 4. ONE read of the frozen snapshot configs, for the gap only — this is the only
|
||||||
.GetDeployedSnapshotByInstanceIdAsync(exp.InstanceId, cancellationToken)
|
// query that materialises ConfigurationJson. A missing entry means the snapshot
|
||||||
|
// was deleted between the expected-set query and now (instance removed
|
||||||
|
// mid-reconcile); it is skipped.
|
||||||
|
var snapshots = await _deploymentRepository
|
||||||
|
.GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
|
gapExpected.Select(e => e.InstanceId).ToList(), ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (snapshot == null)
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var expiresAt = now + _options.PendingDeploymentTtl;
|
||||||
|
|
||||||
|
// Stage with the snapshot's DeploymentId so the gap item's DeploymentId + token point
|
||||||
|
// the node at the right pending row to fetch.
|
||||||
|
var staging = new List<(ExpectedDeployment Expected, PendingDeployment Candidate)>(gapExpected.Count);
|
||||||
|
foreach (var exp in gapExpected)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
if (!snapshots.TryGetValue(exp.InstanceId, out var snapshot))
|
||||||
"Reconcile: snapshot for instance {Instance} disappeared (deleted race); skipping",
|
{
|
||||||
exp.InstanceUniqueName);
|
_logger.LogDebug(
|
||||||
continue;
|
"Reconcile: snapshot for instance {Instance} disappeared (deleted race); skipping",
|
||||||
|
exp.InstanceUniqueName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
staging.Add((exp, new PendingDeployment(
|
||||||
|
snapshot.DeploymentId, exp.InstanceId, snapshot.RevisionHash,
|
||||||
|
snapshot.ConfigurationJson, DeploymentFetchToken.Generate(), now, expiresAt)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = DeploymentFetchToken.Generate();
|
// Insert-if-absent, in one save. If BOTH site nodes are concurrently missing the same
|
||||||
|
// instance (fresh container start / cleared SQLite after a successful deploy), both
|
||||||
// Stage with the snapshot's DeploymentId as the deploymentId so the gap item's
|
// reconcile here; the loser finds the winner's LIVE row and is handed ITS
|
||||||
// DeploymentId + token point the node at the right pending row to fetch.
|
// DeploymentId/RevisionHash/Token (the fetch token is multi-use within its TTL), so
|
||||||
//
|
// both nodes heal in the same round instead of the second waiting for a restart. The
|
||||||
// StagePendingIfAbsent is insert-if-absent: if BOTH site nodes are concurrently
|
// same applies to an in-flight deploy's row: its config is newer than the snapshot,
|
||||||
// missing the same instance (e.g. fresh container start / cleared SQLite after a
|
// and fetching it is still correct (the site's guarded write handles ordering).
|
||||||
// successful deploy), both attempt to stage here. The first succeeds (true); the
|
// Deploy-time supersession serializes via the per-instance operation lock.
|
||||||
// second gets false and is handled in the !staged branch below — it returns the
|
var liveRows = await _deploymentRepository
|
||||||
// existing pending row's token so it heals in the same round, rather than being
|
.StagePendingBatchIfAbsentAsync(staging.Select(x => x.Candidate).ToList(), now, ct)
|
||||||
// omitted. Deploy-time supersession serializes via the per-instance operation lock.
|
|
||||||
var staged = await _deploymentRepository.StagePendingIfAbsentAsync(
|
|
||||||
exp.InstanceId, snapshot.DeploymentId, exp.RevisionHash,
|
|
||||||
snapshot.ConfigurationJson, token, now, expiresAt, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (!staged)
|
foreach (var (exp, candidate) in staging)
|
||||||
{
|
{
|
||||||
// A pending row already exists for this instance — either a CONCURRENT reconcile
|
if (!liveRows.TryGetValue(candidate.InstanceId, out var row))
|
||||||
// from the other site node (both nodes' SQLite empty after a fresh/cleared deploy,
|
{
|
||||||
// both reconciling at startup) or an in-flight deploy. Do NOT omit the item: if we
|
// Raced away: no live row could be staged or found (e.g. the instance was
|
||||||
// did, the second concurrently-missing node would get 0 fetched and stay unhealed
|
// deleted mid-reconcile). Omit it — the node retries on its next pass.
|
||||||
// until a later restart. Instead, read the EXISTING pending row and emit a gap item
|
_logger.LogDebug(
|
||||||
// carrying ITS DeploymentId/RevisionHash/Token. The fetch token is multi-use within
|
"Reconcile: no pending row could be staged for instance {Instance}; omitting from gap",
|
||||||
// its TTL, so both nodes fetch the same pending config and heal in the same round.
|
exp.InstanceUniqueName);
|
||||||
// (If the existing row is from an in-flight deploy its config is newer than the
|
continue;
|
||||||
// snapshot — fetching it is still correct; the site's guarded write handles ordering.)
|
}
|
||||||
var existing = await _deploymentRepository
|
|
||||||
.GetPendingDeploymentByInstanceIdAsync(exp.InstanceId, now, cancellationToken)
|
if (!ReferenceEquals(row, candidate))
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Reconcile: pending row already exists for instance {Instance} (concurrent reconcile or in-flight deploy); returning existing token so this node heals too",
|
"Reconcile: pending row already exists for instance {Instance} (concurrent reconcile or in-flight deploy); returning existing token so this node heals too",
|
||||||
exp.InstanceUniqueName);
|
exp.InstanceUniqueName);
|
||||||
gap.Add(new ReconcileGapItem(
|
|
||||||
exp.InstanceUniqueName, existing.DeploymentId, existing.RevisionHash,
|
|
||||||
exp.IsEnabled, existing.Token));
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// Raced away: the pending row was purged between the stage attempt and this
|
|
||||||
// read. Omit it — reconcile is best-effort and the node retries next round.
|
|
||||||
_logger.LogDebug(
|
|
||||||
"Reconcile: pending row for instance {Instance} disappeared between stage and read (purged race); omitting from gap",
|
|
||||||
exp.InstanceUniqueName);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
gap.Add(new ReconcileGapItem(
|
gap.Add(new ReconcileGapItem(
|
||||||
exp.InstanceUniqueName, snapshot.DeploymentId, exp.RevisionHash, exp.IsEnabled, token));
|
exp.InstanceUniqueName, row.DeploymentId, row.RevisionHash, exp.IsEnabled, row.Token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. ORPHANS = names the node has locally that central no longer considers deployed.
|
// 5. ORPHANS = names the node has locally that central no longer considers deployed.
|
||||||
|
|||||||
+139
@@ -573,6 +573,145 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
|||||||
return !liveExists;
|
return !liveExists;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyDictionary<int, DeployedConfigSnapshot>> GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
|
IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(instanceIds);
|
||||||
|
if (instanceIds.Count == 0)
|
||||||
|
return new Dictionary<int, DeployedConfigSnapshot>();
|
||||||
|
|
||||||
|
var ids = instanceIds.Distinct().ToArray();
|
||||||
|
return await _dbContext.Set<DeployedConfigSnapshot>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => ids.Contains(s.InstanceId))
|
||||||
|
.ToDictionaryAsync(s => s.InstanceId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyDictionary<int, PendingDeployment>> StagePendingBatchIfAbsentAsync(
|
||||||
|
IReadOnlyList<PendingDeployment> candidates, DateTimeOffset nowUtc,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(candidates);
|
||||||
|
var byInstance = new Dictionary<int, PendingDeployment>();
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
byInstance.TryAdd(candidate.InstanceId, candidate);
|
||||||
|
if (byInstance.Count == 0)
|
||||||
|
return byInstance;
|
||||||
|
|
||||||
|
var ids = byInstance.Keys.ToArray();
|
||||||
|
|
||||||
|
// Same two-step contract as StagePendingIfAbsentAsync, set-based: drop EXPIRED rows
|
||||||
|
// first (they would otherwise read as in-flight and collide on the DeploymentId unique
|
||||||
|
// index when a snapshot's own DeploymentId is re-staged), then insert only where no
|
||||||
|
// LIVE row exists. A live row is never superseded.
|
||||||
|
await DeletePendingWhereAsync(
|
||||||
|
p => ids.Contains(p.InstanceId) && p.ExpiresAtUtc <= nowUtc, cancellationToken);
|
||||||
|
|
||||||
|
var live = await ReadLivePendingAsync(ids, nowUtc, cancellationToken);
|
||||||
|
var added = new List<PendingDeployment>();
|
||||||
|
foreach (var (instanceId, candidate) in byInstance)
|
||||||
|
{
|
||||||
|
if (live.ContainsKey(instanceId))
|
||||||
|
continue;
|
||||||
|
added.Add(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.Count == 0)
|
||||||
|
return live;
|
||||||
|
|
||||||
|
await _dbContext.Set<PendingDeployment>().AddRangeAsync(added, cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// The batch lost a race — most commonly the peer site node's concurrent reconcile
|
||||||
|
// inserted the same snapshot DeploymentId first (unique index), or an instance was
|
||||||
|
// deleted mid-reconcile (FK). Un-track the failed inserts and fall back to staging
|
||||||
|
// one row at a time, so one bad row costs only itself and a row the peer staged is
|
||||||
|
// picked up as the live row it now is.
|
||||||
|
foreach (var row in added)
|
||||||
|
_dbContext.Entry(row).State = EntityState.Detached;
|
||||||
|
|
||||||
|
return await StagePendingOneByOneAsync(byInstance, nowUtc, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in added)
|
||||||
|
live[row.InstanceId] = row;
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fallback for <see cref="StagePendingBatchIfAbsentAsync"/> after its single save failed:
|
||||||
|
/// stages each candidate through <see cref="StagePendingIfAbsentAsync"/>, skipping (and
|
||||||
|
/// omitting from the result) any single row that still cannot be written.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<int, PendingDeployment>> StagePendingOneByOneAsync(
|
||||||
|
IReadOnlyDictionary<int, PendingDeployment> byInstance, DateTimeOffset nowUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<int, PendingDeployment>();
|
||||||
|
foreach (var (instanceId, candidate) in byInstance)
|
||||||
|
{
|
||||||
|
bool staged;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
staged = await StagePendingIfAbsentAsync(
|
||||||
|
instanceId, candidate.DeploymentId, candidate.RevisionHash,
|
||||||
|
candidate.ConfigurationJson, candidate.Token,
|
||||||
|
nowUtc, candidate.ExpiresAtUtc, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
DetachPendingChanges();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staged)
|
||||||
|
{
|
||||||
|
result[instanceId] = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await ReadLivePendingAsync([instanceId], nowUtc, cancellationToken);
|
||||||
|
if (existing.TryGetValue(instanceId, out var row))
|
||||||
|
result[instanceId] = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the most recent LIVE pending row per instance, projected WITHOUT
|
||||||
|
/// <c>ConfigurationJson</c> (the caller only needs the fetch coordinates), untracked.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<int, PendingDeployment>> ReadLivePendingAsync(
|
||||||
|
int[] instanceIds, DateTimeOffset nowUtc, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = await _dbContext.Set<PendingDeployment>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => instanceIds.Contains(p.InstanceId) && p.ExpiresAtUtc > nowUtc)
|
||||||
|
.Select(p => new
|
||||||
|
{
|
||||||
|
p.Id, p.DeploymentId, p.InstanceId, p.RevisionHash, p.Token, p.CreatedAtUtc, p.ExpiresAtUtc
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// At most one live row per instance by design; on a hypothetical duplicate take the
|
||||||
|
// most recent, mirroring GetPendingDeploymentByInstanceIdAsync's ordering.
|
||||||
|
return rows
|
||||||
|
.GroupBy(r => r.InstanceId)
|
||||||
|
.Select(g => g.OrderByDescending(r => r.CreatedAtUtc).ThenByDescending(r => r.Id).First())
|
||||||
|
.ToDictionary(
|
||||||
|
r => r.InstanceId,
|
||||||
|
r => new PendingDeployment(
|
||||||
|
r.DeploymentId, r.InstanceId, r.RevisionHash, string.Empty, r.Token,
|
||||||
|
r.CreatedAtUtc, r.ExpiresAtUtc) { Id = r.Id });
|
||||||
|
}
|
||||||
|
|
||||||
// --- Instance lookups for deployment pipeline ---
|
// --- Instance lookups for deployment pipeline ---
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs on EVERY site node (NOT a singleton) so a standby that was DOWN during a deploy
|
/// Runs on EVERY site node (NOT a singleton) so a standby that was DOWN during a deploy
|
||||||
/// self-heals on its next restart. On startup the actor performs one best-effort
|
/// self-heals on its next restart. On startup the actor performs a best-effort
|
||||||
/// reconciliation pass:
|
/// reconciliation pass (retried a bounded number of times, see remarks):
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item>read the node's local deployed inventory from SQLite,</item>
|
/// <item>read the node's local deployed inventory from SQLite,</item>
|
||||||
/// <item>report it to central via the <c>SiteCommunicationActor</c> Ask
|
/// <item>report it to central via the <c>SiteCommunicationActor</c> Ask
|
||||||
@@ -23,10 +23,18 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Best-effort throughout.</b> A central-unreachable / timed-out Ask is caught, logged
|
/// <b>Best-effort throughout.</b> A central-unreachable / timed-out Ask is caught, logged
|
||||||
/// at Warning, and the pass simply ends — reconcile re-runs on the next node startup; it is a
|
/// at Warning, and the pass ends; it is a self-heal, not a critical path. A per-item
|
||||||
/// self-heal, not a critical path. A per-item fetch/write failure is caught and logged, then
|
/// fetch/write failure is caught and logged, then the remaining gap items continue (one bad
|
||||||
/// the remaining gap items continue (one bad item must not abort the rest). The actor never
|
/// item must not abort the rest). The actor never crashes on these failures.
|
||||||
/// crashes on these failures.
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Bounded retry.</b> A pass that did not complete (central unreachable, Ask timeout,
|
||||||
|
/// local read error) or completed with failed items is re-run, up to <c>maxAttempts</c>
|
||||||
|
/// passes in total, after an exponential back-off (<c>retryBaseDelay</c>, then 2x, 4x...).
|
||||||
|
/// Each retry re-reads the local inventory, so items an earlier pass healed are no longer in
|
||||||
|
/// the gap. Before this, a failed pass waited for the NEXT node restart — leaving exactly the
|
||||||
|
/// node this actor exists to heal unhealed. After the last attempt the actor logs and stops
|
||||||
|
/// trying until the next startup.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The pass runs after a small startup delay (so the site's central transport has time to
|
/// The pass runs after a small startup delay (so the site's central transport has time to
|
||||||
@@ -63,6 +71,12 @@ public sealed class SiteReconciliationActor : ReceiveActor, IWithTimers
|
|||||||
private readonly ILogger<SiteReconciliationActor> _logger;
|
private readonly ILogger<SiteReconciliationActor> _logger;
|
||||||
private readonly TimeSpan _initialDelay;
|
private readonly TimeSpan _initialDelay;
|
||||||
private readonly TimeSpan _askTimeout;
|
private readonly TimeSpan _askTimeout;
|
||||||
|
private readonly int _maxAttempts;
|
||||||
|
private readonly TimeSpan _retryBaseDelay;
|
||||||
|
|
||||||
|
// Number of passes started so far (1-based once the first pass runs). Only touched on
|
||||||
|
// the actor thread.
|
||||||
|
private int _attempt;
|
||||||
|
|
||||||
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
|
/// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
|
||||||
public ITimerScheduler Timers { get; set; } = null!;
|
public ITimerScheduler Timers { get; set; } = null!;
|
||||||
@@ -93,6 +107,13 @@ public sealed class SiteReconciliationActor : ReceiveActor, IWithTimers
|
|||||||
/// node's reconcile pass found the gap. Optional: when null the pass stores configs and
|
/// node's reconcile pass found the gap. Optional: when null the pass stores configs and
|
||||||
/// starts nothing, exactly as it did before.
|
/// starts nothing, exactly as it did before.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="maxAttempts">
|
||||||
|
/// Total passes allowed per actor lifetime when a pass fails or leaves failed items.
|
||||||
|
/// Defaults to 3; values below 1 are treated as 1 (no retry).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="retryBaseDelay">
|
||||||
|
/// Back-off before the first retry, doubled for each later one. Defaults to 30 seconds.
|
||||||
|
/// </param>
|
||||||
public SiteReconciliationActor(
|
public SiteReconciliationActor(
|
||||||
SiteStorageService storage,
|
SiteStorageService storage,
|
||||||
IDeploymentConfigFetcher configFetcher,
|
IDeploymentConfigFetcher configFetcher,
|
||||||
@@ -102,7 +123,9 @@ public sealed class SiteReconciliationActor : ReceiveActor, IWithTimers
|
|||||||
ILogger<SiteReconciliationActor> logger,
|
ILogger<SiteReconciliationActor> logger,
|
||||||
TimeSpan? initialDelay = null,
|
TimeSpan? initialDelay = null,
|
||||||
TimeSpan? askTimeout = null,
|
TimeSpan? askTimeout = null,
|
||||||
IActorRef? deploymentManager = null)
|
IActorRef? deploymentManager = null,
|
||||||
|
int maxAttempts = 3,
|
||||||
|
TimeSpan? retryBaseDelay = null)
|
||||||
{
|
{
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_configFetcher = configFetcher;
|
_configFetcher = configFetcher;
|
||||||
@@ -113,23 +136,33 @@ public sealed class SiteReconciliationActor : ReceiveActor, IWithTimers
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_initialDelay = initialDelay ?? TimeSpan.FromSeconds(5);
|
_initialDelay = initialDelay ?? TimeSpan.FromSeconds(5);
|
||||||
_askTimeout = askTimeout ?? TimeSpan.FromSeconds(30);
|
_askTimeout = askTimeout ?? TimeSpan.FromSeconds(30);
|
||||||
|
_maxAttempts = Math.Max(1, maxAttempts);
|
||||||
|
_retryBaseDelay = retryBaseDelay ?? TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
Receive<RunReconcile>(_ => RunReconcilePassAsync().PipeTo(Self));
|
Receive<RunReconcile>(_ =>
|
||||||
|
{
|
||||||
|
_attempt++;
|
||||||
|
RunReconcilePassAsync().PipeTo(Self);
|
||||||
|
});
|
||||||
Receive<ReconcilePassResult>(HandleReconcilePassResult);
|
Receive<ReconcilePassResult>(HandleReconcilePassResult);
|
||||||
|
|
||||||
// Defensive: RunReconcilePassAsync is designed never to throw (it returns a faulted
|
// Defensive: RunReconcilePassAsync is designed never to throw (it returns a faulted
|
||||||
// ReconcilePassResult instead), but if anything unexpected faults the piped Task the
|
// ReconcilePassResult instead), but if anything unexpected faults the piped Task the
|
||||||
// Status.Failure would otherwise go to dead letters silently. Log it instead.
|
// Status.Failure would otherwise go to dead letters silently. Log it instead.
|
||||||
Receive<Status.Failure>(f => _logger.LogWarning(f.Cause,
|
Receive<Status.Failure>(f =>
|
||||||
"Reconcile pass faulted unexpectedly for site {Site} node {Node}",
|
{
|
||||||
_siteIdentifier, _nodeId));
|
_logger.LogWarning(f.Cause,
|
||||||
|
"Reconcile pass faulted unexpectedly for site {Site} node {Node}",
|
||||||
|
_siteIdentifier, _nodeId);
|
||||||
|
ScheduleRetryIfAllowed();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void PreStart()
|
protected override void PreStart()
|
||||||
{
|
{
|
||||||
base.PreStart();
|
base.PreStart();
|
||||||
// One-shot pass after a small delay so the site's central transport can connect first.
|
// First pass after a small delay so the site's central transport can connect first.
|
||||||
// Non-blocking: the timer fires RunReconcile back onto this actor's mailbox.
|
// Non-blocking: the timer fires RunReconcile back onto this actor's mailbox.
|
||||||
Timers.StartSingleTimer(StartupTimerKey, RunReconcile.Instance, _initialDelay);
|
Timers.StartSingleTimer(StartupTimerKey, RunReconcile.Instance, _initialDelay);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -239,16 +272,24 @@ public sealed class SiteReconciliationActor : ReceiveActor, IWithTimers
|
|||||||
if (result.Error != null)
|
if (result.Error != null)
|
||||||
{
|
{
|
||||||
// Best-effort: a failed pass (central unreachable, Ask timeout, local read error) is
|
// Best-effort: a failed pass (central unreachable, Ask timeout, local read error) is
|
||||||
// logged at Warning and the actor stays alive. Reconcile re-runs on the next startup.
|
// logged at Warning and the actor stays alive; it is retried with back-off up to the
|
||||||
|
// attempt cap, then left to the next startup.
|
||||||
_logger.LogWarning(result.Error,
|
_logger.LogWarning(result.Error,
|
||||||
"Reconcile pass for site {Site} node {Node} did not complete (central unreachable or read error) — will retry on next startup",
|
"Reconcile pass {Attempt}/{MaxAttempts} for site {Site} node {Node} did not complete (central unreachable or read error)",
|
||||||
_siteIdentifier, _nodeId);
|
_attempt, _maxAttempts, _siteIdentifier, _nodeId);
|
||||||
|
ScheduleRetryIfAllowed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Reconcile pass for site {Site} node {Node} complete: {Fetched} fetched, {Failed} failed, {Orphans} orphan(s)",
|
"Reconcile pass {Attempt}/{MaxAttempts} for site {Site} node {Node} complete: {Fetched} fetched, {Failed} failed, {Orphans} orphan(s)",
|
||||||
_siteIdentifier, _nodeId, result.Fetched, result.Failed, result.Orphans);
|
_attempt, _maxAttempts, _siteIdentifier, _nodeId, result.Fetched, result.Failed, result.Orphans);
|
||||||
|
|
||||||
|
// Items that failed to fetch/store are still missing: retry the pass (the re-read
|
||||||
|
// inventory makes the gap only what is still missing). The healed instances below are
|
||||||
|
// started now regardless — a partial pass still healed them.
|
||||||
|
if (result.Failed > 0)
|
||||||
|
ScheduleRetryIfAllowed();
|
||||||
|
|
||||||
// Heal the RUNTIME, not just the file (row 67). Storing a config starts nothing: the
|
// Heal the RUNTIME, not just the file (row 67). Storing a config starts nothing: the
|
||||||
// Deployment Manager's boot-time SQLite load has already run by now, so without this
|
// Deployment Manager's boot-time SQLite load has already run by now, so without this
|
||||||
@@ -268,9 +309,30 @@ public sealed class SiteReconciliationActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedules the next reconcile pass after an exponential back-off, unless the attempt cap
|
||||||
|
/// is reached — in which case the actor logs and leaves the heal to the next node startup.
|
||||||
|
/// </summary>
|
||||||
|
private void ScheduleRetryIfAllowed()
|
||||||
|
{
|
||||||
|
if (_attempt >= _maxAttempts)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Reconcile for site {Site} node {Node} gave up after {Attempts} attempt(s) — will retry on next startup",
|
||||||
|
_siteIdentifier, _nodeId, _attempt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = TimeSpan.FromTicks(_retryBaseDelay.Ticks * (1L << Math.Min(_attempt - 1, 16)));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Reconcile for site {Site} node {Node}: retrying in {Delay} (attempt {Next}/{MaxAttempts})",
|
||||||
|
_siteIdentifier, _nodeId, delay, _attempt + 1, _maxAttempts);
|
||||||
|
Timers.StartSingleTimer(StartupTimerKey, RunReconcile.Instance, delay);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal messages ──
|
// ── Internal messages ──
|
||||||
|
|
||||||
/// <summary>Self-tick that drives the one-shot startup reconcile pass.</summary>
|
/// <summary>Self-tick that drives a startup reconcile pass (the first, or a retry).</summary>
|
||||||
private sealed class RunReconcile
|
private sealed class RunReconcile
|
||||||
{
|
{
|
||||||
public static readonly RunReconcile Instance = new();
|
public static readonly RunReconcile Instance = new();
|
||||||
|
|||||||
+11
-7
@@ -40,13 +40,17 @@ public class CentralCommunicationActorReconcileTests : TestKit
|
|||||||
{
|
{
|
||||||
new(2, "inst-B", "rev2", "dep-B", true),
|
new(2, "inst-B", "rev2", "dep-B", true),
|
||||||
});
|
});
|
||||||
deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(2, Arg.Any<CancellationToken>())
|
deploymentRepo.GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
.Returns(new DeployedConfigSnapshot("dep-B", "rev2", "{\"cfg\":\"B\"}"));
|
Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||||
deploymentRepo.StagePendingIfAbsentAsync(
|
.Returns(new Dictionary<int, DeployedConfigSnapshot>
|
||||||
Arg.Any<int>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
{
|
||||||
Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
[2] = new("dep-B", "rev2", "{\"cfg\":\"B\"}") { InstanceId = 2 },
|
||||||
Arg.Any<CancellationToken>())
|
});
|
||||||
.Returns(true);
|
// Every candidate is staged fresh: the batch hands each one back as its live row.
|
||||||
|
deploymentRepo.StagePendingBatchIfAbsentAsync(
|
||||||
|
Arg.Any<IReadOnlyList<PendingDeployment>>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => (IReadOnlyDictionary<int, PendingDeployment>)ci.ArgAt<IReadOnlyList<PendingDeployment>>(0)
|
||||||
|
.ToDictionary(p => p.InstanceId));
|
||||||
|
|
||||||
var options = Options.Create(new CommunicationOptions
|
var options = Options.Create(new CommunicationOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,12 +25,43 @@ public class ReconcileServiceTests
|
|||||||
Substitute.For<IDeploymentManagerRepository>();
|
Substitute.For<IDeploymentManagerRepository>();
|
||||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
|
||||||
private ReconcileService CreateService(TimeSpan? ttl = null)
|
// Fake repository state behind the two batched reconcile calls.
|
||||||
|
private readonly Dictionary<int, DeployedConfigSnapshot> _snapshots = new();
|
||||||
|
private readonly Dictionary<int, PendingDeployment> _existingLive = new();
|
||||||
|
private readonly HashSet<int> _racedAway = new();
|
||||||
|
|
||||||
|
/// <summary>Wires the batched snapshot read and batched stage to the fake state above.</summary>
|
||||||
|
public ReconcileServiceTests()
|
||||||
|
{
|
||||||
|
_deploymentRepo.GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
|
Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => (IReadOnlyDictionary<int, DeployedConfigSnapshot>)ci.ArgAt<IReadOnlyCollection<int>>(0)
|
||||||
|
.Where(_snapshots.ContainsKey)
|
||||||
|
.ToDictionary(id => id, id => _snapshots[id]));
|
||||||
|
|
||||||
|
_deploymentRepo.StagePendingBatchIfAbsentAsync(
|
||||||
|
Arg.Any<IReadOnlyList<PendingDeployment>>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci =>
|
||||||
|
{
|
||||||
|
var live = new Dictionary<int, PendingDeployment>();
|
||||||
|
foreach (var candidate in ci.ArgAt<IReadOnlyList<PendingDeployment>>(0))
|
||||||
|
{
|
||||||
|
if (_existingLive.TryGetValue(candidate.InstanceId, out var existing))
|
||||||
|
live[candidate.InstanceId] = existing;
|
||||||
|
else if (!_racedAway.Contains(candidate.InstanceId))
|
||||||
|
live[candidate.InstanceId] = candidate;
|
||||||
|
}
|
||||||
|
return (IReadOnlyDictionary<int, PendingDeployment>)live;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReconcileService CreateService(TimeSpan? ttl = null, TimeSpan? queryTimeout = null)
|
||||||
{
|
{
|
||||||
var options = Options.Create(new CommunicationOptions
|
var options = Options.Create(new CommunicationOptions
|
||||||
{
|
{
|
||||||
CentralFetchBaseUrl = BaseUrl,
|
CentralFetchBaseUrl = BaseUrl,
|
||||||
PendingDeploymentTtl = ttl ?? TimeSpan.FromMinutes(5),
|
PendingDeploymentTtl = ttl ?? TimeSpan.FromMinutes(5),
|
||||||
|
QueryTimeout = queryTimeout ?? TimeSpan.FromSeconds(30),
|
||||||
});
|
});
|
||||||
return new ReconcileService(
|
return new ReconcileService(
|
||||||
_deploymentRepo, _siteRepo, options, NullLogger<ReconcileService>.Instance);
|
_deploymentRepo, _siteRepo, options, NullLogger<ReconcileService>.Instance);
|
||||||
@@ -48,15 +79,11 @@ public class ReconcileServiceTests
|
|||||||
.Returns(expected.ToList());
|
.Returns(expected.ToList());
|
||||||
|
|
||||||
private void SnapshotFor(ExpectedDeployment exp) =>
|
private void SnapshotFor(ExpectedDeployment exp) =>
|
||||||
_deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(exp.InstanceId, Arg.Any<CancellationToken>())
|
_snapshots[exp.InstanceId] = new DeployedConfigSnapshot(
|
||||||
.Returns(new DeployedConfigSnapshot(exp.DeploymentId, exp.RevisionHash, $"{{\"cfg\":\"{exp.InstanceUniqueName}\"}}"));
|
exp.DeploymentId, exp.RevisionHash, $"{{\"cfg\":\"{exp.InstanceUniqueName}\"}}")
|
||||||
|
{
|
||||||
private void StageReturns(bool result) =>
|
InstanceId = exp.InstanceId
|
||||||
_deploymentRepo.StagePendingIfAbsentAsync(
|
};
|
||||||
Arg.Any<int>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
||||||
Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
|
||||||
Arg.Any<CancellationToken>())
|
|
||||||
.Returns(result);
|
|
||||||
|
|
||||||
private static ReconcileSiteRequest Request(params (string Name, string Rev)[] local) =>
|
private static ReconcileSiteRequest Request(params (string Name, string Rev)[] local) =>
|
||||||
new(SiteIdentifier, "node-a",
|
new(SiteIdentifier, "node-a",
|
||||||
@@ -74,7 +101,6 @@ public class ReconcileServiceTests
|
|||||||
ExpectedSet(a, b, c);
|
ExpectedSet(a, b, c);
|
||||||
SnapshotFor(b);
|
SnapshotFor(b);
|
||||||
SnapshotFor(c);
|
SnapshotFor(c);
|
||||||
StageReturns(true);
|
|
||||||
|
|
||||||
// Node has A current, B stale (revOLD), C missing entirely.
|
// Node has A current, B stale (revOLD), C missing entirely.
|
||||||
var response = await CreateService().ReconcileAsync(
|
var response = await CreateService().ReconcileAsync(
|
||||||
@@ -109,7 +135,6 @@ public class ReconcileServiceTests
|
|||||||
SiteResolves();
|
SiteResolves();
|
||||||
var a = Expected(1, "inst-A", "rev1", "dep-A");
|
var a = Expected(1, "inst-A", "rev1", "dep-A");
|
||||||
ExpectedSet(a);
|
ExpectedSet(a);
|
||||||
StageReturns(true);
|
|
||||||
|
|
||||||
// inst-A is current; inst-Z is not deployed centrally → orphan.
|
// inst-A is current; inst-Z is not deployed centrally → orphan.
|
||||||
var response = await CreateService().ReconcileAsync(
|
var response = await CreateService().ReconcileAsync(
|
||||||
@@ -120,10 +145,10 @@ public class ReconcileServiceTests
|
|||||||
Assert.Equal("inst-Z", orphan);
|
Assert.Equal("inst-Z", orphan);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that when staging a pending deployment returns false and a row already exists, the gap carries the existing token.</summary>
|
/// <summary>Verifies that when a live pending row already exists for a gap instance, the gap carries the existing token.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reconcile_StagePendingReturnsFalse_ExistingRowPresent_ReturnsExistingTokenInGap()
|
public async Task Reconcile_LiveRowAlreadyExists_ReturnsExistingTokenInGap()
|
||||||
{
|
{
|
||||||
// Live-found bug: two site nodes concurrently missing the SAME instance both reconcile at
|
// Live-found bug: two site nodes concurrently missing the SAME instance both reconcile at
|
||||||
// startup. The first stages a pending row; the second's StagePendingIfAbsent returns false.
|
// startup. The first stages a pending row; the second's StagePendingIfAbsent returns false.
|
||||||
@@ -138,19 +163,11 @@ public class ReconcileServiceTests
|
|||||||
|
|
||||||
// B stages fresh (true). C already has a pending row (a concurrent reconcile from the
|
// B stages fresh (true). C already has a pending row (a concurrent reconcile from the
|
||||||
// other node, or an in-flight deploy, staged it first) → stage returns false.
|
// other node, or an in-flight deploy, staged it first) → stage returns false.
|
||||||
StageReturns(true);
|
|
||||||
_deploymentRepo.StagePendingIfAbsentAsync(
|
|
||||||
3, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
||||||
Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
|
||||||
Arg.Any<CancellationToken>())
|
|
||||||
.Returns(false);
|
|
||||||
|
|
||||||
// The existing pending row for inst-C carries the FIRST node's deploymentId + token.
|
// The existing pending row for inst-C carries the FIRST node's deploymentId + token.
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
_deploymentRepo.GetPendingDeploymentByInstanceIdAsync(3, Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
_existingLive[3] = new PendingDeployment(
|
||||||
.Returns(new PendingDeployment(
|
"dep-C-existing", 3, "rev3-existing", string.Empty,
|
||||||
"dep-C-existing", 3, "rev3-existing", "{\"cfg\":\"inst-C\"}",
|
"tok-C-existing", nowUtc, nowUtc.AddMinutes(5));
|
||||||
"tok-C-existing", nowUtc, nowUtc.AddMinutes(5)));
|
|
||||||
|
|
||||||
var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */));
|
var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */));
|
||||||
|
|
||||||
@@ -168,10 +185,10 @@ public class ReconcileServiceTests
|
|||||||
Assert.True(gapC.IsEnabled);
|
Assert.True(gapC.IsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that when staging a pending deployment returns false and no row exists, that gap item is omitted.</summary>
|
/// <summary>Verifies that when no pending row can be staged or found, that gap item is omitted.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reconcile_StagePendingReturnsFalse_NoExistingRow_OmitsThatGapItem()
|
public async Task Reconcile_NoLiveRowStagedOrFound_OmitsThatGapItem()
|
||||||
{
|
{
|
||||||
// Fallback path: stage returns false, but the pending row raced away (was purged between
|
// Fallback path: stage returns false, but the pending row raced away (was purged between
|
||||||
// the stage attempt and the read). Omit the item — the node retries on the next reconcile.
|
// the stage attempt and the read). Omit the item — the node retries on the next reconcile.
|
||||||
@@ -182,16 +199,8 @@ public class ReconcileServiceTests
|
|||||||
SnapshotFor(b);
|
SnapshotFor(b);
|
||||||
SnapshotFor(c);
|
SnapshotFor(c);
|
||||||
|
|
||||||
StageReturns(true);
|
// No row could be staged or found for inst-C (raced away).
|
||||||
_deploymentRepo.StagePendingIfAbsentAsync(
|
_racedAway.Add(3);
|
||||||
3, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
||||||
Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
|
||||||
Arg.Any<CancellationToken>())
|
|
||||||
.Returns(false);
|
|
||||||
|
|
||||||
// The pending row was purged between the stage attempt and the read.
|
|
||||||
_deploymentRepo.GetPendingDeploymentByInstanceIdAsync(3, Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns((PendingDeployment?)null);
|
|
||||||
|
|
||||||
var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */));
|
var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */));
|
||||||
|
|
||||||
@@ -209,7 +218,6 @@ public class ReconcileServiceTests
|
|||||||
var b = Expected(2, "inst-B", "rev2", "dep-B");
|
var b = Expected(2, "inst-B", "rev2", "dep-B");
|
||||||
ExpectedSet(b);
|
ExpectedSet(b);
|
||||||
// No snapshot configured for inst-B → repo returns null (deleted race).
|
// No snapshot configured for inst-B → repo returns null (deleted race).
|
||||||
StageReturns(true);
|
|
||||||
|
|
||||||
var response = await CreateService().ReconcileAsync(Request());
|
var response = await CreateService().ReconcileAsync(Request());
|
||||||
|
|
||||||
@@ -234,4 +242,79 @@ public class ReconcileServiceTests
|
|||||||
await _deploymentRepo.DidNotReceive()
|
await _deploymentRepo.DidNotReceive()
|
||||||
.GetExpectedDeploymentsForSiteAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
.GetExpectedDeploymentsForSiteAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── review 2026-10-21 03/P-1: batched reconcile + bounded work ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reconcile pass is a fixed number of round trips: snapshot JSON is read ONCE, only
|
||||||
|
/// for the gap, and the pending rows are staged in ONE batch — never per gap instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_ReadsGapSnapshotsOnce_AndStagesInOneBatch()
|
||||||
|
{
|
||||||
|
SiteResolves();
|
||||||
|
var a = Expected(1, "inst-A", "rev1", "dep-A");
|
||||||
|
var b = Expected(2, "inst-B", "rev2", "dep-B");
|
||||||
|
var c = Expected(3, "inst-C", "rev3", "dep-C");
|
||||||
|
ExpectedSet(a, b, c);
|
||||||
|
SnapshotFor(a);
|
||||||
|
SnapshotFor(b);
|
||||||
|
SnapshotFor(c);
|
||||||
|
|
||||||
|
var response = await CreateService().ReconcileAsync(Request(("inst-A", "rev1")));
|
||||||
|
|
||||||
|
Assert.Equal(2, response.Gap.Count);
|
||||||
|
await _deploymentRepo.Received(1).GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
|
Arg.Is<IReadOnlyCollection<int>>(ids => ids.Order().SequenceEqual(new[] { 2, 3 })),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
await _deploymentRepo.Received(1).StagePendingBatchIfAbsentAsync(
|
||||||
|
Arg.Is<IReadOnlyList<PendingDeployment>>(rows => rows.Count == 2),
|
||||||
|
Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||||
|
await _deploymentRepo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(
|
||||||
|
Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||||
|
await _deploymentRepo.DidNotReceive().StagePendingIfAbsentAsync(
|
||||||
|
Arg.Any<int>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A node with nothing missing costs no snapshot read and no staging at all.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_NoGap_DoesNoSnapshotReadOrStaging()
|
||||||
|
{
|
||||||
|
SiteResolves();
|
||||||
|
ExpectedSet(Expected(1, "inst-A", "rev1", "dep-A"));
|
||||||
|
|
||||||
|
var response = await CreateService().ReconcileAsync(Request(("inst-A", "rev1")));
|
||||||
|
|
||||||
|
Assert.Empty(response.Gap);
|
||||||
|
await _deploymentRepo.DidNotReceive().GetDeployedSnapshotsByInstanceIdsAsync(
|
||||||
|
Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>());
|
||||||
|
await _deploymentRepo.DidNotReceive().StagePendingBatchIfAbsentAsync(
|
||||||
|
Arg.Any<IReadOnlyList<PendingDeployment>>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central stops working once the site's own deadline (QueryTimeout) has passed instead of
|
||||||
|
/// staging tokens for a caller that has already given up.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_WorkIsBoundedByQueryTimeout()
|
||||||
|
{
|
||||||
|
SiteResolves();
|
||||||
|
_deploymentRepo.GetExpectedDeploymentsForSiteAsync(SiteId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(async ci =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30), ci.ArgAt<CancellationToken>(1));
|
||||||
|
return (IReadOnlyList<ExpectedDeployment>)new List<ExpectedDeployment>();
|
||||||
|
});
|
||||||
|
|
||||||
|
var service = CreateService(queryTimeout: TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => service.ReconcileAsync(Request()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,4 +203,100 @@ public class ReconcileRepositoryTests : IDisposable
|
|||||||
// The reconcile row must not exist
|
// The reconcile row must not exist
|
||||||
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-reconcile"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-2
@@ -53,7 +53,9 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
|||||||
IActorRef siteCommunicationActor,
|
IActorRef siteCommunicationActor,
|
||||||
IDeploymentConfigFetcher fetcher,
|
IDeploymentConfigFetcher fetcher,
|
||||||
ILogger<SiteReconciliationActor>? logger = null,
|
ILogger<SiteReconciliationActor>? logger = null,
|
||||||
TimeSpan? askTimeout = null) =>
|
TimeSpan? askTimeout = null,
|
||||||
|
int maxAttempts = 3,
|
||||||
|
TimeSpan? retryBaseDelay = null) =>
|
||||||
ActorOf(Props.Create(() => new SiteReconciliationActor(
|
ActorOf(Props.Create(() => new SiteReconciliationActor(
|
||||||
_storage,
|
_storage,
|
||||||
fetcher,
|
fetcher,
|
||||||
@@ -62,7 +64,10 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
|||||||
NodeId,
|
NodeId,
|
||||||
logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteReconciliationActor>.Instance,
|
logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteReconciliationActor>.Instance,
|
||||||
TimeSpan.FromMilliseconds(50), // initial delay — fast for tests
|
TimeSpan.FromMilliseconds(50), // initial delay — fast for tests
|
||||||
askTimeout ?? TimeSpan.FromSeconds(5)))); // ask timeout
|
askTimeout ?? TimeSpan.FromSeconds(5), // ask timeout
|
||||||
|
null,
|
||||||
|
maxAttempts,
|
||||||
|
retryBaseDelay)));
|
||||||
|
|
||||||
/// <summary>Verifies that an instance missing at the site is fetched from central and stored.</summary>
|
/// <summary>Verifies that an instance missing at the site is fetched from central and stored.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
@@ -296,6 +301,68 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
|||||||
Assert.Empty(fetcher.Calls);
|
Assert.Empty(fetcher.Calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// review 2026-10-21 03/P-1: a pass whose Ask to central fails is retried with back-off up
|
||||||
|
/// to the attempt cap — not left until the next node restart — and then stops.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CentralAskFails_IsRetried_UpToTheAttemptCap()
|
||||||
|
{
|
||||||
|
var fetcher = new FakeConfigFetcher(_ => Task.FromResult("never"));
|
||||||
|
var commProbe = CreateTestProbe();
|
||||||
|
|
||||||
|
CreateReconciliationActor(
|
||||||
|
commProbe, fetcher,
|
||||||
|
askTimeout: TimeSpan.FromMilliseconds(200),
|
||||||
|
maxAttempts: 3,
|
||||||
|
retryBaseDelay: TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
// Never replied to → three passes (1 + 2 retries), then no more.
|
||||||
|
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
commProbe.ExpectNoMsg(TimeSpan.FromSeconds(1.5));
|
||||||
|
Assert.Empty(fetcher.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// review 2026-10-21 03/P-1: a pass that completes with a failed item is retried, and the
|
||||||
|
/// retry heals the item.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task FailedItem_IsRetried_AndHealsOnTheNextPass()
|
||||||
|
{
|
||||||
|
const string config = "{\"instanceUniqueName\":\"Flaky\"}";
|
||||||
|
var calls = 0;
|
||||||
|
var fetcher = new FakeConfigFetcher(_ => Interlocked.Increment(ref calls) == 1
|
||||||
|
? Task.FromException<string>(new DeploymentConfigFetchException("transient", isSuperseded: false))
|
||||||
|
: Task.FromResult(config));
|
||||||
|
var commProbe = CreateTestProbe();
|
||||||
|
|
||||||
|
CreateReconciliationActor(
|
||||||
|
commProbe, fetcher, maxAttempts: 3, retryBaseDelay: TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
var gap = new ReconcileSiteResponse(
|
||||||
|
[new ReconcileGapItem("Flaky", "depF", "revF", true, "tok-f")], [], "http://central:9000");
|
||||||
|
|
||||||
|
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
commProbe.Reply(gap);
|
||||||
|
|
||||||
|
var retry = commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.False(retry.LocalNameToRevisionHash.ContainsKey("Flaky"));
|
||||||
|
commProbe.Reply(gap);
|
||||||
|
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
var configs = await _storage.GetAllDeployedConfigsAsync();
|
||||||
|
Assert.Single(configs, c => c.InstanceUniqueName == "Flaky");
|
||||||
|
}, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Healed on pass 2 → no third pass.
|
||||||
|
commProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(600));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-test fake <see cref="IDeploymentConfigFetcher"/>: runs a per-deploymentId behavior
|
/// In-test fake <see cref="IDeploymentConfigFetcher"/>: runs a per-deploymentId behavior
|
||||||
/// (return config JSON or throw, as a Task) and records every call's coords thread-safely
|
/// (return config JSON or throw, as a Task) and records every call's coords thread-safely
|
||||||
|
|||||||
Reference in New Issue
Block a user