Expose the classified pause/stop reason of a shard (#565) - #567
Merged
Conversation
An external supervisor could see AgentStatus.Paused and nothing else: ISubscriptionAgent had no reason accessor, and ShardStateTracker kept its current-state map private, so Wolverine's EventSubscriptionAgent (and CritterWatch behind it) knew progress had flatlined but never why -- even though the operator response differs completely per cause. Adds, all funneled through SubscriptionAgent.ReportCriticalFailureAsync (the single place every failure path already converged on): - ShardFailureCategory: ApplyEvent / EventSerialization / UnknownEventType / ProgressionOutOfOrder / Other. - IEventFailureContext: implemented by exceptions that can name the single event they failed on. ApplyEventException implements it here; the stores implement it on their own read-side exceptions (Marten's EventDeserializationFailureException / UnknownEventTypeException, Polecat's equivalents) and declare their own category, so the daemon never sniffs store type names. - EventFailureDetails + ShardFailure: plain serializable values (category, outer + root exception type, message, full detail, the failing event, timestamp) that survive leaving the process, which an Exception can't. ShardFailure.For walks the whole exception graph, so a wrapping ShardStopException or an AggregateException of apply failures still classifies -- the lowest failing sequence wins. - ISubscriptionAgent.Failure as a default interface member (non-breaking), set alongside Status and cleared on start/replay. - ShardState.Failure, published on the paused/stopped state. PauseReason keeps carrying the same full exception text it always did. - ShardStateTracker.CurrentState / TryGetCurrentState / CurrentStates: the synchronous snapshot an external poller never had. - DeadLetterEvent: identity assigned at construction (so it is known before the background write lands) plus DescribesSameFailureAs, the traceability link between a paused shard's ShardFailure and the dead letter the same event produces if it is later skipped. Nothing is written to the dead-letter table when a shard pauses -- a paused event was not skipped. Marten and Polecat ship the matching IEventFailureContext implementations and the extended-progression columns; the write-side contract for those columns is documented on IEventDatabase.WriteExtendedProgressionAsync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Jul 26, 2026
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #565. Needs to ship as a coordinated set with the Marten and Polecat halves (issues linked below) and Wolverine WO-8.
Problem
When a projection's error policy does not skip, an
ApplyEventExceptionroutes toSubscriptionAgent.ReportCriticalFailureAsync, which hard-stops execution and setsStatus = AgentStatus.Paused. An external supervisor could see that and nothing more:ISubscriptionAgentexposed onlyStatus— no reason.ShardStateTrackerkept its current-state map private; the only public surface wasSubscribe(you had to be listening before the transition) or a blocking wait. No synchronous "what is shard X doing right now" snapshot existed.So Wolverine's
EventSubscriptionAgentand CritterWatch behind it could see progress flatline but not say what to do about it — and the operator action is completely different per cause: a poison event needs a code fix or a skip, a corrupt body needs a serializer/data fix, an unknown event type is usually a deployment gap, and an out-of-order progression means two processes are racing the same shard.What's here
Everything hangs off
SubscriptionAgent.ReportCriticalFailureAsync(Exception)— the single funnel every failure path in the daemon already reached (loadNextAsync, both batch executions, the replay executor).ShardFailureCategory—ApplyEvent,EventSerialization,UnknownEventType,ProgressionOutOfOrder,Other.IEventFailureContext— implemented by exceptions that can name the single event they failed on, and it declares its own category.ApplyEventExceptionimplements it here. The serialization and unknown-event-type exceptions live in the stores (that's where events are read), so Marten and Polecat implement it on theirs; the daemon never sniffs store type names. OnlySequenceis guaranteed — a serialization failure is raised while reading a row, before there is anIEvent, which is why every other member is nullable.EventFailureDetails+ShardFailure— plain serializable values: category, the caught exception type and the root one (the type an operator greps for, the same choiceDeadLetterEvent.ExceptionTypemakes), message, the full detail text, the failing event, timestamp. Deliberately not anException: consumers ship this over a wire, persist it, and render it.ShardState.Exceptionstill carries the live exception for in-process observers.ShardFailure.Forwalks the whole exception graph, because these arrive wrapped in practice — aShardStopExceptionaround anApplyEventException, or anAggregateExceptionof several apply failures (lowest failing sequence wins, since that's where the shard actually stopped).Surface
ISubscriptionAgent.Failureas a default interface member — non-breaking; a wrapper delegates it the way it already delegatesStatus. Set alongsideStatus, cleared onStartAsync/ReplayAsyncso a supervisor never alerts on a failure the operator already fixed.ShardState.Failure, published on the paused/stopped state.PauseReasonkeeps carrying the same full exception text it always did (it isShardFailure.Detail), so string-only consumers are untouched.ShardStateTracker.CurrentState(string)/CurrentState(ShardName)/TryGetCurrentState/CurrentStates()— the map was already maintained; it just wasn't readable.Dead-letter traceability —
DeadLetterEvent.Idis now assigned at construction (version 7, time-ordered; stores only generate an id when the value is empty, so nothing about persistence changes) so the id is known before the background retried write lands.DeadLetterEvent.DescribesSameFailureAs(shardName, failure)is the link between the two halves of a per-event failure. Nothing is written to the dead-letter table when a shard pauses: a paused event was not skipped, so a row there would inflate the counts stores report as their "projection is unhealthy" signal and would be rewritten on every restart attempt. If the same event is later skipped, its dead letter lines up on projection name, shard key, sequence and tenant.Tests
EventTests/Daemon/ShardFailureTests.cs(20 tests): classification per category including the wrapped and aggregate shapes, stream-identity normalization (a string-keyed stream'sGuid.Emptyreports as null rather than rendering a meaningless Guid),Detail/PauseReasonparity, the agent exposing and clearing the reason, the failure riding along on the published state, the tracker snapshot, and the dead-letter correlation (including "unknown tenant doesn't veto a match" and "same sequence in a different tenant is not a match").The store-side contract is exercised through a
FakeStoreEventFailuredouble that implementsIEventFailureContextexactly the way Marten and Polecat will — it doubles as the spec for those PRs.src/EventTests(621) andsrc/EventStoreTests(72) pass on net9.0; full solution builds.Coordinated work
IEventFailureContexton the read-side exceptions + extended progression columns.ISubscriptionAgent.FailurefromEventSubscriptionAgent.Noticed while testing (not fixed here)
ShardStateTracker.WaitForShardStatehas a latent race: it checks the state map once and then waits for the next publication, so a state consumed between the check and the watcher subscribing is missed and the wait runs to its timeout. It bit a test in this PR (worked around by polling the new snapshot instead). Worth its own issue.🤖 Generated with Claude Code