Skip to content

fix(protocol): worker connectivity watchdog retries bootstrap + relay reservation - #67

Merged
xzyaoi merged 6 commits into
eth-easl:mainfrom
robmsmt:fix/worker-connectivity-watchdog
Nov 2, 2026
Merged

xzyaoi merged 6 commits into
eth-easl:mainfrom
robmsmt:fix/worker-connectivity-watchdog

Conversation

@robmsmt

@robmsmt robmsmt commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

A worker (no public-addr) is routable from the head only if it holds a bootstrap connection and a live relay reservation. Both are attempted once at startup (reservation: 10 s after the CRDT store starts, only over peers connected at that moment). If that races the network, the worker stays locally healthy (its framework /health is 200, /v1/self answers) but the gateway cannot route to it.

Recovery was unreliable:

  • startAutoReconnect only acts when there are zero connections, and doesn't make reservations.
  • Reconnect() (which re-reserves) fires with 50% probability per minute.
  • Every failure on those paths logs at Debug, and the startup banner bootstrap_connected is a one-time snapshot, so nothing is visible.

Observed on Clariden (SwissAI): 5 of the last 10 SLURM jobs started with bootstrap_connected=false, across the same nodes that connected fine in other jobs. One 2-node job ran >1 h locally healthy but never became routable; the model disappeared from the serving API.

Change

  • New connectivity.go: a watchdog (workers only) that, until bootstrap is connected and a relay reservation is held, retries the bootstrap dial and MakeRelayReservations with 5 s..60 s jittered backoff. Once healthy it re-checks every 30 s.
  • Tracks reservation expiry, so a lapsed reservation is re-made.
  • State transitions logged at Info/Warn (previously invisible).
  • Exports IsBootstrapConnected() / HasRelayReservation() so callers (e.g. a health checker) can tell "locally up" from "routable". Not wired to any endpoint here; happy to add one if you'd like.

Testing

  • connectivity_test.go: unit tests for the watchdog step logic (side-effect injection) and reservation expiry.
  • go test ./internal/protocol/ passes (with -race).
  • Not reproduced end-to-end: the original failure can't be forced, and the failing step isn't logged. The diagnosis is from reading the code paths above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XjjN5e5xxxaFYKQcW51ytb

… reservation

A worker is only routable from the head if it holds a bootstrap connection
and a live relay reservation. Both were attempted once at startup (the
reservation 10 s after the CRDT store starts); if that raced the network,
the node stayed locally healthy but unreachable. Recovery relied on
Reconnect() firing with 50% probability per minute, and the auto-reconnect
loop only acts when there are zero connections. All failures logged at Debug,
so nothing was visible.

Add a watchdog (workers only) that, until both conditions hold, retries the
bootstrap dial and relay reservation with 5s..60s jittered backoff, and logs
state transitions at Info/Warn. Track reservation expiry so a lapsed
reservation is re-made. Export IsBootstrapConnected/HasRelayReservation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjjN5e5xxxaFYKQcW51ytb
…ations to the advertised relay

Review follow-ups for the worker connectivity watchdog:

- Cache resolved bootstrap peers. Health checks are now in-memory reads;
  discovery (HTTP/DNS) runs only on refresh, reconnect, or when the cache
  is older than 10 minutes. An empty or failed resolution keeps the last
  known set, so a discovery outage no longer marks a connected node as
  disconnected.
- Parse bootstrap multiaddrs one by one, skipping entries without a
  /p2p/<id> component instead of failing the whole batch.
- Track reservations per relay peer and drop them when the last
  connection to that relay closes. "Relay reserved" now means the
  RelayPeer advertised in the CRDT is connected and holds an unexpired
  reservation, or AutoRelay advertises a circuit address through it.
- Keep the advertised relay stable while it is still valid; otherwise
  prefer a freshly reserved bootstrap peer.
- Guard MakeRelayReservations against overlapping runs from startup,
  Reconnect() and the watchdog.
- Start the watchdog under the same condition as AutoRelay, and not in
  standalone mode. With no bootstrap peers resolved, log once and back
  off without making reservations.
- Tests restore the package-global state they touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E5X1BDH1J2XKKHPEPX3D1G
@xzyaoi

xzyaoi commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this, the diagnosis is spot on. I reviewed it and pushed a follow-up commit (07b8361) to this branch addressing the main issues. Summary:

Issues found in the original watchdog

  1. bootstrapConnected re-resolved the bootstrap list on every check. With the default bootstrap.static, that is three HTTP fetches (5 s timeout each) every 30 s per worker, plus another resolution in tryReconnectToBootstraps.
  2. If discovery was down or returned nothing, the check reported bootstrap_connected=false even with live connections to the heads.
  3. peer.AddrInfosFromP2pAddrs fails the whole batch on one address without /p2p/<id>, so a single bad entry made the check fail forever.
  4. Reservation state was a single global max-expiry that was never cleared on disconnect, so it could report "reserved" for up to an hour after the relay restarted.
  5. A reservation on any peer counted, even one that is not the RelayPeer advertised in the CRDT. Heads could still fail to route, which is the case this PR targets.
  6. Several MakeRelayReservations runs could overlap (startup goroutine, Reconnect()'s delayed call, the watchdog) and race on SetMyselfRelayPeer.
  7. The watchdog also started in standalone mode and for reachability=public nodes, where it can never succeed.

What the follow-up commit changes

  • Bootstrap peers are cached. Health checks are in-memory reads. Discovery runs only on startup, on reconnect, or when the cache is older than 10 min. An empty or failed resolution keeps the last known set.
  • Bootstrap multiaddrs are parsed one by one, and bad entries are skipped.
  • Reservations are tracked per relay and dropped when the last connection to that relay closes (DisconnectedF).
  • "Relay reserved" now means: the advertised RelayPeer is connected and has an unexpired manual reservation, or AutoRelay advertises a circuit address through it (via EvtAutoRelayAddrsUpdated).
  • MakeRelayReservations keeps the current RelayPeer while it is still valid. Otherwise it prefers a freshly reserved bootstrap peer. This avoids churn from peer iteration order.
  • MakeRelayReservations has a single-flight guard.
  • The watchdog starts under the same condition as AutoRelay and not in standalone mode. With no bootstrap peers resolved, it logs once and backs off without reserving.
  • Tests restore the package-global state they touch. There are new tests for bad bootstrap entries, cache retention, per-relay validity, disconnect, the AutoRelay circuit path, and advertised-relay binding.

go test -race ./internal/protocol/ and go vet pass locally. I could not run golangci-lint locally because of a toolchain mismatch, so CI lint still needs to confirm. This has not been tested end to end.

Possible follow-ups, not in this PR

  • MakeRelayReservations still tries every connected peer serially with a 10 s timeout each. Limiting it to bootstrap/relay-role peers and running in parallel would bound each step.
  • IsBootstrapConnected / HasRelayReservation still have no callers. Exposing a routable field in /v1/self would give exactly the signal that was missing on Clariden.

xzyaoi and others added 4 commits November 2, 2026 14:21
… NAT mesh test

MakeRelayReservations tried every connected peer serially with a 10 s
timeout each, so one watchdog step could block for minutes. Every node
runs a relay service, so it also reserved on other NATed workers, which
heads cannot route through (AutoRelay already covers worker-to-worker
circuits).

- Reserve only on bootstrap peers and peers the node table marks as
  relay/head or with a public address; fall back to all connected peers
  when none qualifies.
- Run reservations in parallel (at most 8 in flight, 10 s timeout each).
  Successes are returned in candidate order, so pickAdvertisedRelay's
  preference is unchanged.

Add TestRelayWatchdogMesh (integration tag): two isolated docker bridge
networks simulate a public network and an HPC private network, so the
head can reach workers only through the relay. It covers a baseline
worker, a worker started while the relay is unreachable, and a relay
restart while the workers stay connected to each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E6QF8VP2TPTQ0EE3FE7J09
…ity probe

The p2p forward error handler writes "ERROR: ..." with status 200, so a
status check alone treated failed forwards as success. Require the
worker's {"status":"ok"} health body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E7G9T32HJGVCJNDW5DEJPM
ErrorHandler wrote "ERROR: <reason>" without setting a status, so the
response went out as 200. Clients and health checks took failed
forwards for success. A relay that could not reach a worker also
answered the head with 200, and the head passed that body to the
client as a successful response.

- ErrorHandler now responds 504 on deadline errors and 502 otherwise.
  The body keeps the "ERROR: <reason>" text. Responses carry
  X-Otela-Proxy-Error: 1 to mark them as generated by the proxy.
- The service-forward retry path reuses ErrorHandler.
- On a relay hop, the head treats a marked response from the relay as
  a transport failure and retries another worker. An unmarked 5xx from
  the worker's own service is still passed through.
- The docker NAT mesh test asserts that a forward to an unreachable
  peer returns a marked 5xx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E7XGGN2KSSE0GY9YX8RBD7
… test

The pub network used an RFC 1918 subnet. AutoRelay drops a relay's
private addresses when building circuit addresses, so workers
advertised no /p2p-circuit address. The head could never dial them
directly, and the test only covered the HTTP relay hop, which does not
depend on reservations. Code from before this PR passed the test.

- Use 11.213.10.0/24, which libp2p treats as public, for the pub
  network.
- Require both the relay hop and a direct circuit dial from the head
  in every scenario.

With this change, code from before the PR fails the relay-restart
scenario in 2 of 2 runs: the relay hop recovers, but the circuit dial
fails for 90 s. This PR passes all scenarios in 3 of 3 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E99Z6NMQVKN8SQNBJWWMXN
@xzyaoi
xzyaoi merged commit 5c4bbdb into eth-easl:main Nov 2, 2026
2 checks passed
xzyaoi added a commit that referenced this pull request Nov 2, 2026
* feat(identity): attest wallet-less nodes from a derived deploy key

Wallet-less nodes linked to OpenTela Cloud via a deploy key previously
published no identity attestation, so the cloud's ownership checks and
peers' trust computation saw an inconsistent record. Derive a stable
Ed25519 key from the deploy key (domain-separated SHA-256 -> seed) and
sign the identity attestation with it, so Owner == attested pubkey and
the signature verifies without a Solana wallet.

- wallet.DeriveIdentityKeyFromDeployKey: deterministic, path-independent.
- wallet.SignIdentityWithKey: sign with a raw Ed25519 key.
- protocol.deployKeyIdentity + InitializeMyself fallback.
- tests for derivation stability and consistent record.

Entire-Checkpoint: 01M4TC34XQTYJ5E62907C3BWWD

* feat(cli): seedable identity generation for 'otela init'

'otela init' pre-generates the libp2p identity key used by --config-dir
and the mesh-bench runner, but had no way to control it: the key was
always random, so a later 'start --seed N' just loaded that random key
and the seed had no effect.

Add --seed to 'otela init' and generate a reproducible, distinct PeerID
from a non-zero seed. To make the seed actually reproducible, the seeded
path now uses Ed25519: Go's RSA generation is deliberately non-
deterministic from a seeded reader, so the old RSA-based --seed never
produced a stable PeerID. RSA is retained for the seed=0 random path,
and existing persisted keys are always loaded unchanged.

- protocol.GenerateKeyFromSeed (Ed25519) and GenerateAndWriteKey(seed).
- newHost reuses the shared keygen instead of duplicating it.
- init_test: same seed => same key, different seed => different key.
- docs: correct the false '--seed 0 is deterministic' claim.

Entire-Checkpoint: 01M4TC3GYSW0FQSHZM0RJJE6S4

* feat(cli): make boot-time instance link opt-in (--auto-link)

'otela start' used to re-link the node with any stored deploy key on
every boot. Same-peer re-links are free, but each new peer ID spends a
key use, so nodes that mint fresh identities (e.g. --ephemeral-identity
on Slurm) drained the key on every restart and left phantom instances.

Linking at boot now requires instance.auto_link (--auto-link /
OF_INSTANCE_AUTO_LINK=true). An explicit 'otela start --deploy-key'
still links that run. Without either, a stored key only logs a hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8V0Q98CKK4XRXFRJYYWCE

* fix(protocol): worker connectivity watchdog retries bootstrap + relay reservation (#67)

* fix(protocol): worker connectivity watchdog retries bootstrap + relay reservation

A worker is only routable from the head if it holds a bootstrap connection
and a live relay reservation. Both were attempted once at startup (the
reservation 10 s after the CRDT store starts); if that raced the network,
the node stayed locally healthy but unreachable. Recovery relied on
Reconnect() firing with 50% probability per minute, and the auto-reconnect
loop only acts when there are zero connections. All failures logged at Debug,
so nothing was visible.

Add a watchdog (workers only) that, until both conditions hold, retries the
bootstrap dial and relay reservation with 5s..60s jittered backoff, and logs
state transitions at Info/Warn. Track reservation expiry so a lapsed
reservation is re-made. Export IsBootstrapConnected/HasRelayReservation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjjN5e5xxxaFYKQcW51ytb

* fix(protocol): make connectivity watchdog checks cheap and tie reservations to the advertised relay

Review follow-ups for the worker connectivity watchdog:

- Cache resolved bootstrap peers. Health checks are now in-memory reads;
  discovery (HTTP/DNS) runs only on refresh, reconnect, or when the cache
  is older than 10 minutes. An empty or failed resolution keeps the last
  known set, so a discovery outage no longer marks a connected node as
  disconnected.
- Parse bootstrap multiaddrs one by one, skipping entries without a
  /p2p/<id> component instead of failing the whole batch.
- Track reservations per relay peer and drop them when the last
  connection to that relay closes. "Relay reserved" now means the
  RelayPeer advertised in the CRDT is connected and holds an unexpired
  reservation, or AutoRelay advertises a circuit address through it.
- Keep the advertised relay stable while it is still valid; otherwise
  prefer a freshly reserved bootstrap peer.
- Guard MakeRelayReservations against overlapping runs from startup,
  Reconnect() and the watchdog.
- Start the watchdog under the same condition as AutoRelay, and not in
  standalone mode. With no bootstrap peers resolved, log once and back
  off without making reservations.
- Tests restore the package-global state they touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E5X1BDH1J2XKKHPEPX3D1G

* fix(protocol): reserve on relay-capable peers in parallel; add docker NAT mesh test

MakeRelayReservations tried every connected peer serially with a 10 s
timeout each, so one watchdog step could block for minutes. Every node
runs a relay service, so it also reserved on other NATed workers, which
heads cannot route through (AutoRelay already covers worker-to-worker
circuits).

- Reserve only on bootstrap peers and peers the node table marks as
  relay/head or with a public address; fall back to all connected peers
  when none qualifies.
- Run reservations in parallel (at most 8 in flight, 10 s timeout each).
  Successes are returned in candidate order, so pickAdvertisedRelay's
  preference is unchanged.

Add TestRelayWatchdogMesh (integration tag): two isolated docker bridge
networks simulate a public network and an HPC private network, so the
head can reach workers only through the relay. It covers a baseline
worker, a worker started while the relay is unreachable, and a relay
restart while the workers stay connected to each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E6QF8VP2TPTQ0EE3FE7J09

* test(integration): check the response body in the relay mesh routability probe

The p2p forward error handler writes "ERROR: ..." with status 200, so a
status check alone treated failed forwards as success. Require the
worker's {"status":"ok"} health body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E7G9T32HJGVCJNDW5DEJPM

* fix(server): report failed p2p forwards as 502/504 instead of 200

ErrorHandler wrote "ERROR: <reason>" without setting a status, so the
response went out as 200. Clients and health checks took failed
forwards for success. A relay that could not reach a worker also
answered the head with 200, and the head passed that body to the
client as a successful response.

- ErrorHandler now responds 504 on deadline errors and 502 otherwise.
  The body keeps the "ERROR: <reason>" text. Responses carry
  X-Otela-Proxy-Error: 1 to mark them as generated by the proxy.
- The service-forward retry path reuses ErrorHandler.
- On a relay hop, the head treats a marked response from the relay as
  a transport failure and retries another worker. An unmarked 5xx from
  the worker's own service is still passed through.
- The docker NAT mesh test asserts that a forward to an unreachable
  peer returns a marked 5xx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E7XGGN2KSSE0GY9YX8RBD7

* test(integration): exercise the relay v2 circuit path in the NAT mesh test

The pub network used an RFC 1918 subnet. AutoRelay drops a relay's
private addresses when building circuit addresses, so workers
advertised no /p2p-circuit address. The head could never dial them
directly, and the test only covered the HTTP relay hop, which does not
depend on reservations. Code from before this PR passed the test.

- Use 11.213.10.0/24, which libp2p treats as public, for the pub
  network.
- Require both the relay hop and a direct circuit dial from the head
  in every scenario.

With this change, code from before the PR fails the relay-restart
scenario in 2 of 2 runs: the relay hop recovers, but the circuit dial
fails for 90 s. This PR passes all scenarios in 3 of 3 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVoeKQvu4rZ1PqgzEWKv
Entire-Checkpoint: 01M6E99Z6NMQVKN8SQNBJWWMXN

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Xiaozhe Yao <askxzyao@gmail.com>

* start: --ephemeral-identity and --datadir for nodes sharing a $HOME (#68)

* start: --ephemeral-identity and --datadir for nodes sharing a $HOME

Nodes that share one home directory (e.g. the nodes of a multi-node Slurm
job on a shared filesystem) all load the same ~/.config/opentela/keys/id and
announce the same peer ID; --seed does not help, since a seed is only used
when no key file exists yet. They also all write their peer databases to
~/.ocfcore, which fills the home quota when nodes are killed before they can
clean up.

- --ephemeral-identity: the node uses an in-memory identity generated once
  per process; no key file is read or written. Overrides --seed. The
  instance link (deploy key) signs with the same in-memory key, so the linked
  peer is the one that serves. Warns when used without --datadir.
- --datadir (or OF_DATADIR): the existing datadir setting (usage store) now
  also moves the peer database to <datadir>/ocfcore. Unset keeps ~/.ocfcore.

Neither flag is bound with viper.BindPFlag: bound keys are written into the
cfg.yaml seeded on first run, so a one-off --ephemeral-identity or temporary
--datadir would stick to every later start. initConfig applies them per run,
like --seed.

Identity selection moves out of newHost into loadOrCreateIdentity so it can
be unit-tested without starting a libp2p host.

* start: skip bootAccount for --component ingress

An ingress-only process never brings up a libp2p host, so linking its
identity (especially an ephemeral one) only spends deploy-key uses and
leaves phantom instances in the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8BSW3HH9JZF5S51EM9B6V

* ephemeral-identity: one identity resolver, env form, temp datadir

Review follow-ups for --ephemeral-identity / --datadir:

- protocol.NodeIdentity(seed) is now the single resolver for both the
  libp2p host and nodeLinkSigner, so linking and serving cannot drift
  (nodeLinkSigner previously re-implemented the selection and ignored
  the seed). ConfiguredSeed() shares the seed parsing.
- One newKey helper for persisted, seeded and ephemeral keys.
- OF_EPHEMERAL_IDENTITY enables the flag; the hyphenated viper key was
  unreachable via AutomaticEnv. Bound after the first-run cfg.yaml seed,
  so it never persists.
- With --ephemeral-identity and no --datadir, node data goes to a
  per-process temp dir instead of piling up in the shared ~/.ocfcore.
- datadir expands a leading ~ (common.DataDir), for both the peer
  database and the usage store.
- `otela peer-id` warns on stderr when ephemeral identity is enabled.
- Drop the dead connection-manager error branch in newHost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8Q1ECER7DRR2Q2HTF3CZD

* instance link: refuse seeded and ephemeral identities

- nodeLinkSigner refuses a seeded identity: its key is derivable from a
  small integer, so linking it would let anyone who guesses the seed
  sign as an account-linked peer. Seeds stay for tests and benchmarks.
- 'otela instance link' refuses when OF_EPHEMERAL_IDENTITY is set: the
  identity dies with the command, so the link would spend a deploy-key
  use on a peer that never comes online. Boot-time links by the serving
  node are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RijVobFYfzE6YvveLwKteU
Entire-Checkpoint: 01M6E8Z0MWVH1H60DHPYH383PN

---------

Co-authored-by: Xiaozhe Yao <askxzyao@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Rob <rob@robmsmt.com>
Co-authored-by: Mel Zuercher <mel.zuercher@ai.ethz.ch>
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.

2 participants