Skip to content

Stable node identity + lifecycle for storeless Solo hosts (#3188)#3189

Merged
jeremydmiller merged 5 commits into
mainfrom
fix/solo-storeless-node-number-3188
Jun 22, 2026
Merged

Stable node identity + lifecycle for storeless Solo hosts (#3188)#3189
jeremydmiller merged 5 commits into
mainfrom
fix/solo-storeless-node-number-3188

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #3188.

Problem

A Solo host with no durable message store (NullMessageStore) never runs StartSoloModeAsync() — it sits behind the storeless early return in startAgentsAsync() (WolverineRuntime.Agents.cs:148). StartSoloModeAsync() is the only place a Solo host gets a stable AssignedNodeNumber = 1 and a NodeStarted() notification, so a storeless Solo host instead keeps the constructor default:

// DurabilitySettings.cs
public int AssignedNodeNumber { get; internal set; } = Guid.NewGuid().ToString().GetDeterministicHashCode();

…a random value that differs on every process start. Its WolverineHeartbeat pings (and any node-keyed signal) carry that churning id, and it never emits NodeStarted()/NodeStopped(). Downstream monitors (CritterWatch JasperFx/CritterWatch#510) see phantom, restart-churning nodes that never reconcile with a lifecycle record. AssignedNodeNumber has an internal set, so this has to be fixed inside Wolverine.

Host shape AssignedNodeNumber NodeStarted()/NodeStopped()
Solo + durable store 1 fired ✅
Solo + NullMessageStore random per process never fired ❌
Balanced assigned via election fired

Fix

A lightweight, store-independent liveness path for Solo, split so the timing is correct (identity must be set before transports start; the NodeStopped() bookend must fire while transports are still up, since a publish-based observer like CritterWatch emits a record there):

  • Identity (runtime). The runtime now assigns AssignedNodeNumber = 1 for a storeless Solo host in startAgentsAsync(), before startMessagingTransportsAsync() — envelope ownership and the heartbeat both read it.
  • Lifecycle bookends (SoloHeartbeatService). A new IHostedService owns NodeStarted()/NodeStopped() for storeless Solo only. It is registered after the runtime hosted service, so NodeStarted() fires once transports are up and — because hosted services stop in reverse order — NodeStopped() fires before the runtime tears the transports down. No-ops unless the host is Solo with a NullMessageStore.
  • Default observer tolerance. PersistenceWolverineObserver.NodeStarted()/NodeStopped() now catch NotSupportedException (matching LostLeadership), so the default observer doesn't throw when a storeless Solo node fires the bookends against a NullMessageStore.

Stored Solo (StartSoloModeAsync) and Balanced are untouched — the new service no-ops for them, so there is no double-NodeStarted/NodeStopped.

Design note: the issue floated a single host-ordered IHostedService that also sets identity. That can't satisfy both constraints at once — a service registered before the runtime stops after it (transports already gone → NodeStopped publish fails), and one registered after starts after the runtime (identity set too late). Splitting identity (runtime, pre-transport) from the lifecycle bookends (service, registered after the runtime) is what makes the timing correct. Confirmed with the issue author before implementing.

Tests

solo_storeless_node_identity (CoreTests, no infra):

  • storeless_solo_gets_node_1_and_fires_lifecycle_bookendsAssignedNodeNumber == 1, NodeStarted() once at boot, NodeStopped() once on clean stop.
  • storeless_solo_node_number_is_stable_across_restart — two boot/stop cycles both yield 1 (no GUID-hash churn).
  • does_not_fire_for_a_storeless_non_solo_host — a storeless Balanced host gets neither bookend.

The two positive tests fail without the fix (random node number; bookends never fire) and pass with it. Full CoreTests suite green.

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits June 22, 2026 11:23
A Solo host with no durable message store (NullMessageStore) never runs
StartSoloModeAsync() — it sits behind the storeless early return in
startAgentsAsync() — so it kept its random per-process AssignedNodeNumber
and never emitted NodeStarted()/NodeStopped(). Its heartbeats (and any
node-keyed signal) therefore carried a node id that changed on every
restart, producing phantom, churning nodes for downstream monitors.

Give Solo mode a lightweight, store-independent liveness path:

- The runtime now assigns AssignedNodeNumber = 1 for a storeless Solo host
  before the messaging transports start (envelope ownership and the
  heartbeat both read it), so the identity is stable like every stored
  Solo host.
- New SoloHeartbeatService (IHostedService) owns the NodeStarted()/
  NodeStopped() bookends for storeless Solo only. It is registered after
  the runtime hosted service so NodeStarted() fires once transports are up
  and — hosted services stop in reverse — NodeStopped() fires before the
  runtime tears the transports down, so a publish-based observer (e.g.
  CritterWatch) can still emit the final record.
- PersistenceWolverineObserver.NodeStarted()/NodeStopped() now tolerate
  NullMessageStore (catch NotSupportedException), matching LostLeadership,
  so the default observer doesn't throw when a storeless Solo node fires
  the bookends.

Stored Solo (StartSoloModeAsync) and Balanced are untouched; the new
service no-ops unless the host is Solo with a NullMessageStore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #3188 change makes every storeless Solo host AssignedNodeNumber = 1.
NATS and MQTT key their per-node reply endpoint solely on the node number
with no service discriminator:

  NatsTransport:  wolverine.response.{AssignedNodeNumber}
  MqttTransport:  wolverine/response/{AssignedNodeNumber}

so two Solo services sharing a broker now collide on the same reply
subject and cross-deliver each other's responses. This surfaced as the
request/reply compliance suite (sender + receiver are two storeless Solo
hosts on one broker) failing with "Expected one message ... but detected 2".

In Solo mode, key the reply subject/topic on the always-unique UniqueNodeId
(formatted "N" — 32 lowercase hex chars, no dashes, safe for NATS subject
tokens and MQTT topic levels) instead of the always-1 node number. Balanced
nodes get a unique AssignedNodeNumber via election, so they keep the existing,
more readable subject. (Redis/Azure/AWS already namespace by ServiceName.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same regression as NATS/MQTT: the #3188 change makes every storeless Solo
host AssignedNodeNumber = 1, and PubsubTransport keys its per-node response
endpoint solely on the node number with no service discriminator:

  wolverine.response.{Math.Abs(AssignedNodeNumber)}

so two Solo services sharing a project collide on the same response
subscription and cross-deliver each other's replies. Reproduced against the
Pub/Sub emulator: 7 request/reply compliance failures, green after the fix.

In Solo mode, key the response endpoint on the always-unique UniqueNodeId
("N" format) instead of the always-1 node number; Balanced keeps the existing
node-number name. GCP Pub/Sub has no CI job yet, so this went uncaught —
tracked separately in #3191.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremydmiller

Copy link
Copy Markdown
Member Author

Pushed reply-routing regression fixes uncovered while validating this change.

The #3188 "Solo == node 1" change means every storeless Solo host gets AssignedNodeNumber = 1. Transports that key their per-node reply endpoint on the node number with no service discriminator then collide when two Solo services share a broker, cross-delivering each other's replies (request/reply compliance: "Expected one message … but detected 2"). Fixed in Solo mode by keying on the always-unique UniqueNodeId ("N" format — dash-free hex, safe for subject/topic names); Balanced is unchanged (its AssignedNodeNumber is unique via election):

  • NATSNatsTransport (validated against broker: request/reply 12/12, full suite 121/121)
  • MQTTMqttTransport + updated has_response_topic_automatically assertions (validated: request/reply 12/12)
  • GCP Pub/SubPubsubTransport (reproduced against the emulator: 7 request/reply failures → 16/16 green after fix)

Redis / Azure Service Bus / AWS SQS already namespace their response endpoint by ServiceName, so they were unaffected.

GCP Pub/Sub has no CI job, which is why its collision wasn't caught automatically — tracked in #3191.

jeremydmiller and others added 2 commits June 22, 2026 13:10
…3189)

Same regression class as NATS/MQTT/GCP, confirmed by CIAWS failing only on this
branch (green on main and on PRs without the node-1 change) and reproduced locally
against LocalStack: 5/16 request/reply compliance tests timed out.

The #3188 change pins storeless Solo to AssignedNodeNumber = 1. The SQS per-node
response queue is named wolverine.response.{ServiceName}.{node}; the service name is
not unique per host (the compliance sender reuses "SenderService" across fixtures), so
with the node pinned to 1 every Solo host shared one response queue and cross-delivered
each other's replies/acks -> timeouts. The control queue (wolverine.control.{node}) has
the same latent collision.

In Solo mode, key both the response and control queue on the always-unique UniqueNodeId
("N" format); Balanced keeps the election-assigned node number. Local SQS compliance
request/reply now 16/16 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e in Solo (#3189)

Azure Service Bus and Redis use the same per-node response naming as Amazon SQS
(wolverine.response.{ServiceName}.{node}), plus node-keyed control queues, so they
share the same latent Solo collision: with #3188 pinning the node to 1 and the
service name not unique per host, multiple Solo hosts on one broker would share a
response endpoint and cross-deliver replies. (Their CI happened not to exercise it,
but the product code was vulnerable.)

Key the response (and Azure control) endpoints on the always-unique UniqueNodeId in
Solo mode; Balanced keeps the election-assigned node number. Validated locally:
Azure SB request/reply compliance 20/20, Redis 8/8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit dc034f6 into main Jun 22, 2026
26 checks passed
This was referenced Jun 23, 2026
This was referenced Jul 9, 2026
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.

Lightweight Solo-mode heartbeat/liveness process (stable node identity + lifecycle without the agent controller)

1 participant