fix(deploy): staleness batch logs one Warning per batch, not one per instance

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 13:48:06 -04:00
parent 5214932447
commit 3b55d60ef9
3 changed files with 112 additions and 2 deletions
@@ -207,6 +207,12 @@ public interface IDeploymentManagerRepository
/// InstanceId → RevisionHash for every <c>DeployedConfigSnapshot</c>. A projection read — never
/// materialises <c>ConfigurationJson</c>, so it is safe to call for the whole fleet (the
/// pending-updates aggregate does, once per cache TTL).
///
/// <para>
/// Deliberately UNFILTERED: it always returns the whole fleet's two-column projection, with no
/// site, state or scope narrowing. A site-scoped or otherwise partial caller must filter the
/// returned map itself rather than assume the read was narrowed for it.
/// </para>
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A dictionary keyed by instance ID; instances with no snapshot are absent.</returns>
@@ -1708,6 +1708,11 @@ public class DeploymentService
// older schema) still yields the frozen-hash staleness result, with a
// null diff.
var deployedRevisionHash = ComputeDeployedHash(snapshot, instanceId, out var deployedConfig);
// Deliberately outside ComputeDeployedHash's JsonException catch: DiffService
// compares already-deserialized POCOs and does no JSON work of its own, so it
// cannot raise the exception that catch exists for. A DiffService that ever
// starts parsing JSON must revisit this.
var diff = deployedConfig == null
? null
: _diffService.ComputeDiff(
@@ -1832,6 +1837,16 @@ public class DeploymentService
var deployedHashes = await _repository.GetDeployedRevisionHashesAsync(cancellationToken);
var session = _flatteningPipeline.CreateSession();
// Failures are summarised into ONE Warning at the end rather than logged
// per instance: this runs for the whole fleet on a cache-TTL cadence, so a
// single broken shared template fanning out to N instances would otherwise
// emit N Warning lines every minute, forever. The per-instance detail stays
// at Debug for whoever is actually diagnosing it.
var evaluated = 0;
var failedCount = 0;
var firstFailureInstanceId = 0;
string? firstFailureError = null;
foreach (var instanceId in instanceIds.Distinct())
{
cancellationToken.ThrowIfCancellationRequested();
@@ -1839,15 +1854,24 @@ public class DeploymentService
if (!deployedHashes.TryGetValue(instanceId, out var deployedHash))
continue;
evaluated++;
// Read-only path: hash only, so skip the Roslyn compile — the deploy
// gate remains the authoritative compile.
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: false, session);
if (currentResult.IsFailure)
{
_logger.LogWarning(
_logger.LogDebug(
"Could not evaluate staleness of instance {InstanceId}: {Error}",
instanceId, currentResult.Error);
if (failedCount == 0)
{
firstFailureInstanceId = instanceId;
firstFailureError = currentResult.Error;
}
failedCount++;
staleness[instanceId] = null;
continue;
}
@@ -1870,6 +1894,14 @@ public class DeploymentService
!string.Equals(normalizedDeployedHash, currentHash, StringComparison.Ordinal);
}
if (failedCount > 0)
{
_logger.LogWarning(
"Could not evaluate staleness of {FailedCount} of {EvaluatedCount} instances; " +
"first failure: instance {InstanceId}: {Error}",
failedCount, evaluated, firstFailureInstanceId, firstFailureError);
}
return staleness;
}