From 3b3979d5faaed8beca4fa89255dae49197831fe5 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 23 May 2026 13:05:09 -0500 Subject: [PATCH] Add store-agnostic projection dead-letter count read to IEventDatabase (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JasperFx.Events 2.0 made SkipApplyErrors the default, so accumulating DeadLetterEvent rows is now the primary "projection unhealthy" signal — but IEventDatabase only let consumers *write* dead letters, forcing store-specific, reflection-based reads. Adds two read members to IEventDatabase: - CountDeadLetterEventsAsync(ShardName, CancellationToken) — per-shard count - FetchDeadLetterCountsAsync(CancellationToken) — bulk, one DeadLetterShardCount (ProjectionName, ShardKey, Count) per shard, mirroring AllProjectionProgress Both ship with default interface implementations returning 0 / empty as a stand-in, so existing IEventDatabase implementers keep compiling and behave gracefully until they override. Marten (JasperFx/marten#4546) and Polecat (JasperFx/polecat#146) will provide real implementations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Daemon/DeadLetterCountDefaultsTests.cs | 62 +++++++++++++++++++ .../Daemon/DeadLetterShardCount.cs | 14 +++++ src/JasperFx.Events/IEventDatabase.cs | 24 +++++++ 3 files changed, 100 insertions(+) create mode 100644 src/EventTests/Daemon/DeadLetterCountDefaultsTests.cs create mode 100644 src/JasperFx.Events/Daemon/DeadLetterShardCount.cs diff --git a/src/EventTests/Daemon/DeadLetterCountDefaultsTests.cs b/src/EventTests/Daemon/DeadLetterCountDefaultsTests.cs new file mode 100644 index 00000000..9d2da615 --- /dev/null +++ b/src/EventTests/Daemon/DeadLetterCountDefaultsTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Shouldly; + +namespace EventTests.Daemon; + +public class DeadLetterCountDefaultsTests +{ + // A bare IEventDatabase that does NOT override the dead-letter count read members, + // so the calls below exercise the default interface implementations added for + // jasperfx#356 (the "return 0 / empty" stand-ins for stores that don't yet read + // dead letters). Everything else throws — those members are never invoked here. + private sealed class BareEventDatabase : IEventDatabase + { + public string Identifier => throw new NotImplementedException(); + public Uri DatabaseUri => throw new NotImplementedException(); + public ShardStateTracker Tracker => throw new NotImplementedException(); + public string StorageIdentifier => throw new NotImplementedException(); + + public Task StoreDeadLetterEventAsync(object storage, DeadLetterEvent deadLetterEvent, CancellationToken token) + => throw new NotImplementedException(); + + public Task EnsureStorageExistsAsync(Type storageType, CancellationToken token) + => throw new NotImplementedException(); + + public Task WaitForNonStaleProjectionDataAsync(TimeSpan timeout) + => throw new NotImplementedException(); + + public Task ProjectionProgressFor(ShardName name, CancellationToken token = default) + => throw new NotImplementedException(); + + public Task FindEventStoreFloorAtTimeAsync(DateTimeOffset timestamp, CancellationToken token) + => throw new NotImplementedException(); + + public Task FetchHighestEventSequenceNumber(CancellationToken token) + => throw new NotImplementedException(); + + public Task> AllProjectionProgress(CancellationToken token = default) + => throw new NotImplementedException(); + } + + private readonly IEventDatabase theDatabase = new BareEventDatabase(); + + [Fact] + public async Task count_dead_letters_default_returns_zero() + { + var count = await theDatabase.CountDeadLetterEventsAsync(new ShardName("Fake")); + count.ShouldBe(0); + } + + [Fact] + public async Task fetch_dead_letter_counts_default_returns_empty() + { + var counts = await theDatabase.FetchDeadLetterCountsAsync(); + counts.ShouldBeEmpty(); + } +} diff --git a/src/JasperFx.Events/Daemon/DeadLetterShardCount.cs b/src/JasperFx.Events/Daemon/DeadLetterShardCount.cs new file mode 100644 index 00000000..cbc719ad --- /dev/null +++ b/src/JasperFx.Events/Daemon/DeadLetterShardCount.cs @@ -0,0 +1,14 @@ +using JasperFx.Events.Projections; + +namespace JasperFx.Events.Daemon; + +/// +/// A count of stored projection/subscription dead letter events for a single shard. +/// aligns with and +/// aligns with , matching how +/// records them. See jasperfx#356. +/// +/// The projection name (). +/// The shard key within the projection (). +/// The number of stored dead letter events for this shard. +public record DeadLetterShardCount(string ProjectionName, string ShardKey, long Count); diff --git a/src/JasperFx.Events/IEventDatabase.cs b/src/JasperFx.Events/IEventDatabase.cs index 64d3e054..2ebb1029 100644 --- a/src/JasperFx.Events/IEventDatabase.cs +++ b/src/JasperFx.Events/IEventDatabase.cs @@ -67,4 +67,28 @@ Task ProjectionProgressFor(ShardName name, /// Task> AllProjectionProgress( CancellationToken token = default); + + /// + /// Count the stored dead letter events for a single projection/subscription shard. With + /// SkipApplyErrors on (the JasperFx.Events 2.0 default), a failed Apply() is + /// recorded as a and the shard keeps advancing, so the + /// accumulation of these is the primary "this projection is unhealthy" signal. + /// The default implementation returns 0 as a stand-in; event stores that persist dead + /// letters should override this. See jasperfx#356. + /// + /// The projection/subscription shard to count dead letters for. + /// + Task CountDeadLetterEventsAsync(ShardName shard, CancellationToken token = default) + => Task.FromResult(0L); + + /// + /// Fetch the stored dead letter event counts for this database, one row per shard + /// ( + ). + /// Mirrors the "give me every row" shape of . + /// The default implementation returns an empty list as a stand-in; event stores that + /// persist dead letters should override this. See jasperfx#356. + /// + /// + Task> FetchDeadLetterCountsAsync(CancellationToken token = default) + => Task.FromResult>([]); } \ No newline at end of file