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:
+6
@@ -207,6 +207,12 @@ public interface IDeploymentManagerRepository
|
|||||||
/// InstanceId → RevisionHash for every <c>DeployedConfigSnapshot</c>. A projection read — never
|
/// 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
|
/// materialises <c>ConfigurationJson</c>, so it is safe to call for the whole fleet (the
|
||||||
/// pending-updates aggregate does, once per cache TTL).
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
/// <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>
|
/// <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
|
// older schema) still yields the frozen-hash staleness result, with a
|
||||||
// null diff.
|
// null diff.
|
||||||
var deployedRevisionHash = ComputeDeployedHash(snapshot, instanceId, out var deployedConfig);
|
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
|
var diff = deployedConfig == null
|
||||||
? null
|
? null
|
||||||
: _diffService.ComputeDiff(
|
: _diffService.ComputeDiff(
|
||||||
@@ -1832,6 +1837,16 @@ public class DeploymentService
|
|||||||
var deployedHashes = await _repository.GetDeployedRevisionHashesAsync(cancellationToken);
|
var deployedHashes = await _repository.GetDeployedRevisionHashesAsync(cancellationToken);
|
||||||
var session = _flatteningPipeline.CreateSession();
|
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())
|
foreach (var instanceId in instanceIds.Distinct())
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
@@ -1839,15 +1854,24 @@ public class DeploymentService
|
|||||||
if (!deployedHashes.TryGetValue(instanceId, out var deployedHash))
|
if (!deployedHashes.TryGetValue(instanceId, out var deployedHash))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
evaluated++;
|
||||||
|
|
||||||
// Read-only path: hash only, so skip the Roslyn compile — the deploy
|
// Read-only path: hash only, so skip the Roslyn compile — the deploy
|
||||||
// gate remains the authoritative compile.
|
// gate remains the authoritative compile.
|
||||||
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
|
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
|
||||||
instanceId, cancellationToken, validateScripts: false, session);
|
instanceId, cancellationToken, validateScripts: false, session);
|
||||||
if (currentResult.IsFailure)
|
if (currentResult.IsFailure)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogDebug(
|
||||||
"Could not evaluate staleness of instance {InstanceId}: {Error}",
|
"Could not evaluate staleness of instance {InstanceId}: {Error}",
|
||||||
instanceId, currentResult.Error);
|
instanceId, currentResult.Error);
|
||||||
|
if (failedCount == 0)
|
||||||
|
{
|
||||||
|
firstFailureInstanceId = instanceId;
|
||||||
|
firstFailureError = currentResult.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
failedCount++;
|
||||||
staleness[instanceId] = null;
|
staleness[instanceId] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1870,6 +1894,14 @@ public class DeploymentService
|
|||||||
!string.Equals(normalizedDeployedHash, currentHash, StringComparison.Ordinal);
|
!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;
|
return staleness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-1
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -38,6 +39,9 @@ public class DeploymentServiceStalenessTests
|
|||||||
/// <summary>The single session every flatten in a batch must share.</summary>
|
/// <summary>The single session every flatten in a batch must share.</summary>
|
||||||
private readonly FlattenSession _session = new(new TemplateGraphWatermark());
|
private readonly FlattenSession _session = new(new TemplateGraphWatermark());
|
||||||
|
|
||||||
|
/// <summary>Captures the service's log entries so the per-batch Warning can be asserted.</summary>
|
||||||
|
private readonly CapturingLogger _logger = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the service over substituted repository and pipeline seams; every
|
/// Builds the service over substituted repository and pipeline seams; every
|
||||||
/// other dependency is the real concrete collaborator (as elsewhere in this
|
/// other dependency is the real concrete collaborator (as elsewhere in this
|
||||||
@@ -73,7 +77,34 @@ public class DeploymentServiceStalenessTests
|
|||||||
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
||||||
}),
|
}),
|
||||||
Options.Create(new CommunicationOptions()),
|
Options.Create(new CommunicationOptions()),
|
||||||
NullLogger<DeploymentService>.Instance);
|
_logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures formatted log entries so the batch's single failure Warning can be
|
||||||
|
/// asserted (mirrors the CapturingLogger used elsewhere in the test suite).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingLogger : ILogger<DeploymentService>
|
||||||
|
{
|
||||||
|
/// <summary>Every entry logged, in order.</summary>
|
||||||
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Entries.Add((logLevel, formatter(state, exception)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds a one-attribute flattened config with the given scalar value.</summary>
|
/// <summary>Builds a one-attribute flattened config with the given scalar value.</summary>
|
||||||
@@ -215,6 +246,47 @@ public class DeploymentServiceStalenessTests
|
|||||||
Assert.Null(result[1]);
|
Assert.Null(result[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetInstanceStalenessAsync_ManyFlattenFailures_LogsExactlyOneWarning()
|
||||||
|
{
|
||||||
|
// The batch runs for the whole fleet on a cache-TTL cadence, so one broken
|
||||||
|
// shared template fanning out to N instances must NOT produce N Warning
|
||||||
|
// lines per minute: the per-instance detail drops to Debug and the batch
|
||||||
|
// emits a single summary Warning naming the first failure.
|
||||||
|
ArrangeDeployedHashes((1, "sha256:deployed"), (2, "sha256:deployed"));
|
||||||
|
foreach (var id in new[] { 1, 2 })
|
||||||
|
{
|
||||||
|
_pipeline.FlattenAndValidateAsync(
|
||||||
|
id, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
|
||||||
|
.Returns(Result<FlatteningPipelineResult>.Failure($"broken template {id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _service.GetInstanceStalenessAsync([1, 2]);
|
||||||
|
|
||||||
|
Assert.Null(result[1]);
|
||||||
|
Assert.Null(result[2]);
|
||||||
|
|
||||||
|
var warnings = _logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||||
|
var warning = Assert.Single(warnings);
|
||||||
|
Assert.Contains("2 of 2", warning.Message);
|
||||||
|
Assert.Contains("broken template 1", warning.Message);
|
||||||
|
// Both per-instance details are still available, one level down.
|
||||||
|
Assert.Equal(2, _logger.Entries.Count(e => e.Level == LogLevel.Debug));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetInstanceStalenessAsync_NoFailures_LogsNoWarning()
|
||||||
|
{
|
||||||
|
var config = ScalarConfig("5");
|
||||||
|
var hash = _hasher.ComputeHash(config);
|
||||||
|
ArrangeDeployedHashes((1, hash));
|
||||||
|
ArrangeFlatten(1, config, hash);
|
||||||
|
|
||||||
|
await _service.GetInstanceStalenessAsync([1]);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(_logger.Entries, e => e.Level == LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetInstanceStalenessAsync_InstanceWithoutSnapshot_IsOmitted()
|
public async Task GetInstanceStalenessAsync_InstanceWithoutSnapshot_IsOmitted()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user