Fixes B2 and B7 from the 3-node PostgreSQL + NATS cluster rehearsal.
B2: a node that boots while NATS is down never gets a coordinator
NatsPubSub::open connected eagerly. When NATS was unreachable, Coordinator::build (crates/coordinator/src/bootstrap.rs) recorded a build error and returned None, and StorageConfig fell back to Coordinator::None for the life of the process. Everything that depends on a coordinator is decided once at startup:
the IPC broadcast channel (build_ipc(has_pubsub));
spawn_broadcast_subscriber, which returns immediately when there is no coordinator;
the publisher;
the task manager's clustered refresh;
the node-id lease renewal.
So cross-node push to that node stayed broken until a restart. The rehearsal saw it still broken 4+ minutes after NATS came back.
Fix. Cargo.lock has async-nats 0.50, which supports ConnectOptions::retry_on_initial_connect(). The client is created at once and connects in the background with the crate's reconnect backoff: immediately, then doubling, at most 4 s apart, and unlimited unless maxReconnects is set. So the node always boots with Coordinator::Nats. Subscriber, publisher and the rest start as normal, and subscriptions made before the connection exists start delivering once it does. Startup never waits on NATS or fails because of it. A config reload no longer blocks on it either.
I chose this over a background rebuild-and-swap of the coordinator because of the startup decisions above. With a swap, every one of them would also have to be redone when the coordinator appears. The lazy client needs none of that.
Not covered: Redis, Kafka and Zenoh coordinators still fail at boot the old way. The rehearsal used NATS.
B7: losing the coordinator was silent
New events (in trc and the packaged schema, ids 644 to 646):
Event
Level
When
cluster.coordinator-connected
info
first connection and every reconnection
cluster.coordinator-disconnected
warn
connection lost; closed; gave up (maxReconnects); or not connected within timeoutConnection at startup (the B2 case)
cluster.coordinator-error
warn
a failed connection attempt, only the first after each connect/disconnect, since the client retries every few seconds; also server errors, slow consumers, lame duck mode
Health.GET /healthz/cluster returns:
200 {"coordinator":"connected"} when connected;
503 {"coordinator":"disconnected"} when a NATS coordinator is configured but not connected;
200 with "none" when there is no coordinator;
200 with "unknown" for backends that don't track their connection (Redis, Kafka, Zenoh).
/healthz/live (the container HEALTHCHECK) and /healthz/ready are deliberately unchanged. A node without its coordinator still accepts, stores and serves mail; only cross-node push and cache invalidation lag. If either endpoint failed while NATS was down, an orchestrator would restart every node, or pull every node out of service, at once, turning a coordinator outage into a mail outage. /healthz/cluster is there for monitoring and alerting, or for a readiness gate where that trade-off is wanted.
The docs site should list /healthz/cluster next to live and ready.
Tests
New: cluster::coordinator::coordinator_reconnect_tests (needs Docker and the nats feature; STORE=RocksDb). Passes.
Start a node against a NATS port with nothing behind it. It boots without build errors, has a coordinator, reports 503 disconnected, and subscribes.
Start a NATS container on that port. The node reports 200 connected, and its early subscription receives a message published by a second client.
Stop NATS: 503 disconnected. Start it again: 200 connected, and the same subscription keeps working.
With LOG=info, the events above appear in that order, with one coordinator-error per outage per client.
The existing cluster::broadcast::cluster_tests (3 nodes, STORE=PostgreSql COORDINATOR=Nats) passes.
tools/fork/*-check.py: clean.
Fixes B2 and B7 from the 3-node PostgreSQL + NATS cluster rehearsal.
## B2: a node that boots while NATS is down never gets a coordinator
`NatsPubSub::open` connected eagerly. When NATS was unreachable, `Coordinator::build` (`crates/coordinator/src/bootstrap.rs`) recorded a build error and returned `None`, and `StorageConfig` fell back to `Coordinator::None` for the life of the process. Everything that depends on a coordinator is decided once at startup:
- the IPC broadcast channel (`build_ipc(has_pubsub)`);
- `spawn_broadcast_subscriber`, which returns immediately when there is no coordinator;
- the publisher;
- the task manager's clustered refresh;
- the node-id lease renewal.
So cross-node push to that node stayed broken until a restart. The rehearsal saw it still broken 4+ minutes after NATS came back.
**Fix.** Cargo.lock has async-nats 0.50, which supports `ConnectOptions::retry_on_initial_connect()`. The client is created at once and connects in the background with the crate's reconnect backoff: immediately, then doubling, at most 4 s apart, and unlimited unless `maxReconnects` is set. So the node always boots with `Coordinator::Nats`. Subscriber, publisher and the rest start as normal, and subscriptions made before the connection exists start delivering once it does. Startup never waits on NATS or fails because of it. A config reload no longer blocks on it either.
I chose this over a background rebuild-and-swap of the coordinator because of the startup decisions above. With a swap, every one of them would also have to be redone when the coordinator appears. The lazy client needs none of that.
**Not covered:** Redis, Kafka and Zenoh coordinators still fail at boot the old way. The rehearsal used NATS.
## B7: losing the coordinator was silent
**New events** (in `trc` and the packaged schema, ids 644 to 646):
| Event | Level | When |
|---|---|---|
| `cluster.coordinator-connected` | info | first connection and every reconnection |
| `cluster.coordinator-disconnected` | warn | connection lost; closed; gave up (`maxReconnects`); or not connected within `timeoutConnection` at startup (the B2 case) |
| `cluster.coordinator-error` | warn | a failed connection attempt, only the first after each connect/disconnect, since the client retries every few seconds; also server errors, slow consumers, lame duck mode |
**Health.** `GET /healthz/cluster` returns:
- `200 {"coordinator":"connected"}` when connected;
- `503 {"coordinator":"disconnected"}` when a NATS coordinator is configured but not connected;
- `200` with `"none"` when there is no coordinator;
- `200` with `"unknown"` for backends that don't track their connection (Redis, Kafka, Zenoh).
`/healthz/live` (the container `HEALTHCHECK`) and `/healthz/ready` are deliberately unchanged. A node without its coordinator still accepts, stores and serves mail; only cross-node push and cache invalidation lag. If either endpoint failed while NATS was down, an orchestrator would restart every node, or pull every node out of service, at once, turning a coordinator outage into a mail outage. `/healthz/cluster` is there for monitoring and alerting, or for a readiness gate where that trade-off is wanted.
The docs site should list `/healthz/cluster` next to live and ready.
## Tests
- New: `cluster::coordinator::coordinator_reconnect_tests` (needs Docker and the `nats` feature; `STORE=RocksDb`). Passes.
1. Start a node against a NATS port with nothing behind it. It boots without build errors, has a coordinator, reports `503 disconnected`, and subscribes.
2. Start a NATS container on that port. The node reports `200 connected`, and its early subscription receives a message published by a second client.
3. Stop NATS: `503 disconnected`. Start it again: `200 connected`, and the same subscription keeps working.
4. With `LOG=info`, the events above appear in that order, with one `coordinator-error` per outage per client.
- The existing `cluster::broadcast::cluster_tests` (3 nodes, `STORE=PostgreSql COORDINATOR=Nats`) passes.
- `tools/fork/*-check.py`: clean.
A node that started while NATS was down never got a coordinator. The
connect failed at boot, bootstrap recorded a build error and the node ran
with Coordinator::None until restarted. It had no broadcast subscriber
or publisher, so cross-node push and cache invalidation to it stayed
broken, and its healthcheck said nothing about it. Losing NATS after
startup was silent too.
- The NATS client now connects in the background
(retry_on_initial_connect): startup never waits on NATS or fails over
it, the node gets its coordinator, subscriber and publisher at once,
and the client keeps trying (async-nats's backoff, at most 4 s apart)
until NATS answers. Subscriptions made meanwhile start delivering when
it does. A configured maxReconnects still ends the attempts.
- Three new events report the connection: cluster.coordinator-connected
(info), cluster.coordinator-disconnected (warn: lost, closed, gave up,
or not connected within the connection timeout at startup) and
cluster.coordinator-error (warn: a failed attempt, reported once per
outage rather than every retry, and server errors, slow consumers and
lame duck mode). They are in the packaged schema, ids 644 to 646.
- GET /healthz/cluster reports the coordinator: 200
{"coordinator":"connected"}, 503 {"coordinator":"disconnected"}, or
200 with "none" (no coordinator) or "unknown" (a backend that doesn't
track its connection). /healthz/live and /healthz/ready are unchanged
on purpose: a node without its coordinator still serves mail, and
failing those would have orchestrators restart, or pull out of
service, every node at once whenever NATS is down.
Only NATS connects lazily; the other coordinator backends still fail at
boot as before.
cluster::coordinator::coordinator_reconnect_tests starts a node against a
NATS port with nothing behind it, checks it boots with a coordinator and
reports it disconnected, subscribes, then starts NATS on that port: the
node connects on its own and the subscription receives a message from a
second client. Stopping and restarting NATS shows disconnected, then
connected, and the same subscription keeps working.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes B2 and B7 from the 3-node PostgreSQL + NATS cluster rehearsal.
B2: a node that boots while NATS is down never gets a coordinator
NatsPubSub::openconnected eagerly. When NATS was unreachable,Coordinator::build(crates/coordinator/src/bootstrap.rs) recorded a build error and returnedNone, andStorageConfigfell back toCoordinator::Nonefor the life of the process. Everything that depends on a coordinator is decided once at startup:build_ipc(has_pubsub));spawn_broadcast_subscriber, which returns immediately when there is no coordinator;So cross-node push to that node stayed broken until a restart. The rehearsal saw it still broken 4+ minutes after NATS came back.
Fix. Cargo.lock has async-nats 0.50, which supports
ConnectOptions::retry_on_initial_connect(). The client is created at once and connects in the background with the crate's reconnect backoff: immediately, then doubling, at most 4 s apart, and unlimited unlessmaxReconnectsis set. So the node always boots withCoordinator::Nats. Subscriber, publisher and the rest start as normal, and subscriptions made before the connection exists start delivering once it does. Startup never waits on NATS or fails because of it. A config reload no longer blocks on it either.I chose this over a background rebuild-and-swap of the coordinator because of the startup decisions above. With a swap, every one of them would also have to be redone when the coordinator appears. The lazy client needs none of that.
Not covered: Redis, Kafka and Zenoh coordinators still fail at boot the old way. The rehearsal used NATS.
B7: losing the coordinator was silent
New events (in
trcand the packaged schema, ids 644 to 646):cluster.coordinator-connectedcluster.coordinator-disconnectedmaxReconnects); or not connected withintimeoutConnectionat startup (the B2 case)cluster.coordinator-errorHealth.
GET /healthz/clusterreturns:200 {"coordinator":"connected"}when connected;503 {"coordinator":"disconnected"}when a NATS coordinator is configured but not connected;200with"none"when there is no coordinator;200with"unknown"for backends that don't track their connection (Redis, Kafka, Zenoh)./healthz/live(the containerHEALTHCHECK) and/healthz/readyare deliberately unchanged. A node without its coordinator still accepts, stores and serves mail; only cross-node push and cache invalidation lag. If either endpoint failed while NATS was down, an orchestrator would restart every node, or pull every node out of service, at once, turning a coordinator outage into a mail outage./healthz/clusteris there for monitoring and alerting, or for a readiness gate where that trade-off is wanted.The docs site should list
/healthz/clusternext to live and ready.Tests
cluster::coordinator::coordinator_reconnect_tests(needs Docker and thenatsfeature;STORE=RocksDb). Passes.503 disconnected, and subscribes.200 connected, and its early subscription receives a message published by a second client.503 disconnected. Start it again:200 connected, and the same subscription keeps working.LOG=info, the events above appear in that order, with onecoordinator-errorper outage per client.cluster::broadcast::cluster_tests(3 nodes,STORE=PostgreSql COORDINATOR=Nats) passes.tools/fork/*-check.py: clean.A node that started while NATS was down never got a coordinator. The connect failed at boot, bootstrap recorded a build error and the node ran with Coordinator::None until restarted. It had no broadcast subscriber or publisher, so cross-node push and cache invalidation to it stayed broken, and its healthcheck said nothing about it. Losing NATS after startup was silent too. - The NATS client now connects in the background (retry_on_initial_connect): startup never waits on NATS or fails over it, the node gets its coordinator, subscriber and publisher at once, and the client keeps trying (async-nats's backoff, at most 4 s apart) until NATS answers. Subscriptions made meanwhile start delivering when it does. A configured maxReconnects still ends the attempts. - Three new events report the connection: cluster.coordinator-connected (info), cluster.coordinator-disconnected (warn: lost, closed, gave up, or not connected within the connection timeout at startup) and cluster.coordinator-error (warn: a failed attempt, reported once per outage rather than every retry, and server errors, slow consumers and lame duck mode). They are in the packaged schema, ids 644 to 646. - GET /healthz/cluster reports the coordinator: 200 {"coordinator":"connected"}, 503 {"coordinator":"disconnected"}, or 200 with "none" (no coordinator) or "unknown" (a backend that doesn't track its connection). /healthz/live and /healthz/ready are unchanged on purpose: a node without its coordinator still serves mail, and failing those would have orchestrators restart, or pull out of service, every node at once whenever NATS is down. Only NATS connects lazily; the other coordinator backends still fail at boot as before. cluster::coordinator::coordinator_reconnect_tests starts a node against a NATS port with nothing behind it, checks it boots with a coordinator and reports it disconnected, subscribes, then starts NATS on that port: the node connects on its own and the subscription receives a message from a second client. Stopping and restarting NATS shows disconnected, then connected, and the same subscription keeps working.8a8f48c944to95f0445d83