fix: preserve endurance failures and validate milestone reports
This commit is contained in:
@@ -60,8 +60,8 @@ public static class WechatStabilityAnalyzer
|
||||
if (memoryGrowth > 256L * 1024 * 1024) findings.Add("working-set-growth-over-256mb");
|
||||
if (handleGrowth > 500) findings.Add("handle-growth-over-500");
|
||||
if (threadGrowth > 100) findings.Add("thread-growth-over-100");
|
||||
if (samples.Any(sample => sample.LastSnapshotAt is { } lastSnapshot
|
||||
&& sample.Timestamp - lastSnapshot > TimeSpan.FromMinutes(5))) findings.Add("listener-snapshot-gap-over-5min");
|
||||
if (samples.Any(sample => sample.Timestamp - (sample.LastSnapshotAt ?? startedAt) > TimeSpan.FromMinutes(5)))
|
||||
findings.Add("listener-snapshot-gap-over-5min");
|
||||
|
||||
return new WechatStabilityReport(
|
||||
startedAt,
|
||||
@@ -76,10 +76,31 @@ public static class WechatStabilityAnalyzer
|
||||
findings);
|
||||
}
|
||||
|
||||
public static WechatStabilityReport CheckRun(
|
||||
WechatStabilityReport report, TimeSpan requested, TimeSpan observed, bool cancelled,
|
||||
string? listenerFailure = null, string? samplingFailure = null)
|
||||
{
|
||||
var findings = report.Findings.ToList();
|
||||
var completed = observed + TimeSpan.FromSeconds(1) >= requested && !cancelled;
|
||||
if (!completed) findings.Add("duration-incomplete");
|
||||
if (listenerFailure is not null) findings.Add("listener-failed:" + listenerFailure);
|
||||
if (samplingFailure is not null) findings.Add("sampling-failed:" + samplingFailure);
|
||||
if (report.Samples[^1].LastSnapshotAt is not { } snapshot
|
||||
|| report.FinishedAt - snapshot > TimeSpan.FromSeconds(10)) findings.Add("listener-snapshot-stale");
|
||||
return report with { Healthy = findings.Count == 0, Findings = findings, RequestedSeconds = requested.TotalSeconds,
|
||||
ObservedSeconds = observed.TotalSeconds, CompletedDuration = completed };
|
||||
}
|
||||
|
||||
public static async Task WriteJsonAsync(WechatStabilityReport report, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
|
||||
var temporary = fullPath + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
|
||||
File.Move(temporary, fullPath, overwrite: true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class WechatStabilityRunner
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "duration must be positive.");
|
||||
if (sampleInterval < TimeSpan.FromSeconds(1) || sampleInterval > duration || duration / sampleInterval > 10_000)
|
||||
if (sampleInterval < TimeSpan.FromSeconds(1) || sampleInterval > duration || Math.Ceiling(duration / sampleInterval) + 1 > 10_000)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "sampleInterval must be at least one second, no longer than duration, and produce at most 10000 samples.");
|
||||
|
||||
var directory = Path.GetFullPath(outputDirectory);
|
||||
@@ -63,6 +63,11 @@ public static class WechatStabilityRunner
|
||||
}, CancellationToken.None);
|
||||
|
||||
var samples = new List<WechatStabilitySample>();
|
||||
WechatStabilityReport BuildReport(TimeSpan requested) => WechatStabilityAnalyzer.CheckRun(
|
||||
WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples.ToArray(),
|
||||
Interlocked.Read(ref messageEvents), Interlocked.Read(ref reconnectEvents)),
|
||||
requested, elapsed.Elapsed, cancellationToken.IsCancellationRequested,
|
||||
Volatile.Read(ref listenerError)?.GetType().Name, samplingError?.GetType().Name);
|
||||
try
|
||||
{
|
||||
while (!linked.IsCancellationRequested)
|
||||
@@ -71,15 +76,14 @@ public static class WechatStabilityRunner
|
||||
Volatile.Read(ref listenerError), Interlocked.Read(ref snapshotTicks));
|
||||
samples.Add(sample);
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(sample)).ConfigureAwait(false);
|
||||
if (listener.IsCompleted && elapsed.Elapsed + TimeSpan.FromSeconds(1) < duration)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener stopped before endurance duration elapsed.");
|
||||
foreach (var hours in new[] { 8, 24, 72 })
|
||||
{
|
||||
if (elapsed.Elapsed < TimeSpan.FromHours(hours) || !milestones.Add(hours)) continue;
|
||||
var milestone = WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples.ToArray(), messageEvents, reconnectEvents)
|
||||
with { RequestedSeconds = hours * 3600, ObservedSeconds = elapsed.Elapsed.TotalSeconds, CompletedDuration = true };
|
||||
var milestone = BuildReport(TimeSpan.FromHours(hours));
|
||||
await WechatStabilityAnalyzer.WriteJsonAsync(milestone, Path.Combine(directory, $"report-{hours}h.json"), linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
if (listener.IsCompleted && elapsed.Elapsed + TimeSpan.FromSeconds(1) < duration)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener stopped before endurance duration elapsed.");
|
||||
await Task.Delay(sampleInterval, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -91,20 +95,25 @@ public static class WechatStabilityRunner
|
||||
await listener.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
samples.Add(CaptureSample(messageEvents, reconnectEvents, listenerError, snapshotTicks));
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(samples[^1])).ConfigureAwait(false);
|
||||
var report = WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples, messageEvents, reconnectEvents);
|
||||
var findings = report.Findings.ToList();
|
||||
var completed = elapsed.Elapsed + TimeSpan.FromSeconds(1) >= duration && !cancellationToken.IsCancellationRequested;
|
||||
if (!completed) findings.Add("duration-incomplete");
|
||||
if (listenerError is not null) findings.Add("listener-failed:" + listenerError.GetType().Name);
|
||||
if (samplingError is not null) findings.Add("sampling-failed:" + samplingError.GetType().Name);
|
||||
if (samples[^1].LastSnapshotAt is null || DateTimeOffset.UtcNow - samples[^1].LastSnapshotAt > TimeSpan.FromSeconds(10))
|
||||
findings.Add("listener-snapshot-stale");
|
||||
report = report with { Healthy = findings.Count == 0, Findings = findings, RequestedSeconds = duration.TotalSeconds,
|
||||
ObservedSeconds = elapsed.Elapsed.TotalSeconds, CompletedDuration = completed };
|
||||
try { samples.Add(CaptureSample(messageEvents, reconnectEvents, listenerError, snapshotTicks)); }
|
||||
catch (Exception exception)
|
||||
{
|
||||
samplingError ??= exception;
|
||||
// Keep the failure explicit, even if the first and final probes both fail.
|
||||
samples.Add(new WechatStabilitySample(DateTimeOffset.UtcNow, 0, 0, 0, 0, false,
|
||||
[WxAgentErrorCode.InvalidOperationState], messageEvents, reconnectEvents));
|
||||
}
|
||||
try { await writer.WriteLineAsync(JsonSerializer.Serialize(samples[^1])).ConfigureAwait(false); }
|
||||
catch (Exception exception) { samplingError ??= exception; }
|
||||
var report = BuildReport(duration);
|
||||
using var reportTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await WechatStabilityAnalyzer.WriteJsonAsync(report, Path.Combine(directory, "report.json"), reportTimeout.Token).ConfigureAwait(false);
|
||||
foreach (var hours in new[] { 8, 24, 72 })
|
||||
{
|
||||
if (elapsed.Elapsed < TimeSpan.FromHours(hours) || !milestones.Add(hours)) continue;
|
||||
await WechatStabilityAnalyzer.WriteJsonAsync(BuildReport(TimeSpan.FromHours(hours)),
|
||||
Path.Combine(directory, $"report-{hours}h.json"), reportTimeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,66 @@ public sealed class WechatStabilityTests
|
||||
Assert.Contains("thread-growth-over-100", report.Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, false, 0, true)]
|
||||
[InlineData(15, false, 0, false)]
|
||||
[InlineData(30, true, 0, false)]
|
||||
[InlineData(30, false, 11, false)]
|
||||
public void RunChecks_ApplyDurationCancellationAndHeartbeatToEveryReport(int seconds, bool cancelled, int snapshotAge, bool healthy)
|
||||
{
|
||||
var finish = DateTimeOffset.UtcNow;
|
||||
var last = Sample(finish, 100, 10, 1) with { LastSnapshotAt = finish.AddSeconds(-snapshotAge) };
|
||||
var report = WechatStabilityAnalyzer.Analyze(finish.AddSeconds(-seconds), finish, [last], 0, 0);
|
||||
var checkedReport = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(seconds), cancelled);
|
||||
Assert.Equal(healthy, checkedReport.Healthy);
|
||||
Assert.Equal(seconds >= 29 && !cancelled, checkedReport.CompletedDuration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunChecks_RejectMissingHeartbeatAndBackgroundFailures()
|
||||
{
|
||||
var finish = DateTimeOffset.UtcNow;
|
||||
var report = WechatStabilityAnalyzer.Analyze(finish.AddHours(-8), finish, [Sample(finish, 0, 0, 0)], 0, 0);
|
||||
report = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromHours(8), TimeSpan.FromHours(8), false,
|
||||
"InvalidOperationException", "IOException");
|
||||
Assert.False(report.Healthy);
|
||||
Assert.Contains("listener-snapshot-stale", report.Findings);
|
||||
Assert.Contains("listener-failed:InvalidOperationException", report.Findings);
|
||||
Assert.Contains("sampling-failed:IOException", report.Findings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StabilityAnalyzer_DoesNotHideMissingHeartbeatBeforeRecovery()
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var missing = Sample(started.AddMinutes(6), 100, 10, 1);
|
||||
var recovered = missing with { Timestamp = started.AddMinutes(7), LastSnapshotAt = started.AddMinutes(7) };
|
||||
var report = WechatStabilityAnalyzer.Analyze(started, recovered.Timestamp, [missing, recovered], 0, 1);
|
||||
Assert.Contains("listener-snapshot-gap-over-5min", report.Findings);
|
||||
Assert.False(report.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportWrite_PreservesExistingEvidenceWhenCancelled()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "wx-stability-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(directory, "report.json");
|
||||
await File.WriteAllTextAsync(path, "previous evidence");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var report = WechatStabilityAnalyzer.Analyze(now, now, [Sample(now, 0, 0, 0)], 0, 0);
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
WechatStabilityAnalyzer.WriteJsonAsync(report, path, cancelled.Token));
|
||||
Assert.Equal("previous evidence", await File.ReadAllTextAsync(path));
|
||||
Assert.False(File.Exists(path + ".tmp"));
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
}
|
||||
|
||||
private static WechatStabilitySample Sample(
|
||||
DateTimeOffset timestamp,
|
||||
long memory,
|
||||
|
||||
Reference in New Issue
Block a user