Stable node identity + lifecycle for storeless Solo hosts (#3188)#3189
Conversation
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>
|
Pushed reply-routing regression fixes uncovered while validating this change. The #3188 "Solo == node 1" change means every storeless Solo host gets
Redis / Azure Service Bus / AWS SQS already namespace their response endpoint by GCP Pub/Sub has no CI job, which is why its collision wasn't caught automatically — tracked in #3191. |
…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>
Fixes #3188.
Problem
A Solo host with no durable message store (
NullMessageStore) never runsStartSoloModeAsync()— it sits behind the storeless early return instartAgentsAsync()(WolverineRuntime.Agents.cs:148).StartSoloModeAsync()is the only place a Solo host gets a stableAssignedNodeNumber = 1and aNodeStarted()notification, so a storeless Solo host instead keeps the constructor default:…a random value that differs on every process start. Its
WolverineHeartbeatpings (and any node-keyed signal) carry that churning id, and it never emitsNodeStarted()/NodeStopped(). Downstream monitors (CritterWatch JasperFx/CritterWatch#510) see phantom, restart-churning nodes that never reconcile with a lifecycle record.AssignedNodeNumberhas aninternal set, so this has to be fixed inside Wolverine.AssignedNodeNumberNodeStarted()/NodeStopped()1✅NullMessageStoreFix
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):AssignedNodeNumber = 1for a storeless Solo host instartAgentsAsync(), beforestartMessagingTransportsAsync()— envelope ownership and the heartbeat both read it.SoloHeartbeatService). A newIHostedServiceownsNodeStarted()/NodeStopped()for storeless Solo only. It is registered after the runtime hosted service, soNodeStarted()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 isSolowith aNullMessageStore.PersistenceWolverineObserver.NodeStarted()/NodeStopped()now catchNotSupportedException(matchingLostLeadership), so the default observer doesn't throw when a storeless Solo node fires the bookends against aNullMessageStore.Stored Solo (
StartSoloModeAsync) and Balanced are untouched — the new service no-ops for them, so there is no double-NodeStarted/NodeStopped.Tests
solo_storeless_node_identity(CoreTests, no infra):storeless_solo_gets_node_1_and_fires_lifecycle_bookends—AssignedNodeNumber == 1,NodeStarted()once at boot,NodeStopped()once on clean stop.storeless_solo_node_number_is_stable_across_restart— two boot/stop cycles both yield1(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