Run the apollo network benchmark
apollo_network_benchmark spins up N libp2p nodes that broadcast stress-test messages to each other and measures delivery latency, throughput, and message loss. Everything is driven by one orchestrator binary:
cargo run --release --bin apollo_network_benchmark_run -- <local|cluster> <start|stop|logs|port-forward> [flags]
- local: Docker Compose on this machine — one container per node plus Prometheus (:9090), Grafana (:3000), and cAdvisor (:8080). Best for quick runs.
- cluster: GKE on
sequencer-dev (us-central1) using an Indexed Job on the apollo-network-benchmark node pool. Best for scale and isolated resources.
Crate: crates/apollo_network_benchmark. The orchestrator builds the Docker image, generates configs, and deploys — you never invoke the node binary directly.
Step 1: Gather parameters
Ask ONE AskUserQuestion round. Skip any question the user already answered in their prompt — map their words onto flags using the reference table below. Everything not asked uses defaults.
- Where to run: Local Docker (Recommended — quick, no cloud access needed) / GKE cluster (scale beyond local resources; needs gcloud + kubectl).
- Scenario (protocol + broadcast mode):
- Gossipsub, all nodes broadcast (Recommended — the default) →
--network-protocol gossipsub --mode all
- Gossipsub, single broadcaster →
--mode one (you MUST also ask for --broadcaster <id>, id < num_nodes, suggest num_nodes - 1; the orchestrator rejects the command at parse without it)
- Gossipsub, round-robin →
--mode rr (nodes take turns every --round-duration-seconds, default 3s)
- SQMR →
--network-protocol sqmr (or reversed-sqmr via Other)
- Load (nodes × message size @ heartbeat):
- Default: 3 nodes × 1 KB @ 1000 ms (the binary defaults)
- Medium: 5 nodes × 4 KB @ 200 ms
- Heavy: 10 nodes × 16 KB @ 50 ms
- Duration: 5 min smoke (
--timeout-seconds 300) / 15 min (Recommended, 900) / full default (4000 ≈ 66 min) / custom. Always pass --timeout-seconds explicitly — the 66-minute default is a trap for interactive runs.
Then ask ONE short follow-up (multiSelect) — "Change any advanced defaults?":
- None (Recommended) — keep all defaults.
- Network shaping — add ingress
--latency (ms) and/or --throughput (KB/s) constraints.
- Buffers & logging —
--buffer-size, --verbosity.
- Cluster tuning (only offer for cluster runs) —
--node-pool-role, --node-toleration, CPU/memory requests and limits, --production-docker.
Only for the selected categories, ask one more round for the concrete values (offer sensible presets, e.g. latency 50/100/200 ms, with Other for custom). Never ask for values in unselected categories — defaults apply, unless the user already gave values in their prompt.
Flag reference
| Flag |
Default |
Meaning |
--num-nodes |
3 |
Number of nodes |
--mode |
all |
all / one / rr |
--network-protocol |
gossipsub |
gossipsub / sqmr / reversed-sqmr |
--broadcaster |
required for one |
Broadcasting node id; required at CLI parse when --mode one, must be < num_nodes |
--round-duration-seconds |
3 |
Turn length in rr mode |
--message-size-bytes |
1024 |
Message size (min 36 — metadata overhead) |
--heartbeat-millis |
1000 |
Sleep between broadcasts (must be > 0) |
--timeout-seconds |
4000 |
Run duration; nodes exit after this |
--buffer-size |
100000 |
Broadcast topic channel capacity |
--quic |
false |
Format bootstrap multiaddrs as UDP/QUIC — does NOT switch the node's transport (see gotchas) |
--latency |
off |
Added ingress latency, ms (tc netem) |
--throughput |
off |
Ingress throughput cap, KB/s (tc htb) |
--image |
rebuild |
Reuse a prebuilt image — local: bare docker tag; cluster: full registry ref (see Step 3) |
--memory-limit |
3g |
Local only: memory per container |
--node-pool-role |
apollo-network-benchmark |
Cluster only: node pool selector |
--node-toleration |
off |
Cluster only: key=value NoSchedule toleration |
--cpu-requests / --cpu-limits |
7500m |
Cluster only: per-pod CPU |
--memory-requests / --memory-limits |
10Gi |
Cluster only: per-pod memory |
--production-docker |
false |
Cluster only: slow production image build |
Step 2: Preflight
Local:
docker info succeeds.
- Ports 3000, 9090, 8080, and the per-node ranges 2000+id / 10000+id are free (
ss -tlnp | grep -E ':(3000|9090|8080)').
- Sanity-check memory: num_nodes × 3g (or
--memory-limit) must fit in RAM.
Cluster:
- Verify a live gcloud token, not just a listed account —
gcloud auth print-access-token must succeed. An account shown as active by gcloud auth list can still have an expired token that fails reauth headlessly, so the listing check alone is not enough.
- Confirm
kubectl config current-context points at the sequencer-dev cluster and a trivial authed call works (kubectl get ns).
- If the token is expired / reauth is required, or the context is wrong, ask the user to run
! gcloud auth login and set the context themselves — never re-auth or switch contexts for them. Do this before cluster start: otherwise a 5–15 min image build runs before the dead token surfaces at the docker push / deploy step.
Parameter guards (validate before starting):
one mode: --broadcaster is required (the orchestrator rejects the command at parse without it) and its id must be < num_nodes.
- Clock skew (ALL modes on cluster): every received message computes
receive_time - send_time, and the node panics if that's negative — i.e. if the sender's clock is even microseconds ahead (handlers.rs). This is mode-independent: all/one/rr all hit it. Single-machine local runs are safe (one clock); on a cluster, nodes crash unless tightly NTP-synced — a real run was killed by 17µs of skew. Warn the user, and treat crashlooping nodes with clock skew detected in their logs as this issue, not a benchmark misconfig.
rr mode additionally derives turn ownership from each node's wall clock, so skew also corrupts the round schedule (on top of the panic above).
--message-size-bytes ≥ 36.
Step 3: Start the benchmark
Run in the background (the first run builds a Docker image, which takes minutes):
cargo run --release --bin apollo_network_benchmark_run -- local start \
--num-nodes <N> --mode <mode> --network-protocol <proto> \
--message-size-bytes <B> --heartbeat-millis <ms> --timeout-seconds <s>
For --mode one, append --broadcaster <id> (id < num_nodes) — the orchestrator fails at CLI parse without it.
Cluster is identical with cluster start plus any cluster-only flags. Watch the output for the built image tag and successful container/pod startup; on error, surface it to the user and stop — do not retry blindly.
For back-to-back runs, pass --image <ref> to skip the rebuild, using the exact string printed as Image tag: on the previous run (also recorded in the deployment metadata under ~/apollo_network_benchmark_local/ or ~/apollo_network_benchmark_cluster/). The reference differs by mode: local takes the bare local docker tag (broadcast-network-stress-test-node:<timestamp>), while cluster requires the full registry reference (us-central1-docker.pkg.dev/starkware-dev/sequencer/broadcast-network-stress-test-node:<timestamp>) — a bare tag fails the cluster image verification and push.
Step 4: Monitor
- Cluster only: start port-forwarding in the background first:
cargo run --release --bin apollo_network_benchmark_run -- cluster port-forward
If it exits immediately, the Prometheus/Grafana pods aren't Running yet (no service endpoints) — wait for them and retry: kubectl rollout status statefulset/prometheus -n <ns>.
- Sleepod auto-sleep: on sequencer-dev a
sleepod controller sleeps StatefulSets nightly (20:00–05:00 UTC) by default. cluster start opts the run's namespace out with a SleepPolicy, so the dashboards stay up at any run time. If they get napped anyway (scaled to 0, sleepod.io/original-replicas annotation present), the opt-out didn't apply — re-apply the policy, or as a stopgap kubectl scale statefulset/prometheus statefulset/grafana --replicas=1 -n <ns> (note this can be re-slept during the night window).
- Tell the user the dashboards are live: Grafana
http://localhost:3000, Prometheus http://localhost:9090.
- Poll the Prometheus HTTP API every few minutes during the run:
curl -s 'http://localhost:9090/api/v1/query' --data-urlencode 'query=network_connected_peers'
curl -s 'http://localhost:9090/api/v1/query' --data-urlencode 'query=sum(receive_message_count)'
- Health checks (~2 min in): every node should report
network_connected_peers = N-1, and receive_message_count should be increasing. If peers never connect or counts stay 0, the run is broken — fetch container/pod logs, report, and offer to stop.
- Report interim snapshots to the user; don't go silent for the whole run.
- Fallback if central Prometheus is unavailable (cluster): the per-node
/metrics endpoints vanish the instant the Job completes, so anything not scraped before the timeout is lost. If the central Prometheus/Grafana pods are down, port-forward each node pod and scrape its exporter directly during the run, e.g. kubectl -n <ns> port-forward pod/broadcast-network-stress-test-<id> 2000:2000 && curl -s localhost:2000/metrics. Always capture a final snapshot before the timeout regardless of which path you use.
Step 5: Summarize results
Query Prometheus near the end of the run, before the timeout — once nodes exit, scrape targets disappear and only stale data remains. Instant queries:
# Latency percentiles (seconds). The node exporter renders these as Prometheus SUMMARIES
# (quantile labels), not histograms — there are no `_bucket` series, so histogram_quantile()
# returns empty. Query the quantile labels directly, matching the generated Grafana dashboard.
receive_message_delay_seconds{quantile="0.5"}
receive_message_delay_seconds{quantile="0.95"}
receive_message_delay_seconds{quantile="0.99"}
# Aggregate across nodes:
avg(receive_message_delay_seconds) by (quantile)
# Send-side achieved vs theoretical throughput (bytes/s) — like-for-like, this is the saturation signal
sum(rate(broadcast_message_bytes_sum[5m]))
sum(broadcast_message_theoretical_throughput)
# Receive-side aggregate throughput (bytes/s) — counts every delivered copy, expect ≈ send rate × (N-1)
sum(rate(receive_message_bytes_sum[5m]))
# Delivery accounting
sum(broadcast_message_count)
sum(receive_message_count)
sum(receive_message_pending_count)
sum(network_dropped_broadcast_messages) by (drop_reason)
Report back a short table plus a plain-language verdict:
- The exact command run (so it's reproducible), node count, scenario, duration.
- Latency p50/p95/p99.
- Throughput: send-side achieved vs theoretical, and receive-side aggregate vs expected (send rate × (N-1)).
- Delivery: sent vs received (expected received = sent × (N-1) for broadcast in
all/one/rr modes), pending count at end, drops by reason.
- Verdict: healthy (no drops, pending ≈ 0, latency stable), lossy (drops or growing pending), or saturated (send-side achieved throughput plateaus below theoretical, latency climbing).
Step 6: Cleanup
Ask the user before tearing down — they may want to keep dashboards up. Run only the commands for the deployment mode chosen in Step 1; the other mode's commands error when no such deployment exists.
Local:
cargo run --release --bin apollo_network_benchmark_run -- local stop
Verify cleanup with docker ps (no broadcast-network-stress-test containers left).
Cluster:
# Fetch logs first if wanted (saved to /tmp/broadcast-network-stress-test-<id>.logs.txt)
cargo run --release --bin apollo_network_benchmark_run -- cluster logs
cargo run --release --bin apollo_network_benchmark_run -- cluster stop
Kill any background port-forward processes you started.
Gotchas
- 66-minute default timeout: always set
--timeout-seconds explicitly for interactive runs.
- Clock-skew panic on cluster (any mode): the receive path panics on a negative
receive_time - send_time, so any node whose clock trails a sender's by even microseconds crashes the run — observed killing a --mode all cluster run on 17µs of skew. Local single-machine runs are safe; cluster runs need tightly NTP-synced nodes (or a tolerance change in the node code). rr mode is extra-sensitive since it also keys turn ownership off the wall clock.
--broadcaster is mandatory for --mode one: the orchestrator flattens UserArgs, whose broadcaster has required_if_eq("mode", "one"), so the command fails at CLI parse without it (the num_nodes - 1 fallback in get_env_var_pairs is unreachable via the CLI). It also errors if the id is ≥ num_nodes.
--quic does not enable QUIC: it only changes the bootstrap multiaddr format to /udp/<port>/quic-v1; the actual transport is whatever the node binary's NetworkManager builds, which is currently TCP-only (crates/apollo_network/src/network_manager/mod.rs, .with_tcp + TODO: .with_quic()). Until QUIC lands there, passing --quic breaks peer bootstrapping — don't offer it.
- Min message size is 36 bytes (message metadata).
- Port collisions: the local stack claims 3000/9090/8080 plus 2000+id and 10000+id per node — stop other local Grafana/Prometheus stacks first.
- Cluster scheduling failures: pods need the
apollo-network-benchmark node pool; if pods stay Pending, check the pool exists and consider --node-toleration or lower --cpu-requests/--memory-requests.
--production-docker is a slow multi-stage build — only for production-fidelity measurements; the default fast dev build is right for iteration.
1---2name: run-network-benchmark3description: Run the apollo_network_benchmark P2P stress test end-to-end — gather parameters, start a local Docker or GKE cluster deployment, monitor it via Prometheus/Grafana, and summarize latency/throughput/delivery results. Use when the user says 'run the network benchmark', 'network stress test', 'apollo_network_benchmark', 'p2p benchmark', or wants to benchmark gossipsub/sqmr broadcasting.4---56# Run the apollo network benchmark78`apollo_network_benchmark` spins up N libp2p nodes that broadcast stress-test messages to each other and measures delivery latency, throughput, and message loss. Everything is driven by one orchestrator binary:910```bash11cargo run --release --bin apollo_network_benchmark_run -- <local|cluster> <start|stop|logs|port-forward> [flags]12```1314- **local**: Docker Compose on this machine — one container per node plus Prometheus (:9090), Grafana (:3000), and cAdvisor (:8080). Best for quick runs.15- **cluster**: GKE on `sequencer-dev` (us-central1) using an Indexed Job on the `apollo-network-benchmark` node pool. Best for scale and isolated resources.1617Crate: `crates/apollo_network_benchmark`. The orchestrator builds the Docker image, generates configs, and deploys — you never invoke the node binary directly.1819## Step 1: Gather parameters2021Ask ONE AskUserQuestion round. Skip any question the user already answered in their prompt — map their words onto flags using the reference table below. Everything not asked uses defaults.22231. **Where to run**: Local Docker (Recommended — quick, no cloud access needed) / GKE cluster (scale beyond local resources; needs gcloud + kubectl).242. **Scenario** (protocol + broadcast mode):25 - Gossipsub, all nodes broadcast (Recommended — the default) → `--network-protocol gossipsub --mode all`26 - Gossipsub, single broadcaster → `--mode one` (you MUST also ask for `--broadcaster <id>`, id < num_nodes, suggest `num_nodes - 1`; the orchestrator rejects the command at parse without it)27 - Gossipsub, round-robin → `--mode rr` (nodes take turns every `--round-duration-seconds`, default 3s)28 - SQMR → `--network-protocol sqmr` (or `reversed-sqmr` via Other)293. **Load** (nodes × message size @ heartbeat):30 - Default: 3 nodes × 1 KB @ 1000 ms (the binary defaults)31 - Medium: 5 nodes × 4 KB @ 200 ms32 - Heavy: 10 nodes × 16 KB @ 50 ms334. **Duration**: 5 min smoke (`--timeout-seconds 300`) / 15 min (Recommended, `900`) / full default (`4000` ≈ 66 min) / custom. Always pass `--timeout-seconds` explicitly — the 66-minute default is a trap for interactive runs.3435Then ask ONE short follow-up (multiSelect) — "Change any advanced defaults?":36- **None (Recommended)** — keep all defaults.37- **Network shaping** — add ingress `--latency` (ms) and/or `--throughput` (KB/s) constraints.38- **Buffers & logging** — `--buffer-size`, `--verbosity`.39- **Cluster tuning** (only offer for cluster runs) — `--node-pool-role`, `--node-toleration`, CPU/memory requests and limits, `--production-docker`.4041Only for the selected categories, ask one more round for the concrete values (offer sensible presets, e.g. latency 50/100/200 ms, with Other for custom). Never ask for values in unselected categories — defaults apply, unless the user already gave values in their prompt.4243### Flag reference4445| Flag | Default | Meaning |46|---|---|---|47| `--num-nodes` | 3 | Number of nodes |48| `--mode` | `all` | `all` / `one` / `rr` |49| `--network-protocol` | `gossipsub` | `gossipsub` / `sqmr` / `reversed-sqmr` |50| `--broadcaster` | required for `one` | Broadcasting node id; required at CLI parse when `--mode one`, must be < num_nodes |51| `--round-duration-seconds` | 3 | Turn length in `rr` mode |52| `--message-size-bytes` | 1024 | Message size (min 36 — metadata overhead) |53| `--heartbeat-millis` | 1000 | Sleep between broadcasts (must be > 0) |54| `--timeout-seconds` | 4000 | Run duration; nodes exit after this |55| `--buffer-size` | 100000 | Broadcast topic channel capacity |56| `--quic` | false | Format bootstrap multiaddrs as UDP/QUIC — does NOT switch the node's transport (see gotchas) |57| `--latency` | off | Added ingress latency, ms (tc netem) |58| `--throughput` | off | Ingress throughput cap, KB/s (tc htb) |59| `--image` | rebuild | Reuse a prebuilt image — local: bare docker tag; cluster: full registry ref (see Step 3) |60| `--memory-limit` | 3g | Local only: memory per container |61| `--node-pool-role` | apollo-network-benchmark | Cluster only: node pool selector |62| `--node-toleration` | off | Cluster only: `key=value` NoSchedule toleration |63| `--cpu-requests` / `--cpu-limits` | 7500m | Cluster only: per-pod CPU |64| `--memory-requests` / `--memory-limits` | 10Gi | Cluster only: per-pod memory |65| `--production-docker` | false | Cluster only: slow production image build |6667## Step 2: Preflight6869Local:70- `docker info` succeeds.71- Ports 3000, 9090, 8080, and the per-node ranges 2000+id / 10000+id are free (`ss -tlnp | grep -E ':(3000|9090|8080)'`).72- Sanity-check memory: num_nodes × 3g (or `--memory-limit`) must fit in RAM.7374Cluster:75- Verify a **live** gcloud token, not just a listed account — `gcloud auth print-access-token` must succeed. An account shown as active by `gcloud auth list` can still have an expired token that fails reauth headlessly, so the listing check alone is not enough.76- Confirm `kubectl config current-context` points at the `sequencer-dev` cluster and a trivial authed call works (`kubectl get ns`).77- If the token is expired / reauth is required, or the context is wrong, ask the user to run `! gcloud auth login` and set the context themselves — never re-auth or switch contexts for them. Do this **before** `cluster start`: otherwise a 5–15 min image build runs before the dead token surfaces at the `docker push` / deploy step.7879Parameter guards (validate before starting):80- `one` mode: `--broadcaster` is required (the orchestrator rejects the command at parse without it) and its id must be < num_nodes.81- **Clock skew (ALL modes on cluster)**: every received message computes `receive_time - send_time`, and the node **panics** if that's negative — i.e. if the sender's clock is even microseconds ahead (`handlers.rs`). This is mode-independent: `all`/`one`/`rr` all hit it. Single-machine local runs are safe (one clock); on a cluster, nodes crash unless tightly NTP-synced — a real run was killed by 17µs of skew. Warn the user, and treat crashlooping nodes with `clock skew detected` in their logs as this issue, not a benchmark misconfig.82- `rr` mode additionally derives turn ownership from each node's wall clock, so skew also corrupts the round schedule (on top of the panic above).83- `--message-size-bytes` ≥ 36.8485## Step 3: Start the benchmark8687Run in the background (the first run builds a Docker image, which takes minutes):8889```bash90cargo run --release --bin apollo_network_benchmark_run -- local start \91 --num-nodes <N> --mode <mode> --network-protocol <proto> \92 --message-size-bytes <B> --heartbeat-millis <ms> --timeout-seconds <s>93```9495For `--mode one`, append `--broadcaster <id>` (id < num_nodes) — the orchestrator fails at CLI parse without it.9697Cluster is identical with `cluster start` plus any cluster-only flags. Watch the output for the built image tag and successful container/pod startup; on error, surface it to the user and stop — do not retry blindly.9899For back-to-back runs, pass `--image <ref>` to skip the rebuild, using the exact string printed as `Image tag:` on the previous run (also recorded in the deployment metadata under `~/apollo_network_benchmark_local/` or `~/apollo_network_benchmark_cluster/`). The reference differs by mode: **local** takes the bare local docker tag (`broadcast-network-stress-test-node:<timestamp>`), while **cluster** requires the full registry reference (`us-central1-docker.pkg.dev/starkware-dev/sequencer/broadcast-network-stress-test-node:<timestamp>`) — a bare tag fails the cluster image verification and push.100101## Step 4: Monitor102103- Cluster only: start port-forwarding in the background first:104 ```bash105 cargo run --release --bin apollo_network_benchmark_run -- cluster port-forward106 ```107 If it exits immediately, the Prometheus/Grafana pods aren't `Running` yet (no service endpoints) — wait for them and retry: `kubectl rollout status statefulset/prometheus -n <ns>`.108- **Sleepod auto-sleep**: on sequencer-dev a `sleepod` controller sleeps StatefulSets nightly (20:00–05:00 UTC) by default. `cluster start` opts the run's namespace out with a `SleepPolicy`, so the dashboards stay up at any run time. If they get napped anyway (scaled to 0, `sleepod.io/original-replicas` annotation present), the opt-out didn't apply — re-apply the policy, or as a stopgap `kubectl scale statefulset/prometheus statefulset/grafana --replicas=1 -n <ns>` (note this can be re-slept during the night window).109- Tell the user the dashboards are live: Grafana `http://localhost:3000`, Prometheus `http://localhost:9090`.110- Poll the Prometheus HTTP API every few minutes during the run:111 ```bash112 curl -s 'http://localhost:9090/api/v1/query' --data-urlencode 'query=network_connected_peers'113 curl -s 'http://localhost:9090/api/v1/query' --data-urlencode 'query=sum(receive_message_count)'114 ```115- Health checks (~2 min in): every node should report `network_connected_peers` = N-1, and `receive_message_count` should be increasing. If peers never connect or counts stay 0, the run is broken — fetch container/pod logs, report, and offer to stop.116- Report interim snapshots to the user; don't go silent for the whole run.117- **Fallback if central Prometheus is unavailable** (cluster): the per-node `/metrics` endpoints vanish the instant the Job completes, so anything not scraped before the timeout is lost. If the central Prometheus/Grafana pods are down, port-forward each node pod and scrape its exporter directly during the run, e.g. `kubectl -n <ns> port-forward pod/broadcast-network-stress-test-<id> 2000:2000 && curl -s localhost:2000/metrics`. Always capture a final snapshot before the timeout regardless of which path you use.118119## Step 5: Summarize results120121Query Prometheus **near the end of the run, before the timeout** — once nodes exit, scrape targets disappear and only stale data remains. Instant queries:122123```bash124# Latency percentiles (seconds). The node exporter renders these as Prometheus SUMMARIES125# (quantile labels), not histograms — there are no `_bucket` series, so histogram_quantile()126# returns empty. Query the quantile labels directly, matching the generated Grafana dashboard.127receive_message_delay_seconds{quantile="0.5"}128receive_message_delay_seconds{quantile="0.95"}129receive_message_delay_seconds{quantile="0.99"}130# Aggregate across nodes:131avg(receive_message_delay_seconds) by (quantile)132133# Send-side achieved vs theoretical throughput (bytes/s) — like-for-like, this is the saturation signal134sum(rate(broadcast_message_bytes_sum[5m]))135sum(broadcast_message_theoretical_throughput)136137# Receive-side aggregate throughput (bytes/s) — counts every delivered copy, expect ≈ send rate × (N-1)138sum(rate(receive_message_bytes_sum[5m]))139140# Delivery accounting141sum(broadcast_message_count)142sum(receive_message_count)143sum(receive_message_pending_count)144sum(network_dropped_broadcast_messages) by (drop_reason)145```146147Report back a short table plus a plain-language verdict:148- The exact command run (so it's reproducible), node count, scenario, duration.149- Latency p50/p95/p99.150- Throughput: send-side achieved vs theoretical, and receive-side aggregate vs expected (send rate × (N-1)).151- Delivery: sent vs received (expected received = sent × (N-1) for broadcast in `all`/`one`/`rr` modes), pending count at end, drops by reason.152- Verdict: **healthy** (no drops, pending ≈ 0, latency stable), **lossy** (drops or growing pending), or **saturated** (send-side achieved throughput plateaus below theoretical, latency climbing).153154## Step 6: Cleanup155156Ask the user before tearing down — they may want to keep dashboards up. Run only the commands for the deployment mode chosen in Step 1; the other mode's commands error when no such deployment exists.157158Local:159```bash160cargo run --release --bin apollo_network_benchmark_run -- local stop161```162Verify cleanup with `docker ps` (no `broadcast-network-stress-test` containers left).163164Cluster:165```bash166# Fetch logs first if wanted (saved to /tmp/broadcast-network-stress-test-<id>.logs.txt)167cargo run --release --bin apollo_network_benchmark_run -- cluster logs168169cargo run --release --bin apollo_network_benchmark_run -- cluster stop170```171Kill any background port-forward processes you started.172173## Gotchas174175- **66-minute default timeout**: always set `--timeout-seconds` explicitly for interactive runs.176- **Clock-skew panic on cluster (any mode)**: the receive path panics on a negative `receive_time - send_time`, so any node whose clock trails a sender's by even microseconds crashes the run — observed killing a `--mode all` cluster run on 17µs of skew. Local single-machine runs are safe; cluster runs need tightly NTP-synced nodes (or a tolerance change in the node code). `rr` mode is extra-sensitive since it also keys turn ownership off the wall clock.177- **`--broadcaster` is mandatory for `--mode one`**: the orchestrator flattens `UserArgs`, whose `broadcaster` has `required_if_eq("mode", "one")`, so the command fails at CLI parse without it (the `num_nodes - 1` fallback in `get_env_var_pairs` is unreachable via the CLI). It also errors if the id is ≥ num_nodes.178- **`--quic` does not enable QUIC**: it only changes the bootstrap multiaddr format to `/udp/<port>/quic-v1`; the actual transport is whatever the node binary's `NetworkManager` builds, which is currently TCP-only (`crates/apollo_network/src/network_manager/mod.rs`, `.with_tcp` + `TODO: .with_quic()`). Until QUIC lands there, passing `--quic` breaks peer bootstrapping — don't offer it.179- **Min message size is 36 bytes** (message metadata).180- **Port collisions**: the local stack claims 3000/9090/8080 plus 2000+id and 10000+id per node — stop other local Grafana/Prometheus stacks first.181- **Cluster scheduling failures**: pods need the `apollo-network-benchmark` node pool; if pods stay Pending, check the pool exists and consider `--node-toleration` or lower `--cpu-requests`/`--memory-requests`.182- **`--production-docker`** is a slow multi-stage build — only for production-fidelity measurements; the default fast dev build is right for iteration.