From 1fa0f2daf96e7394ee5e33e53a027124d3acc57b Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 17 Jul 2026 07:17:24 -0500 Subject: [PATCH] feat(#435): read-only per-cell projection progress query on IEventDatabase Adds IEventDatabase.ReadProjectionProgressAsync(projectionName, tenantId, token) so monitoring tools can read the progression-row state for a single (projection, tenant) cell without spinning up an IProjectionDaemon. AllProjectionProgress already works but returns every row across every projection x tenant pair, leaving the caller to filter. For a per-cell UI polling loop (e.g. 1Hz against a single visible batch) the targeted query is materially cheaper. Shipped as a default interface member that throws NotSupportedException, not as the abstract member the issue's snippet implies. IEventDatabase is implemented out-of-tree by Marten and Polecat, so an abstract member would break every implementor the moment it shipped. This matches the graceful-degradation pattern already used throughout this interface (jasperfx#356, #407, #450, #473). The default throws rather than returning null on purpose: null is the documented "no row for this pair yet" answer, so a store that simply has not implemented the query must not borrow it and report a live cell as absent. ProjectionProgressRow is deliberately placed in the JasperFx.Events namespace, identical to the 1.0 port in #517, so Marten and Polecat can implement the override source-identically against both lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ReadProjectionProgressDefaultsTests.cs | 143 ++++++++++++++++++ src/JasperFx.Events/IEventDatabase.cs | 27 ++++ src/JasperFx.Events/ProjectionProgressRow.cs | 29 ++++ 3 files changed, 199 insertions(+) create mode 100644 src/EventTests/Daemon/ReadProjectionProgressDefaultsTests.cs create mode 100644 src/JasperFx.Events/ProjectionProgressRow.cs diff --git a/src/EventTests/Daemon/ReadProjectionProgressDefaultsTests.cs b/src/EventTests/Daemon/ReadProjectionProgressDefaultsTests.cs new file mode 100644 index 00000000..82aae1c5 --- /dev/null +++ b/src/EventTests/Daemon/ReadProjectionProgressDefaultsTests.cs @@ -0,0 +1,143 @@ +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; +using Xunit; + +namespace EventTests.Daemon; + +/// +/// jasperfx#435 — ReadProjectionProgressAsync ships as a default interface implementation so the +/// out-of-tree IEventDatabase implementors (Marten, Polecat) keep compiling on the 1.x line. These +/// pin the contract that matters to a consumer: "not implemented" and "no row yet" are different +/// answers, and the default must not conflate them. +/// +public class ReadProjectionProgressDefaultsTests +{ + // A bare IEventDatabase that does NOT override ReadProjectionProgressAsync, so the call below + // exercises the default. 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(); + } + + // An IEventDatabase that DOES implement the query, standing in for Marten/Polecat. Proves the + // member is overridable and that a real implementation's null survives as "no row". + private sealed class ProgressionEventDatabase : BareDatabaseBase + { + public override ValueTask ReadProjectionProgressAsync( + string projectionName, string? tenantId, CancellationToken token) + => projectionName == "Orders" + ? new ValueTask( + new ProjectionProgressRow("Orders", tenantId, 42, "Running", null)) + : new ValueTask((ProjectionProgressRow?)null); + } + + // Split out so the overriding stub does not have to restate every unrelated member. + private abstract class BareDatabaseBase : IEventDatabase + { + public virtual ValueTask ReadProjectionProgressAsync( + string projectionName, string? tenantId, CancellationToken token) + => throw new NotImplementedException(); + + 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 theBareDatabase = new BareEventDatabase(); + private readonly IEventDatabase theProgressionDatabase = new ProgressionEventDatabase(); + + [Fact] + public async Task default_throws_rather_than_reporting_a_live_cell_as_absent() + { + // The critical distinction: null is the documented "no row for this pair yet" answer, so an + // unimplemented store must not return it. A monitoring UI would render a running projection + // as having no progress at all. + await Should.ThrowAsync(async () => + await theBareDatabase.ReadProjectionProgressAsync("Orders", null, CancellationToken.None)); + } + + [Fact] + public async Task default_throws_for_a_tenant_scoped_read_too() + { + await Should.ThrowAsync(async () => + await theBareDatabase.ReadProjectionProgressAsync("Orders", "tenant-1", CancellationToken.None)); + } + + [Fact] + public async Task an_implementing_store_can_return_a_row() + { + var row = await theProgressionDatabase.ReadProjectionProgressAsync("Orders", "tenant-1", CancellationToken.None); + + row.ShouldNotBeNull(); + row.ProjectionName.ShouldBe("Orders"); + row.TenantId.ShouldBe("tenant-1"); + row.Sequence.ShouldBe(42); + row.AgentStatus.ShouldBe("Running"); + row.LastHeartbeat.ShouldBeNull(); + } + + [Fact] + public async Task an_implementing_store_returns_null_when_no_row_exists_for_the_pair() + { + var row = await theProgressionDatabase.ReadProjectionProgressAsync("Unobserved", null, CancellationToken.None); + row.ShouldBeNull(); + } + + [Fact] + public void null_tenant_id_means_store_global_or_default_tenant() + { + new ProjectionProgressRow("Orders", null, 1, "Running", null).TenantId.ShouldBeNull(); + } +} diff --git a/src/JasperFx.Events/IEventDatabase.cs b/src/JasperFx.Events/IEventDatabase.cs index cf0591d3..ad6b9f72 100644 --- a/src/JasperFx.Events/IEventDatabase.cs +++ b/src/JasperFx.Events/IEventDatabase.cs @@ -174,4 +174,31 @@ Task> FetchDeadLetterCountsAsync(string? ten ? FetchDeadLetterCountsAsync(token) : throw new NotSupportedException( "Per-tenant FetchDeadLetterCountsAsync is not implemented on this IEventDatabase. Use an event store that implements per-tenant partitioning."); + + /// + /// Read the current sequence + lifecycle state for a single (projection, tenant) cell directly + /// from the progression table, without spinning up an . + /// Returns null when no row exists for the pair yet — e.g. the daemon has not yet observed this + /// projection for this tenant. A null means store-global on a + /// non-tenanted store, or the default-tenant row on a tenanted store. + /// + /// This is the targeted counterpart to , + /// which returns every row across every projection × tenant pair and leaves the caller to filter. + /// For a per-cell UI polling loop (e.g. 1Hz against a single visible batch) the targeted query is + /// materially cheaper. + /// + /// The default implementation throws rather than returning + /// null: null is the meaningful "no row yet" answer, so a store that simply has not implemented + /// this must not borrow it and report a live cell as absent. Event stores (Marten, Polecat) + /// override this against their progression table. See jasperfx#435. + /// + /// Name of the projection whose cell to read. + /// Tenant owning the cell. Null means store-global / default tenant. + /// + ValueTask ReadProjectionProgressAsync( + string projectionName, + string? tenantId, + CancellationToken token) + => throw new NotSupportedException( + "ReadProjectionProgressAsync is not implemented on this IEventDatabase. Use an event store (Marten or Polecat) that supports reading a single projection progression row."); } \ No newline at end of file diff --git a/src/JasperFx.Events/ProjectionProgressRow.cs b/src/JasperFx.Events/ProjectionProgressRow.cs new file mode 100644 index 00000000..db074969 --- /dev/null +++ b/src/JasperFx.Events/ProjectionProgressRow.cs @@ -0,0 +1,29 @@ +namespace JasperFx.Events; + +/// +/// Point in time state of a single (projection, tenant) progression cell, read straight from the +/// event store's progression table. This is the targeted, per-cell counterpart to +/// — it exists so a monitoring tool polling one +/// visible cell does not have to fetch and filter every projection × tenant row on each tick. +/// See jasperfx#435. +/// +/// Name of the projection this cell tracks. +/// +/// Tenant the cell belongs to. Null means store-global on a non-tenanted store, or the +/// default-tenant row on a tenanted store. +/// +/// Event sequence number this cell has processed through. +/// +/// Lifecycle state of the agent driving this cell. Left as a string rather than the +/// enum on purpose: this is a diagnostic read of whatever the +/// store persisted, and a store may report a state outside the enum's Running/Stopped/Paused. +/// +/// +/// Timestamp the cell last reported progress; null when the store does not track a heartbeat for it. +/// +public record ProjectionProgressRow( + string ProjectionName, + string? TenantId, + long Sequence, + string AgentStatus, + DateTimeOffset? LastHeartbeat);