Skip to content

Adopt spawned v0.7: ActorSystem shutdown, supervision, test mocks, metrics and a testing roadmap - #783

Draft
unbalancedparentheses wants to merge 12 commits into
mainfrom
spawned-v0.7
Draft

unbalancedparentheses wants to merge 12 commits into
mainfrom
spawned-v0.7

Conversation

@unbalancedparentheses

Copy link
Copy Markdown
Contributor

Draft, not for merge yet. This is the single place where we post the spawned v0.7 adoption, the testing plan and every update (spawned releases, canary results, bugs the tests catch). Read the summary, then the commits you care about; each commit stands alone.

Why

  • Shutdown: on main, SIGTERM (what docker stop sends) has no handler, so the node dies at once with no graceful shutdown; SIGINT exits in 8-11 s. Users were told to raise Docker's stop timeout (1d7902d).
  • No supervision: a crashed actor stays dead.
  • Tests stub whole protocols by hand (four copies of a P2PToBlockChain recorder).
  • No runtime visibility into actor mailboxes, queue wait or exits.

What changes

Commit What it does
chore: upgrade spawned to v0.7 Dependency bump only; the 0.5 protocols and handlers compile unchanged.
refactor: own actors in an ActorSystem with a shutdown deadline One ActorSystem: the chain actor in a one-thread group (same isolation as Backend::Thread), P2P in an async group. SIGINT and SIGTERM trigger shutdown with an 8 s deadline; on a miss it names the unfinished groups/actors and exits 1. discv5 binds before any actor starts; P2P (ingress) stops before the chain.
feat: supervise restartable actors The HTTP server under a OneForOne supervisor (Permanent, 3 restarts / 60 s, backoff 1-10 s). Chain and P2P stay unsupervised: their state is not rebuilt and other actors hold their references (reasons in code comments). Optional fault injection: ETHLAMBDA_FAULT_INJECTION=1 + SIGUSR1 crashes the HTTP server.
test: replace hand-written protocol stubs with spawned-test mocks All four hand-written chain stand-ins become spawned-test mocks; "nothing reaches the chain" tests now fail on any message.
feat: export spawned actor metrics on /metrics spawned_* series: mailbox depth, queue wait, handler duration, exits by reason.
chore: pin spawned to the v0.7.0 release plus spawned-test Pins spawned at v0.7.0 plus the not-yet-released spawned-test crate (lambdaclass/spawned#198).
docs: add a testing and verification roadmap docs/testing_roadmap.md: testing ethlambda the way FoundationDB, TigerBeetle, Dropbox, etcd and AWS test their systems. First step: a seeded in-process chain-logic simulator.

Per-method request timeouts were not needed: ethlambda makes no request/reply actor calls.

Measured so far (local)

Binary Signal Result
main SIGINT clean exit in 8.4-10.9 s
main SIGTERM killed in 0.01 s, no graceful shutdown
this branch SIGINT clean exit in 7.7 s
this branch SIGTERM exit 1 at 8.0 s naming chain: its first tick replays fork choice slot by slot from genesis (existing behaviour; checkpoint-synced nodes avoid it)

Fault injection on the real binary: HTTP server back in 0.17-0.28 s, /metrics answering again.

A canary on a dedicated server is running now; results will be posted here.

Reading guide

Known follow-ups

  • ethrex's p2p crate (used here for discv5) still links spawned 0.5, so the binary carries both versions until ethrex upgrades (in progress).
  • spawned feedback from this branch (supervision logging, named actors in the shutdown report, abnormal exit without panic, Prometheus _max exposition bug) is tracked in spawned.

Point spawned-concurrency and spawned-rt at the unreleased v0.7 candidate
(lambdaclass/spawned fd28d2e9: main 32796a88 plus the spawned-test crate),
and declare spawned-test at the same revision so every spawned crate shares
one set of types.

Mechanical only: ethlambda's 0.5 protocol and handler signatures compile
unchanged against v0.7, both crates keep their default features (which now
include `async`), and every actor still starts the way it did. Nothing is
placed in an ActorSystem yet.
Startup and shutdown were wired by hand: the chain actor on its own
`Backend::Thread`, P2P on the shared runtime, and on ctrl-c both were
stopped and joined with no deadline. A clean shutdown during catch-up took
14.3 s, past Docker's 10 s grace period (1d7902d), and only SIGINT was
handled, so `docker stop` (SIGTERM) killed the node outright unless it was
run with `--stop-signal SIGINT`.

Now one ActorSystem owns both actors and their threads:

- `chain`: a one-worker `thread_group`, the same isolation the chain actor
  had on `Backend::Thread`: one dedicated native thread for its long
  synchronous handlers, plus a one-worker Tokio runtime for its timers and
  the aggregation worker's `spawn_blocking`.
- `network`: an `async_group` for P2P, sized like the default Tokio runtime
  it used to share (one worker under Shadow).

The chain actor is admitted before P2P, so the system's reverse-admission
shutdown stops P2P, the ingress, before the actor it feeds. discv5 now
binds its socket in `run_node` before any actor starts, so a busy port
still aborts startup with nothing running; `P2P::spawn` takes the handle
and an execution group, `BlockChain::spawn{,_beacon}` take a group.

Shutdown answers SIGTERM as well as SIGINT and is bounded by an 8 s
deadline: the HTTP server gets 2 s to close (an open SSE stream could
otherwise hold it), then the system stops the actors with what is left. A
shutdown that misses the deadline logs the unfinished groups and actors by
name and exits 1 instead of waiting. Repeated ctrl-c still forces an exit.
Put the HTTP server (API, metrics and debug routes) under a OneForOne
supervisor in the actor system. It was a bare `tokio::spawn`: a failure
logged one error and the node ran on with no API and no metrics. It is
now an `HttpServer` actor, admitted on the `network` group, that serves on
an owned helper and stops when the server returns, so the supervisor
builds a fresh one. Each run builds its router afresh from cloned handles,
which is what makes a restart safe; what one loses is per connection (open
event streams, Beacon API fee recipients until the validator client
re-registers them).

Budget: Permanent, at most 3 restarts a minute, backoff from 1 s to 10 s.
A server that keeps failing (its port taken by another process) exhausts
it, the supervisor gives up with an error log, and the node runs on
without HTTP, as it did before. Restarts and the give-up are logged by a
watcher, since spawned logs neither. The tree is admitted last, so
shutdown stops it first, with 2 s of the 8 s deadline.

The chain and P2P actors stay unsupervised, with the reasons in
`run_node`: the chain actor's in-memory state, including the validator
keys' prepared signing state, is not rebuilt from the store, and P2P owns
the swarm and discv5 sockets built once at startup; both are referenced
by the other actors through refs a replacement would not rebind.

For test nodes only, `ETHLAMBDA_FAULT_INJECTION=1` makes SIGUSR1 crash the
current HTTP server with a real handler panic, to exercise recovery.
The p2p forwarding tests stood in for the chain actor with four
hand-written `P2PToBlockChain` impls: `RecordingChain` in column_checks.rs
and in test_support, `Recorder` in envelope_checks.rs (also used by
envelope_client.rs) and `GloasRecorder` in verdict.rs. Each implemented all
eight protocol methods to record one or two of them.

They are now `spawned_test::tasks::Mock` actors converted with
`to_p2p_to_block_chain_ref()`, so the code under test still holds the
protocol ref it holds in production:

- `Mock::new().allow_sends()` logs every send; tests read what they care
  about with `expect` / `take_all` and the arrival order with `received()`.
- Tests asserting that nothing reaches the chain actor use a strict
  `Mock::new()`, which fails on any message at all rather than only the
  one method the old stub recorded.

The stubs were synchronous; a mock is an actor, so tests settle it (or
expect the last send) before reading the log. Where the old tests closed a
channel to prove nothing followed, the new ones rely on the code's own
ordering (the parked batch is the task's last send; the range release
fires after the last envelope) and assert the complete log.
Enable spawned-concurrency's `metrics` feature and append its Prometheus
text to the existing `/metrics` response: mailbox depth, queue wait,
handler duration, request outcomes and actor lifecycle (spawned, exited
by reason), all prefixed `spawned_` and summed over every actor in the
process. They are what to watch on a canary of the v0.7 runtime: a
restarted HTTP server shows as an exit and a new spawn, and a stalled
chain actor as queue wait and mailbox depth.

Collection is the library's fixed set of atomic counters, timing every
message (the default sampling); no exporter or labels are added.
@unbalancedparentheses

Copy link
Copy Markdown
Contributor Author

Canary on a dedicated server (2026-11-09)

Mainnet beacon follower, checkpoint-synced, main (c3f5f56) and this branch (77db8d2) side by side, 54-61 peers each; plus a local 3-node lean devnet for genesis-anchored shutdowns and fault injection under 4 s slots. Execution payloads were not validated (no execution client: disk budget).

main this branch
Binary size 45.17 MB 46.56 MB (+3.1%)
CPU mean / p95 24.1% / 28.9% 24.2% / 29.4%
RSS first → last 10% of 45 min 1740 → 1981 MB 1738 → 1958 MB
Peers (mean) 60.0 59.9
Head +223 slots, sync distance ≤ 1 identical
SIGTERM ×3 killed (exit 143), no shutdown exit 0, Shutdown complete in 93-102 ms
SIGINT exit 0, 52-1266 ms exit 0, 72-1081 ms
  • Genesis-anchored (lean, ~1000 slots behind): SIGTERM 16 and 6 ms, SIGINT 8 ms on this branch; main killed on SIGTERM. The 8 s deadline was never hit.
  • Actor metrics (mainnet): mailbox depth 0; 621k messages; queue wait mean 17 µs (p99.9 ≤ 512 µs); handler mean 0.13 ms (p99.9 ≤ 2 ms). On the lean devnet the chain actor's handler p99 reached ~2 s (post-quantum signature work on its dedicated thread); the node kept up.
  • Fault injection: 6 HTTP-server crashes within budget all recovered (HTTP back in 0.25-1.43 s, panic counter +1 each). Exceeding the budget made the supervisor give up as designed; the node kept importing blocks, and SIGTERM still exited cleanly in 100 ms.
  • No regression vs main in CPU, memory, peers or head progress over 45 minutes. A multi-hour soak of this branch is still running.

Found along the way (not caused by this PR):

  1. A lean node rejoining from genesis proposes a block on top of genesis before it has synced (slot 1001), then reorgs; its attestations make peers log Head checkpoint … does not descend from finalized block.
  2. A shutdown during block proposal drops the block (Failed to publish block … Actor stopped), because P2P stops before the chain actor. Possible fix: let the chain actor finish or abandon an in-flight proposal before P2P stops.

spawned feedback (being fixed upstream): giving up waits a full backoff before failing; a child reports Stopping instead of failed after a give-up; restart latency is hard to predict from the config.

This branch has since been repinned to spawned main b1a17e6c (canary feedback fixes: structured supervision logs, named shutdown reports, stop_with_reason, drop-panic fixes); a rerun on that revision follows.

@unbalancedparentheses

Copy link
Copy Markdown
Contributor Author

Known issue on this branch: a shutdown during block proposal drops the block

During the canary, two lean nodes logged ERROR Failed to publish block … err=Actor stopped when shut down mid-proposal (the proposal also stretched those shutdowns to 117 ms and 282 ms). This comes from this branch's shutdown order: P2P (the ingress) is admitted after the chain actor, so it stops first, and a block the chain actor is still building has nowhere to go.

Proposed fix before this PR is ready: let the chain actor finish or explicitly abandon an in-flight proposal before P2P stops (e.g. stop accepting new duties, wait for the current proposal with a short bound inside the 8 s deadline, then stop P2P), and log an abandoned proposal as a warning instead of an error.

Related issues found by the same canary: #784 (SIGTERM not handled on main), #785 (node restarted from genesis proposes before syncing), #786 (debug-build test overflow).

@unbalancedparentheses

Copy link
Copy Markdown
Contributor Author

Bumped spawned to 0.7.1 from crates.io (c58cd82), dropping the git pin. Gates on Linux x86_64: cargo test --locked --profile release-fast --workspace 2920 passed, 0 failed; make lint clean. In a plain debug cargo test --workspace, 60 ethlambda-rpc tests fail with attempt to add with overflow at crates/net/rpc/src/beacon/ptc.rs:574 and validator.rs:896 (H256::repeat_byte(slot as u8 + 1) overflows at slot 255). That's a pre-existing test-fixture bug unrelated to spawned: it fails identically on the previous pin, and release-fast (what make test uses) is unaffected.

@unbalancedparentheses

Copy link
Copy Markdown
Contributor Author

Bumped spawned to 0.7.2 (crates.io). No API changes for ethlambda; 0.7.2 shrinks ActorRef/Context to one pointer (lambdaclass/spawned#227) and tightens macro diagnostics. spawned's client-compatibility CI built this branch against the 0.7.2 code: cargo test --profile release-fast --workspace and clippy green (lambdaclass/spawned actions run 42881805858).

@unbalancedparentheses

Copy link
Copy Markdown
Contributor Author

Bumped spawned to 0.7.3 (crates.io). One supervision behaviour change, aligned with OTP: a failed restart (a replacement that stops before readiness) is retried within the restart budget instead of failing the supervisor, and OneForAll rebuilds the whole cohort; see migration notes. If a supervised child here relied on a failing replacement to stop its tree, it should call Supervisor::stop/shutdown explicitly. spawned's client-compatibility CI was green for this branch against the 0.7.3 code before the release.

This branch has not been deployed

No deployments
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.

1 participant