Skip to content

feat(#435): read-only per-cell projection progress query on IEventDatabase (2.x)#518

Merged
jeremydmiller merged 1 commit into
mainfrom
fix-435-read-projection-progress-main
Jul 17, 2026
Merged

feat(#435): read-only per-cell projection progress query on IEventDatabase (2.x)#518
jeremydmiller merged 1 commit into
mainfrom
fix-435-read-projection-progress-main

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Addresses #435 on the 2.x line. Companion to #517, which carries the identical change to the 1.0 branch (the issue is scoped a "v1.1 candidate", but the surface is wanted on both lines so Marten/Polecat can implement against whichever they target).

What this adds

ValueTask<ProjectionProgressRow?> IEventDatabase.ReadProjectionProgressAsync(
    string projectionName, string? tenantId, CancellationToken token);

public record ProjectionProgressRow(
    string ProjectionName, string? TenantId, long Sequence,
    string AgentStatus, DateTimeOffset? LastHeartbeat);

AllProjectionProgress already works, but returns every row across every projection × tenant pair and leaves the caller to filter. For a per-cell UI polling loop (1Hz against a single visible batch), the targeted query is materially cheaper.

Backwards compatibility

Shipped as a default interface member throwing 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 (#356, #407, #450, #473).

The default throws rather than returning null deliberately. null is the documented "no row for this pair yet" answer. If an unimplemented store borrowed it, a monitoring UI would render a running projection as having no progress — silently wrong beats loudly unsupported. Same reasoning as #503.

Two things worth a reviewer's attention

1. ShardStatus already exists on 2.x and overlaps. JasperFx.Descriptors.ShardStatus (ShardName, State, ProcessedSequence, EventStoreSequence, Error) covers adjacent ground. I kept ProjectionProgressRow separate because the grains genuinely differ — ShardStatus is per-shard with no tenant dimension and is reached through IEventStore.GetProjectionStatusesAsync (all projections, all shards), whereas this is a targeted per-(projection, tenant) read off IEventDatabase. If you'd rather reconcile the two shapes, now is the cheap moment; that's a judgement call I didn't want to make unilaterally. This overlap does not exist on the 1.0 line, which is why #517 doesn't mention it.

2. AgentStatus/LastHeartbeat sourcing is unverified. Same caveat as #517: I could not confirm these are actually available from mt_event_progression / pc_event_progression. Sequence is plainly a progression-row value; the other two read more like daemon/tracker state. If the columns don't exist, the companion PRs will have to stub them and the record's shape becomes misleading. Flagging rather than guessing — easy to trim now, awkward after it ships.

Relatedly, AgentStatus is a string per the issue rather than the existing JasperFx.AgentStatus enum, which tolerates store-specific states outside Running/Stopped/Paused.

Placement

ProjectionProgressRow sits in the JasperFx.Events namespace (alongside IEventDatabase) rather than JasperFx.Descriptors, identical to #517, so Marten and Polecat can write the override source-identically against both the 1.x and 2.x lines. Note 2.x is already inconsistent here — ProjectionStatus lives in JasperFx.Descriptors while DeadLetterShardCount lives in JasperFx.Events.Daemon — so there was no single convention to follow; cross-line consistency seemed the more useful tie-breaker.

Test coverage

src/EventTests/Daemon/ReadProjectionProgressDefaultsTests.cs — 5 tests: the default throws (store-global and tenant-scoped), an implementing store can return a row, an implementing store's null survives as "no row", and the record's null-tenant semantic.

Verified by mutation on this branch: making the default return null instead of throwing fails exactly the two guard tests and leaves the other three green.

Verification

  • dotnet build jasperfx.sln — succeeded, 0 errors
  • src/EventTests — 503/503 pass on net9.0 and net10.0

Follow-ups filed

  • jasperfx/marten — implement against mt_event_progression
  • jasperfx/polecat — implement against pc_event_progression

🤖 Generated with Claude Code

…abase

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 (#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) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit efa3033 into main Jul 17, 2026
1 check passed
jeremydmiller added a commit that referenced this pull request Jul 17, 2026
…ersists it (#519)

AgentStatus was typed non-nullable string, but no event store can populate it
today. Both Marten and Polecat create an agent_status column on the progression
table and read it back, yet neither has a code path that writes one:

- Marten ships mt_mark_event_progression_extended(), whose SQL does write
  agent_status/pause_reason/running_on_node — but the only reference to it is
  EventGraph.FeatureSchema.cs, which registers it as a SystemFunction so the DDL
  is created. Nothing invokes it.
- Polecat has MarkExtendedProjectionProgress, which likewise writes the columns
  and is constructed only from tests (polecat#323).

So agent_status reads NULL in both stores, and an implementer of
ReadProjectionProgressAsync would have to invent a value to satisfy the type.
Make it string? so a store with nothing to report can say so — symmetric with
LastHeartbeat, already nullable and documented as "null when the store does not
track a heartbeat for it".

Caught before release: #518/#517 are merged but ProjectionProgressRow has not
shipped (latest JasperFx.Events is 2.27.0), so this costs nothing now.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant