Skip to content

Surface paused-shard failures to the assignment plane + critter stack bump (#3637, #3638, #3519) - #3658

Merged
jeremydmiller merged 1 commit into
mainfrom
feat/3638-shard-failure-surfacing
Jul 26, 2026
Merged

Surface paused-shard failures to the assignment plane + critter stack bump (#3637, #3638, #3519)#3658
jeremydmiller merged 1 commit into
mainfrom
feat/3638-shard-failure-surfacing

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Bumps the critter stack pins and lands the Wolverine half of the WO-8 pause-visibility work that was
deliberately deferred until the JasperFx.Events accessor shipped.

Dependency bump

Package From To
JasperFx / JasperFx.Events / *.SourceGenerator 2.34.0 2.36.1
Marten (+ AspNetCore, Newtonsoft) 9.18.0 9.20.0
Polecat [5.5.0,6.0.0) [5.7.0,6.0.0)

This is the release carrying JasperFx/jasperfx#565 (ShardFailure), #567 (ISubscriptionAgent.Failure),
#534 and #540 — all three issues below need it.

Closes #3637 / #3638 — surface a paused shard's failure, with a reason

When a projection/subscription shard hits an ApplyEventException under a non-skipping error policy the
daemon pauses it. The anti-thrash guards already kept it from being restart-looped, but nothing in the
assignment plane ever distinguished Paused from Running: progress silently flatlined and the only
trace was a log line on one node. ShardFailure now makes the reason portable, so this ships as the
coordinated pair the two issues describe.

  • IEventSubscriptionAgent.Failure — a ShardFailure? default-null DIM;
    EventSubscriptionAgent.Failure delegates to the live inner agent, the same way Status has since
    Managed event-subscription distribution: randomized agent fails first start on multi-(ancillary-)store Marten hosts and wedges in a permanent 30s retry loop #3519. Read-through rather than cached, so a shard that recovers stops reporting a stale reason.
  • Health checks say something actionable — the failure category, the sequence and type of the event
    it died on, and the root exception type, instead of the fixed "Projection {uri} paused due to errors". An inner agent that reports no failure reads exactly as it did before.
  • Failures that can't self-heal are no longer restarted. A shard stopped on ApplyEvent,
    EventSerialization, UnknownEventType or ProgressionOutOfOrder will die on the identical event on
    every restart, so the Managed event-subscription distribution: randomized agent fails first start on multi-(ancillary-)store Marten hosts and wedges in a permanent 30s retry loop #3519 wedge recovery in NodeAgentController.StartAgentAsync now stands down
    for those and surfaces the reason instead. ProgressionOutOfOrder means two processes are on the same
    shard — restarting adds a third contender. Only Other (a database blip, a timeout — what
    restart-on-stall actually exists for) is still retried.
  • Observer / assignment plane — new IWolverineObserver.AgentPaused(Uri, ShardFailure?) default-no-op
    hook plus a NodeRecordType.AgentPaused record carrying the classified reason, so the failure is
    readable from another process and CritterWatch can alert on it (Assert on Publish/SendAsync() if running in MediatorOnly mode #830). A sweep in the health-check loop
    reports locally-owned paused agents on every node, not just the leader — the daemon pauses a shard on
    whichever node owns it. Fires once per transition into failure, and re-arms if the shard recovers and
    later fails anew.

Per the §4 finding on #3637, this takes the observe-only option rather than detaching from
distribution: the anti-thrash guards already hold, and detaching would risk exactly the re-assignment
churn WO-1..7 removed.

Closes #3519 — the wedged first-assignment start

The reported shape: on a multi-store Marten host, one event-subscription agent — a different one on
every boot — failed its very first assignment start and stayed wedged in a permanent 30-second retry loop.
The root causes were daemon-side and land with this bump: the start failure now arrives as a
ShardStartException naming its cause instead of a causeless Exception (#534), and a start that
faults after spinning up the execution pipeline hard-stops the half-started shard rather than orphaning it
(#540), so the next attempt succeeds.

What was left on this side was when that next attempt happens. The node only retried on the next
assignment reevaluation, so the loser of a sub-second startup race sat idle for a full
CheckAssignmentPeriod (30s by default) while its high-water mark climbed. StartAgentAsync now retries a
failed start locally, bounded and configurable:

opts.Durability.AgentStartRetryAttempts = 2;                          // default; 0 restores the old single attempt
opts.Durability.AgentStartRetryDelay = TimeSpan.FromMilliseconds(250); // default, multiplied by attempt number

A failure that outlives the retries is thrown as before with the last cause intact, logged by the existing
per-agent isolation, and picked up on the next reevaluation.

Tests

  • paused_shard_failure_surfacing — 15 tests across wrapper delegation, health-check reporting, restart
    suppression by category, and the local sweep (once-per-transition, re-arm after recovery, silence for
    healthy and for reason-less stops).
  • agent_start_retry_on_startup_race — 4 tests: recovery from a first-attempt race, giving up after the
    configured attempts with the daemon's reason preserved, opt-out, and no extra attempt for a healthy
    agent.
  • Full CoreTests suite green; full wolverine.slnx builds clean against the new pins.

Docs

New "When a Projection Fails" section on the Marten distribution page (categories, what each one means
for an operator, and the three surfaces the reason shows up on) plus "Agent Start Retries", with a
cross-reference from the Polecat page.


🤖 Generated with Claude Code

https://claude.ai/code/session_014DkTEuNQQdPp63WLY86KzC

…, #3638, #3519)

Bumps JasperFx/JasperFx.Events 2.34.0 -> 2.36.1, Marten 9.18.0 -> 9.20.0 and
Polecat [5.5.0,6.0.0) -> [5.7.0,6.0.0), the release carrying #565/#567
(ShardFailure + ISubscriptionAgent.Failure) and #534/#540.

WO-8 (#3637 / #3638): a shard the daemon pauses on an ApplyEventException was
visible only as AgentStatus.Paused, so progress flatlined with nothing
actionable to alert on.

- IEventSubscriptionAgent.Failure (default-null DIM); EventSubscriptionAgent
  delegates it to the live inner agent the way Status has since GH-3519.
- Health checks report the failure category, the failing event's sequence and
  type, and the root exception type instead of a fixed "paused due to errors".
- Failures bound to a specific event (ApplyEvent, EventSerialization,
  UnknownEventType) or to two processes racing one shard
  (ProgressionOutOfOrder) are no longer swept back up by the GH-3519 wedge
  recovery -- they would die on the identical event on every restart. Only
  Other, the category auto-restart exists for, is still retried.
- IWolverineObserver.AgentPaused(uri, failure) default-no-op hook plus a
  NodeRecordType.AgentPaused record, fired once per transition into failure by
  a per-node sweep in the health-check loop. Observe-only, per the analysis in
  #3637 s4: the anti-thrash guards already hold and detaching would risk the
  re-assignment churn WO-1..7 removed.

#3519: the daemon-side causes of the wedged first-assignment start land with
this bump. What was left here was WHEN the retry happens -- the node only
retried on the next assignment reevaluation, so the loser of a sub-second
startup race idled for a full CheckAssignmentPeriod. StartAgentAsync now
retries locally, bounded by Durability.AgentStartRetryAttempts (default 2) and
AgentStartRetryDelay (default 250ms, multiplied by attempt number), preserving
the last cause when it gives up.

19 new tests in CoreTests; full CoreTests (2088) and MartenTests Distribution
(57) green, wolverine.slnx builds clean in Release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DkTEuNQQdPp63WLY86KzC
@jeremydmiller
jeremydmiller merged commit 20a5cbd into main Jul 26, 2026
31 checks passed
portlogicsvn pushed a commit to portlogicsvn/wolverine that referenced this pull request Aug 6, 2026
The JasperFxGH-3698 ledger closed the re-decision hole for first-time assignments and
silently excluded every reassignment. An agent being moved is still listed in
its SOURCE node's persisted ActiveAgents, so Agent.OriginalNode is set, and
applyPendingAssignments skipped it outright:

    if (agent.OriginalNode != null) continue;

The ledger armed on a ReassignAgent but could never apply one, and
TryBuildAssignmentCommand's OriginalNode != null branch consulted no ledger at
all. So the leader re-decided the same move from scratch on every evaluation
until the source's assignment row finally disappeared.

Against the reported cluster shape -- 512 databases x 17 agents across five
nodes, group affinity, three incumbents and two newcomers ramping in -- that is
3,468 reassignment decisions per cycle against a frozen snapshot, repeating
indefinitely. Roughly 13 cycles of that is the reported ~45,000 in six minutes.
Each decision writes an AssignmentChanged row (the observer runs before
batchCommands and before the dispatcher, so nothing downstream dedupes it),
feeding the node-record volume problem of JasperFx#3658 at full rate.

The outcome converged because ReassignAgents carries set-based value equality,
so the dispatcher collapses an identical re-emitted batch while its lane is
busy. That hold ends the moment the batch completes, though, so a re-decision
landing before the snapshot catches up re-runs a real StopAgents round trip
against a source that has already let go.

Three changes:

- applyPendingAssignments ends the wait only on a MATCHING node, so a move in
  flight sets PendingNode and holds its placement.
- TryBuildAssignmentCommand returns false for a move already dispatched and
  still live. PendingRetryDue re-drives one that is never confirmed.
- AgentCommandDispatcher tracks reassignments in a _moving map, keyed to the
  node the agents are moving TO (not the command's lane, which is the source).
  Kept separate from _inFlight because Enqueue must never suppress a
  reassignment -- it carries a stop. Without this the hold falls back to the
  ledger TTL (60s), which a batch's own reply window trivially outlives.

Regression coverage in pending_reassignment_ledger; 4 of the 8 fail without the
fix, the rest guard against over-suppression -- a pause still stops an agent
mid-move, confirmation still clears the ledger, an unconfirmed move is still
re-driven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHAuhdWS3XeAk16swV9G8m
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant