fix(historian): dead-letter poison events after maxAttempts (finding 002)

This commit is contained in:
Joseph Doherty
2026-06-16 05:25:43 -04:00
parent 5e27b5f708
commit fcb3801415
4 changed files with 67 additions and 6 deletions
@@ -268,6 +268,37 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(60));
}
/// <summary>
/// Regression for finding 002: a permanently-malformed (poison) event that the
/// writer can only ever map to <see cref="HistorianWriteOutcome.RetryPlease"/>
/// must NOT retry forever. After <c>maxAttempts</c> retry-please drains the row
/// is dead-lettered so the queue head can never stall on it indefinitely.
/// </summary>
[Fact]
public async Task RetryPlease_dead_letters_row_after_MaxAttempts()
{
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using var sink = new SqliteStoreAndForwardSink(
_dbPath, writer, _log, maxAttempts: 3);
await sink.EnqueueAsync(Event("poison"), CancellationToken.None);
// Drain 1 + 2: still retrying — the row stays queued, not yet dead-lettered.
await sink.DrainOnceAsync(CancellationToken.None);
sink.GetStatus().QueueDepth.ShouldBe(1, "after 1 attempt the row is still queued");
sink.GetStatus().DeadLetterDepth.ShouldBe(0, "not yet dead-lettered after 1 attempt");
await sink.DrainOnceAsync(CancellationToken.None);
sink.GetStatus().QueueDepth.ShouldBe(1, "after 2 attempts the row is still queued");
sink.GetStatus().DeadLetterDepth.ShouldBe(0, "not yet dead-lettered after 2 attempts");
// Drain 3: the 3rd attempt hits the cap — dead-letter it.
await sink.DrainOnceAsync(CancellationToken.None);
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(0, "row left the live queue once max attempts exceeded");
status.DeadLetterDepth.ShouldBe(1, "poison row dead-lettered at the max-attempts cap");
}
/// <summary>Verifies that NullAlarmHistorianSink reports disabled status.</summary>
[Fact]
public void NullAlarmHistorianSink_reports_disabled_status()
@@ -295,6 +326,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, w, null!));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, batchSize: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0));
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, maxAttempts: 0));
}
/// <summary>Verifies that a disposed sink rejects enqueue operations.</summary>