feat(audit-log): surface partition-purge failure as health event + counter
This commit is contained in:
@@ -390,9 +390,25 @@ MS SQL for direct-write events). Unredacted secrets never persist.
|
|||||||
- **Purge actor:** `AuditLogPurgeActor` singleton on the active central node
|
- **Purge actor:** `AuditLogPurgeActor` singleton on the active central node
|
||||||
runs daily, switches out any partition whose latest `OccurredAtUtc` is older
|
runs daily, switches out any partition whose latest `OccurredAtUtc` is older
|
||||||
than the retention window, then applies any per-channel overrides (see below),
|
than the retention window, then applies any per-channel overrides (see below),
|
||||||
and emits an `AuditLog:Purged` event (partition range, rowcount, duration) per
|
and emits an `AuditLogPurgedEvent` (partition range, rowcount, duration) per
|
||||||
switched partition. A partition-maintenance step rolls forward each month,
|
switched partition. A partition-maintenance step rolls forward each month,
|
||||||
creating the next month's partition ahead of time.
|
creating the next month's partition ahead of time.
|
||||||
|
- **Purge failure is a health metric, not just a log line:** every purge step that
|
||||||
|
throws — a partition switch-out, a per-channel override DELETE, or the pre-purge
|
||||||
|
boundary enumeration — publishes an `AuditLogPurgeFailedEvent` (month boundary,
|
||||||
|
error message, elapsed ms; `DateTime.MinValue` boundary for the enumeration/channel
|
||||||
|
phases that have no single month in hand) and increments the `PurgeFailures` central
|
||||||
|
health counter on `AuditCentralHealthSnapshot` (alongside `CentralAuditWriteFailures`).
|
||||||
|
This is the symmetric failure twin of the success-path `AuditLogPurgedEvent`: a
|
||||||
|
silently failing retention job would otherwise be invisible until the table grew
|
||||||
|
unbounded. Per-boundary/per-channel error isolation is unchanged — a single failure
|
||||||
|
still never abandons the rest of the tick.
|
||||||
|
- **Maintenance command timeout:** the switch-out drop-and-rebuild dance and each
|
||||||
|
per-channel `DELETE TOP` batch run with an explicit command timeout
|
||||||
|
(`AuditLog:Purge:MaintenanceCommandTimeoutMinutes`, default 30, floor 1 min) rather
|
||||||
|
than the ~30 s ADO.NET default, which could abort the metadata-only SWITCH mid-dance
|
||||||
|
on a large or contended partition and leave the live table without
|
||||||
|
`UX_AuditLog_EventId` until a later tick's CATCH branch rebuilt it.
|
||||||
- **Per-channel retention overrides (M5.5 T3):** `AuditLog:PerChannelRetentionDays`
|
- **Per-channel retention overrides (M5.5 T3):** `AuditLog:PerChannelRetentionDays`
|
||||||
is a dictionary keyed by canonical channel name (`ApiOutbound`, `DbOutbound`,
|
is a dictionary keyed by canonical channel name (`ApiOutbound`, `DbOutbound`,
|
||||||
`Notification`, `ApiInbound`) whose value is a retention window in days that
|
`Notification`, `ApiInbound`) whose value is a retention window in days that
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ public sealed class AuditCentralHealthSnapshot
|
|||||||
: IAuditCentralHealthSnapshot,
|
: IAuditCentralHealthSnapshot,
|
||||||
ICentralAuditWriteFailureCounter,
|
ICentralAuditWriteFailureCounter,
|
||||||
IAuditRedactionFailureCounter,
|
IAuditRedactionFailureCounter,
|
||||||
IAuditInboundCeilingHitsCounter
|
IAuditInboundCeilingHitsCounter,
|
||||||
|
IAuditPurgeFailureCounter
|
||||||
{
|
{
|
||||||
private int _centralAuditWriteFailures;
|
private int _centralAuditWriteFailures;
|
||||||
private int _auditRedactionFailure;
|
private int _auditRedactionFailure;
|
||||||
private int _auditInboundCeilingHits;
|
private int _auditInboundCeilingHits;
|
||||||
|
private int _auditPurgeFailures;
|
||||||
private readonly ConcurrentDictionary<string, bool> _stalled = new();
|
private readonly ConcurrentDictionary<string, bool> _stalled = new();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -59,6 +61,10 @@ public sealed class AuditCentralHealthSnapshot
|
|||||||
public int AuditInboundCeilingHits =>
|
public int AuditInboundCeilingHits =>
|
||||||
Interlocked.CompareExchange(ref _auditInboundCeilingHits, 0, 0);
|
Interlocked.CompareExchange(ref _auditInboundCeilingHits, 0, 0);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public int PurgeFailures =>
|
||||||
|
Interlocked.CompareExchange(ref _auditPurgeFailures, 0, 0);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IReadOnlyDictionary<string, bool> SiteAuditTelemetryStalled =>
|
public IReadOnlyDictionary<string, bool> SiteAuditTelemetryStalled =>
|
||||||
new Dictionary<string, bool>(_stalled);
|
new Dictionary<string, bool>(_stalled);
|
||||||
@@ -88,4 +94,8 @@ public sealed class AuditCentralHealthSnapshot
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
void IAuditInboundCeilingHitsCounter.Increment() =>
|
void IAuditInboundCeilingHitsCounter.Increment() =>
|
||||||
Interlocked.Increment(ref _auditInboundCeilingHits);
|
Interlocked.Increment(ref _auditInboundCeilingHits);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
void IAuditPurgeFailureCounter.Increment() =>
|
||||||
|
Interlocked.Increment(ref _auditPurgeFailures);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,14 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional purge-failure counter — resolved from the same per-tick scope the repository
|
||||||
|
// comes from. NoOp default on site/test roots; the central composition root binds the
|
||||||
|
// AuditCentralHealthSnapshot so failures surface on the central health dashboard. Mirrors
|
||||||
|
// AuditLogIngestActor's ICentralAuditWriteFailureCounter resolution shape.
|
||||||
|
var purgeFailureCounter = scope.ServiceProvider.GetService<IAuditPurgeFailureCounter>();
|
||||||
|
|
||||||
IReadOnlyList<DateTime> boundaries;
|
IReadOnlyList<DateTime> boundaries;
|
||||||
|
var enumSw = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
boundaries = await repository
|
boundaries = await repository
|
||||||
@@ -158,10 +165,14 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
enumSw.Stop();
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
ex,
|
ex,
|
||||||
"Failed to enumerate eligible AuditLog partition boundaries (threshold {ThresholdUtc:o}); skipping purge tick.",
|
"Failed to enumerate eligible AuditLog partition boundaries (threshold {ThresholdUtc:o}); skipping purge tick.",
|
||||||
threshold);
|
threshold);
|
||||||
|
// Enumeration threw before any boundary was selected — publish with the
|
||||||
|
// DateTime.MinValue sentinel so the failure is still observable on the health surface.
|
||||||
|
PublishPurgeFailure(eventStream, purgeFailureCounter, DateTime.MinValue, ex, enumSw.ElapsedMilliseconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +180,7 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
// No whole-month partitions are eligible, but per-channel overrides may
|
// No whole-month partitions are eligible, but per-channel overrides may
|
||||||
// still expire rows earlier than the global window — run them below.
|
// still expire rows earlier than the global window — run them below.
|
||||||
await RunPerChannelOverridesAsync(repository).ConfigureAwait(false);
|
await RunPerChannelOverridesAsync(repository, eventStream, purgeFailureCounter).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +214,7 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
"Failed to purge AuditLog partition {MonthBoundary:yyyy-MM-dd}; other partitions continue. Elapsed {DurationMs} ms.",
|
"Failed to purge AuditLog partition {MonthBoundary:yyyy-MM-dd}; other partitions continue. Elapsed {DurationMs} ms.",
|
||||||
boundary,
|
boundary,
|
||||||
sw.ElapsedMilliseconds);
|
sw.ElapsedMilliseconds);
|
||||||
|
PublishPurgeFailure(eventStream, purgeFailureCounter, boundary, ex, sw.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +224,24 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
// switch-out has already dropped whole months older than RetentionDays; these
|
// switch-out has already dropped whole months older than RetentionDays; these
|
||||||
// deletes only ever expire rows EARLIER than that, so they run last and are a
|
// deletes only ever expire rows EARLIER than that, so they run last and are a
|
||||||
// strict tightening.
|
// strict tightening.
|
||||||
await RunPerChannelOverridesAsync(repository).ConfigureAwait(false);
|
await RunPerChannelOverridesAsync(repository, eventStream, purgeFailureCounter).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes an <see cref="AuditLogPurgeFailedEvent"/> on the EventStream and increments the
|
||||||
|
/// central purge-failure health counter (when wired). Best-effort — the symmetric failure twin
|
||||||
|
/// of the success-path <see cref="AuditLogPurgedEvent"/> publish, so a silently failing
|
||||||
|
/// retention job is a health-surface condition, not just a log line (arch-review 04, S2).
|
||||||
|
/// </summary>
|
||||||
|
private static void PublishPurgeFailure(
|
||||||
|
Akka.Event.EventStream eventStream,
|
||||||
|
IAuditPurgeFailureCounter? counter,
|
||||||
|
DateTime monthBoundary,
|
||||||
|
Exception ex,
|
||||||
|
long elapsedMilliseconds)
|
||||||
|
{
|
||||||
|
eventStream.Publish(new AuditLogPurgeFailedEvent(monthBoundary, ex.Message, elapsedMilliseconds));
|
||||||
|
counter?.Increment();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -224,7 +253,12 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
/// per-boundary error isolation of the partition switch-out loop.
|
/// per-boundary error isolation of the partition switch-out loop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="repository">The repository resolved for this tick's DI scope.</param>
|
/// <param name="repository">The repository resolved for this tick's DI scope.</param>
|
||||||
private async Task RunPerChannelOverridesAsync(IAuditLogRepository repository)
|
/// <param name="eventStream">The actor-system EventStream captured before the first await.</param>
|
||||||
|
/// <param name="purgeFailureCounter">Optional central purge-failure health counter (NoOp on site/test roots).</param>
|
||||||
|
private async Task RunPerChannelOverridesAsync(
|
||||||
|
IAuditLogRepository repository,
|
||||||
|
Akka.Event.EventStream eventStream,
|
||||||
|
IAuditPurgeFailureCounter? purgeFailureCounter)
|
||||||
{
|
{
|
||||||
var overrides = _auditOptions.PerChannelRetentionDays;
|
var overrides = _auditOptions.PerChannelRetentionDays;
|
||||||
if (overrides is null || overrides.Count == 0)
|
if (overrides is null || overrides.Count == 0)
|
||||||
@@ -281,6 +315,10 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
channel,
|
channel,
|
||||||
days,
|
days,
|
||||||
sw.ElapsedMilliseconds);
|
sw.ElapsedMilliseconds);
|
||||||
|
// A per-channel DELETE failure is a purge failure too — surface it alongside the
|
||||||
|
// switch-out failures. MonthBoundary is DateTime.MinValue (this is not a
|
||||||
|
// whole-month switch), matching the enumeration-phase sentinel.
|
||||||
|
PublishPurgeFailure(eventStream, purgeFailureCounter, DateTime.MinValue, ex, sw.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Symmetric failure twin of <see cref="AuditLogPurgedEvent"/>. Published on the
|
||||||
|
/// actor-system EventStream by <see cref="AuditLogPurgeActor"/> whenever a purge
|
||||||
|
/// step throws — a partition switch-out, a per-channel override DELETE, or the
|
||||||
|
/// boundary enumeration that precedes them. A silently failing retention job is a
|
||||||
|
/// health-surface condition, not just a log line, so this event lets the central
|
||||||
|
/// health collector + ops dashboards observe purge failures without coupling to
|
||||||
|
/// the actor (arch-review 04, S2).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="MonthBoundary">
|
||||||
|
/// The pf_AuditLog_Month lower-bound boundary whose switch-out failed. When the
|
||||||
|
/// failure occurs BEFORE any specific boundary is selected — i.e. the pre-purge
|
||||||
|
/// boundary enumeration threw — this is <see cref="System.DateTime.MinValue"/> as a
|
||||||
|
/// sentinel for "enumeration phase, no boundary in hand".
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Error">
|
||||||
|
/// The failure's message (<see cref="System.Exception.Message"/>). The full
|
||||||
|
/// exception is logged at Error on the actor's logger; this carries the short form
|
||||||
|
/// for surfacing on health/ops without leaking a full stack trace onto the stream.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ElapsedMilliseconds">
|
||||||
|
/// Wall-clock time spent on the failed step before it threw, in milliseconds.
|
||||||
|
/// </param>
|
||||||
|
public sealed record AuditLogPurgeFailedEvent(
|
||||||
|
DateTime MonthBoundary,
|
||||||
|
string Error,
|
||||||
|
long ElapsedMilliseconds);
|
||||||
@@ -61,6 +61,16 @@ public interface IAuditCentralHealthSnapshot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
int AuditInboundCeilingHits { get; }
|
int AuditInboundCeilingHits { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Count of AuditLog partition-purge failures since process start. Incremented by
|
||||||
|
/// <see cref="AuditLogPurgeActor"/> every time a purge step throws — a partition
|
||||||
|
/// switch-out, a per-channel override DELETE, or the pre-purge boundary enumeration.
|
||||||
|
/// A sustained non-zero count means the retention job is silently not draining the
|
||||||
|
/// table; each failure also publishes an <see cref="AuditLogPurgeFailedEvent"/> on the
|
||||||
|
/// EventStream (arch-review 04, S2).
|
||||||
|
/// </summary>
|
||||||
|
int PurgeFailures { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-site latched stalled state: <c>true</c> when the
|
/// Per-site latched stalled state: <c>true</c> when the
|
||||||
/// <see cref="SiteAuditReconciliationActor"/> has observed two
|
/// <see cref="SiteAuditReconciliationActor"/> has observed two
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counter sink invoked by <see cref="AuditLogPurgeActor"/> every time a purge
|
||||||
|
/// step throws (partition switch-out, per-channel override DELETE, or the pre-purge
|
||||||
|
/// boundary enumeration). Mirrors <see cref="ICentralAuditWriteFailureCounter"/>
|
||||||
|
/// one-for-one — same one-method contract, same NoOp default, same
|
||||||
|
/// never-abort-the-tick invariant (the purge actor swallows the failure per
|
||||||
|
/// boundary and continues).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A NoOp default is the safe fallback for site/test composition roots that do not
|
||||||
|
/// wire the central health surface; <see cref="AuditCentralHealthSnapshot"/> is the
|
||||||
|
/// production binding that routes increments into the aggregated central health
|
||||||
|
/// snapshot's <see cref="IAuditCentralHealthSnapshot.PurgeFailures"/> read surface.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAuditPurgeFailureCounter
|
||||||
|
{
|
||||||
|
/// <summary>Increment the central AuditLog purge-failure counter by one.</summary>
|
||||||
|
void Increment();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IAuditPurgeFailureCounter"/> binding used when the central
|
||||||
|
/// health surface (<see cref="AuditCentralHealthSnapshot"/>) has not been wired
|
||||||
|
/// (test composition roots, site-only hosts). Drops every increment on the floor.
|
||||||
|
/// Mirrors <see cref="NoOpCentralAuditWriteFailureCounter"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NoOpAuditPurgeFailureCounter : IAuditPurgeFailureCounter
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Increment()
|
||||||
|
{
|
||||||
|
// intentional no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,6 +212,12 @@ public static class ServiceCollectionExtensions
|
|||||||
// redaction-failure counters.
|
// redaction-failure counters.
|
||||||
services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();
|
services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();
|
||||||
|
|
||||||
|
// AuditLog purge-failure counter — NoOp default for site/test
|
||||||
|
// composition roots. AddAuditLogCentralMaintenance below replaces this binding
|
||||||
|
// with the AuditCentralHealthSnapshot implementation so partition/channel purge
|
||||||
|
// failures surface on the central dashboard alongside the write-failure counter.
|
||||||
|
services.TryAddSingleton<IAuditPurgeFailureCounter, NoOpAuditPurgeFailureCounter>();
|
||||||
|
|
||||||
// Central direct-write audit writer used by
|
// Central direct-write audit writer used by
|
||||||
// NotificationOutboxActor and Inbound API to
|
// NotificationOutboxActor and Inbound API to
|
||||||
// emit AuditLog rows that originate ON central, not via site telemetry.
|
// emit AuditLog rows that originate ON central, not via site telemetry.
|
||||||
@@ -419,6 +425,11 @@ public static class ServiceCollectionExtensions
|
|||||||
// ICentralAuditWriteFailureCounter above.
|
// ICentralAuditWriteFailureCounter above.
|
||||||
services.Replace(ServiceDescriptor.Singleton<IAuditInboundCeilingHitsCounter>(
|
services.Replace(ServiceDescriptor.Singleton<IAuditInboundCeilingHitsCounter>(
|
||||||
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
||||||
|
// Replace the NoOp IAuditPurgeFailureCounter with the AuditCentralHealthSnapshot so
|
||||||
|
// partition/channel purge failures surface on the central dashboard. Same
|
||||||
|
// singleton-forward pattern as ICentralAuditWriteFailureCounter above (arch-review 04, S2).
|
||||||
|
services.Replace(ServiceDescriptor.Singleton<IAuditPurgeFailureCounter>(
|
||||||
|
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,4 +543,50 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
duration: TimeSpan.FromSeconds(3),
|
duration: TimeSpan.FromSeconds(3),
|
||||||
interval: TimeSpan.FromMilliseconds(50));
|
interval: TimeSpan.FromMilliseconds(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter (Task 5)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
|
||||||
|
{
|
||||||
|
// A silently failing retention job is a health-surface condition, not just a log line:
|
||||||
|
// the per-boundary catch must publish AuditLogPurgeFailedEvent AND bump the
|
||||||
|
// AuditCentralHealthSnapshot.PurgeFailures counter (arch-review 04, S2).
|
||||||
|
var boundary = new DateTime(2025, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var repo = new RecordingRepo
|
||||||
|
{
|
||||||
|
Boundaries = new List<DateTime> { boundary },
|
||||||
|
ThrowOnBoundary = boundary,
|
||||||
|
BoundaryException = new InvalidOperationException("simulated switch timeout"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var snapshot = new AuditCentralHealthSnapshot();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped<IAuditLogRepository>(_ => repo);
|
||||||
|
services.AddSingleton(snapshot);
|
||||||
|
services.AddSingleton<IAuditPurgeFailureCounter>(snapshot);
|
||||||
|
var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgeFailedEvent));
|
||||||
|
|
||||||
|
Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||||||
|
sp,
|
||||||
|
Options.Create(FastTickOptions()),
|
||||||
|
Options.Create(new AuditLogOptions()),
|
||||||
|
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||||
|
|
||||||
|
var evt = probe.ExpectMsg<AuditLogPurgeFailedEvent>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(boundary, evt.MonthBoundary);
|
||||||
|
Assert.Contains("simulated switch timeout", evt.Error);
|
||||||
|
Assert.True(evt.ElapsedMilliseconds >= 0);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => Assert.True(snapshot.PurgeFailures >= 1,
|
||||||
|
$"expected PurgeFailures >= 1, got {snapshot.PurgeFailures}"),
|
||||||
|
duration: TimeSpan.FromSeconds(3),
|
||||||
|
interval: TimeSpan.FromMilliseconds(50));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user