Skip to content

processor: undersized urgent_consensus queue drops gossip bursts (missed attestations on high-footprint operators) #1088

Description

@shane-moore

Summary

A large mainnet operator (~75 committees, 57 of 128 subnets, v1.2.3) is missing attestations because the processor's single urgent_consensus queue drops inbound gossip during sub-second slot-boundary bursts, sometimes the node's own committee messages. The default depth (1000) is an underived placeholder. This issue is scoped to one focused change: raise that default to a measured, sane value (4096). Work-item expiry and the queue split are tracked in #254. (The affected operator is already unblocked via the existing --work-queue-size override; this fixes the default so the next high-footprint operator does not silently hit the same wall.)

Problem / impact

  • Bursts of Processor queue full / Unable to pass message to message receiver on urgent_consensus, hundreds within single seconds at slot boundaries, quiet between.
  • Not CPU-starved (~2.6 of 8 cores during the drops, idle cores available); load is bursty, not sustained.
  • Most dropped messages are other operators' relay traffic (harmless), but a fraction are the node's own consensus/partial-signature messages, which causes the misses.

Root cause

Every inbound gossip message is one work item on the single urgent_consensus queue (default 1000, processor/src/lib.rs:78), drained by num_cpus workers each running the full validate-and-route (message_receiver/src/manager.rs:78). Past 1000 queued, the newest is dropped. RSA verify (message_validator/src/lib.rs:698) is lock-free and parallel, so throughput is bounded by the worker pool and the 1000-slot queue is the only burst buffer.

go-ssv validates inside go-libp2p's pubsub pipeline, which buffers up to ValidateThrottle = 8192 in-flight validations before dropping, so it absorbs ~8x larger slot-boundary bursts on comparable hardware. Anchor's 1000 was never derived from traffic (placeholder from #57).

Measured on mainnet (2026-06-14, read-only)

From the mainnet Anchor canaries' /metrics (4 active nodes, up to 80 days uptime):

  • Per-item validation service time ~165-430 us (anchor_processor_worker_time{type="message_receiver"}, exact mean via sum/count). Drain ~2.5k msg/s/core, ~20k/s at 8 cores.
  • Average arrival ~50-125 msg/s/node; message_receiver is 99.7% of all urgent_consensus traffic.
  • Zero drops and zero queue depth on every canary over full uptime, so 1000 is adequate at single-cluster footprint and only the high-footprint operator's bursts overflow it. Average CPU ~2% of a core; this is burst-vs-buffer, not CPU.
  • The operator overshoots 1000 by "hundreds" per burst (true peak >= ~1500, clipped by the drop), and runs clean on go-ssv's ~8192 ceiling.

Proposed fix

Raise the urgent_consensus default from 1000 to 4096 (processor/src/lib.rs:78), with a comment recording the derivation, plus tests (default size; burst-absorption past the old cap). Keep the queue name (it is the --work-queue-size CLI contract); value-only change, operator overrides preserved.

Why 4096 (not 8192): it covers the observed overshoot with ~2.7x margin and is modest enough to be safe without work-item expiry. A deeper default (toward go-ssv's 8192) buffers ~8x more before dropping, which on a sustained-overloaded node means grinding through proportionally more stale messages, so it would need expiry as a guard. 4096 sidesteps that, keeps the change to a single value, and is a strict improvement; if production shows it is short, the escalation below raises it with the proper guard. Peak held memory ~= depth x real msg size (~4 MB at 4096); tokio mpsc allocates lazily so idle cost is ~0.

Going deeper (if 4096 is not enough)

Recorded so a future maintainer can escalate without re-deriving; every trigger is a processor metric already exported. None needed now.

  1. Deeper default (toward 8192). More burst headroom if send_error{type="message_receiver"} keeps climbing after 4096. A deeper buffer alone only delays drops, so under sustained overload it needs a shedding policy (next), not just a bigger backlog.
  2. Priority / queue split (Processor: Split into multiple queues #254), the real overload lever. Shed other operators' relay traffic before our own duty-critical/outbound work; ownership answers "still useful to us?" far better than age. Trigger: send_error{type="signer"|"sender"} fires (own outbound dropped) or multi-second urgent_consensus backlog.
    • Why not time-based expiry: the dominant traffic is attestation QBFT / partial-signature messages, which stay useful for ~the attestation inclusion window (up to 32 slots / 1 epoch; a late-but-within-epoch attestation still earns the target reward, the largest flag component). A correct duty-aware TTL is therefore epoch-scale, so expiry would essentially never fire (a node 30+ slots behind is already dead) for near-zero benefit, while a naive ~1-slot TTL would wrongly drop still-rewardable work. Ownership-based shedding is the better mechanism; expiry might help only for genuinely short-window types (e.g. sync-committee, included the next slot).
  3. Decouple accept-from-validate. Dispatch validation off the worker pool (go-libp2p style) so queue depth stops being the only burst buffer.
  4. Per-subnet-scaled depth. Size the queue from the node's subnet count so benefit and memory cost land on the same high-footprint nodes.

Considered and not doing: un-hiding --work-queue-size. Its values are queue names slated to change in #254 (lib.rs:58 TODO) and an unknown name is a hard startup failure (config.rs:308-310), so documenting it now is a rename footgun.

Scope

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions