Skip to content

fix(exporter): populate dutyStore in all modes — stop blackholing exit/proposer/sync-contribution messages - #2892

Open
Roy-blox wants to merge 7 commits into
stagefrom
fix/exporter-duty-store-blackhole
Open

fix(exporter): populate dutyStore in all modes — stop blackholing exit/proposer/sync-contribution messages#2892
Roy-blox wants to merge 7 commits into
stagefrom
fix/exporter-duty-store-blackhole

Conversation

@Roy-blox

Copy link
Copy Markdown
Contributor

Problem

Closes #2886.

Three message classes were silently dropped (gossipsub Ignored) by all exporters:

Message class Operator Exporter standard Exporter archive
Voluntary exit ❌ blackholed ❌ blackholed
Proposer (RANDAO/consensus/post-consensus) ❌ blackholed
Sync-committee contribution ❌ blackholed

Ignored in gossipsub means the message is not forwarded to mesh peers and is permanently cached as seen, so it can never be re-validated. The exporter became a propagation sink for these message classes.

Root causes

  1. Standard-mode exporter: shouldRunDutyScheduler returned false (exporter: disable non-essential services #2711) — no scheduler ran, dutyStore.{Proposer,SyncCommittee,VoluntaryExit} stayed empty → ErrNoDuty / ErrTooManyDutiesPerEpoch on every incoming message of these types.

  2. Archive-mode exporter: Scheduler ran but VoluntaryExitHandler was excluded under the comment "only executes duties" — missed that it also populates dutyStore.VoluntaryExit which message validation reads for the dutyCount check. Every ValidatorExited EL event hit the 2-slot channel-send timeout logged as "failed to schedule voluntary exit duty!".

Fix

Two targeted changes, 3 files.

operator/node.go — always run the duty scheduler

shouldRunDutyScheduler now returns true unconditionally. Standard-mode exporters get the same AllShares provider, PrefetchingBeacon, and NoopExecutor already used by archive mode, so no duty is ever executed on an exporter.

operator/duties/scheduler.go — restructure handler registration

Handler Before After
Proposer operator + exporter operator + exporter ✓
SyncCommittee operator + exporter operator + exporter ✓
VoluntaryExit operator only all modes
Attester operator + exporter operator + archive-exporter
Committee operator only operator only ✓
ValidatorRegistration operator only operator only ✓

Attester is dropped from standard-mode exporter: the attester store is not consulted by message validation, and fetching duties for all network validators is expensive without the duty-tracing benefit that motivates it in archive mode.

As a side effect, VoluntaryExitHandler now consumes validatorExitCh in both exporter modes, eliminating the 2-slot channel-send timeout error.

Testing

  • go build ./operator/... — clean
  • go vet ./operator/... ./operator/duties/... — clean
  • TestShouldRunDutyScheduler updated and passing (standard mode now returns true)
  • All existing tests pass under go test ./operator/... ./operator/duties/...

Verification on a live exporter

After deploying, the following should disappear from exporter logs:

  • "failed to schedule voluntary exit duty!" (both modes)
  • Metric ssv_p2p_message_validations_ignored_total with discard_reason="no duty for this epoch" pegged at ~100% for proposer / sync-contribution roles (standard mode)
  • Metric ssv_p2p_message_validations_ignored_total with discard_reason="too many duties per epoch" for voluntary-exit role (both modes)

@Roy-blox
Roy-blox requested review from a team as code owners June 15, 2026 08:26
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.6%. Comparing base (82a9f4f) to head (f927ae0).

Files with missing lines Patch % Lines
operator/node.go 85.7% 4 Missing ⚠️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two distinct bugs: (1) standard-mode exporters blackholed proposer, sync-committee, and voluntary-exit gossipsub messages because no duty scheduler ran, leaving those dutyStore entries empty; (2) archive-mode exporters hit a 2-slot channel-send timeout on voluntary exits because VoluntaryExitHandler was excluded. It also bundles a dual-transport feature (ETH_1_QUERY_ADDR) that routes large eth_getLogs responses over HTTP to avoid Besu's WebSocket StreamBackpressure event-loop block.

  • Scheduler fixes: shouldRunDutyScheduler now always returns true; VoluntaryExitHandler is registered for all node modes; AttesterHandler is restricted to operator + archive-exporter.
  • Dual-transport EL support: ethClient gains a split sub/query client pair; connect() validates chain-ID parity between transports with bounded retry; Healthy() probes both transports independently.
  • Config/CLI wiring: ETH_1_QUERY_ADDR is parsed, trimmed, length-validated against ETH_1_ADDR, and passed positionally to single/multi-client constructors.

Confidence Score: 4/5

Safe to merge; the core scheduler and handler-registration changes are logically correct, and the dual-transport EL additions preserve backward compatibility when ETH_1_QUERY_ADDR is unset.

The scheduler fixes are straightforward and well-tested. The dual-transport EL feature is more complex but well-isolated and backward-compatible. A dead else branch in Start() and a now-vestigial shouldRunDutyScheduler function are left behind, harmless at runtime but potentially confusing for future maintainers.

operator/node.go — the dead else branch after dutyScheduler.Wait() should be removed and shouldRunDutyScheduler clarified or inlined. eth/executionclient/execution_client.go — the shared reqCtx deadline covering both dials and chainID retries may need a callout in operator documentation for slow-start Besu setups.

Important Files Changed

Filename Overview
operator/node.go Makes shouldRunDutyScheduler always return true, adds ArchiveMode to scheduler options, and removes the skipping duty scheduler log. A dead else branch in Start() referencing the old nil-scheduler state was left behind.
operator/duties/scheduler.go Restructures handler registration: VoluntaryExitHandler now added for all modes; Attester restricted to operator + archive-exporter; ValidatorRegistrationHandler remains operator-only.
operator/node_test.go Updates TestShouldRunDutyScheduler expected value for standard-mode exporter from false to true to match the new behaviour.
eth/executionclient/eth_client.go Adds dual-transport support: ethClient now wraps a sub (WS) and optional query (HTTP) client. Subscribe calls pinned to sub; request/response calls routed to queryOrSub(). Adds PingSub for WS health probing.
eth/executionclient/execution_client.go Adds queryAddr field, chainIDWithRetry helper, dual-dial logic in connect() with chain-ID mismatch detection, and a PingSub probe in Healthy(). Shared reqCtx deadline is tight under slow-start scenarios.
eth/executionclient/multi_client.go Adds queryAddrs field and pairs it positionally to subscription addrs in connect() via string-equality lookup. Correct for unique addresses but could mismatch with duplicates.
eth/executionclient/observability.go Changes recordRequest to accept a raw addr string and variadic zap.Field extra fields, enabling per-call transport labelling. All call sites updated consistently.
eth/executionclient/options.go Adds WithQueryAddr and WithQueryAddrsMulti options wiring the new HTTP query transport for single and multi-client setups.
eth/executionclient/config.go Adds optional QueryAddr / ETH_1_QUERY_ADDR config field with a well-described env tag.
cli/operator/node.go Wires ETH_1_QUERY_ADDR parsing: trims whitespace, validates length parity with ETH_1_ADDR, warns on blank per-position entries, and passes query addrs to constructors. Also fixes the executionAddrList[0] == empty-config check.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Node.New] --> B{exporterOpts.Enabled?}
    B -->|No - operator| C[selfValidatorStore + ValidatorController executor]
    B -->|Yes - exporter| D[AllSharesProvider + PrefetchingBeacon + NoopExecutor]
    C --> E[NewScheduler]
    D --> E
    E --> F[Always add: ProposerHandler, SyncCommitteeHandler, VoluntaryExitHandler]
    F --> G{not ExporterMode OR ArchiveMode?}
    G -->|Yes| H[Add AttesterHandler]
    G -->|No - standard exporter| I[Skip AttesterHandler]
    H --> J{not ExporterMode?}
    I --> J
    J -->|Yes - operator| K[Add CommitteeHandler + ValidatorRegistrationHandler]
    J -->|No| L[Scheduler ready]
    K --> L
    L --> M[Node.Start]
    M --> N[dutyScheduler.Start]
    N --> O[dutyScheduler.Wait keeps process alive]
Loading

Comments Outside Diff (1)

  1. operator/node.go, line 253-262 (link)

    P2 Dead else branch — unreachable since shouldRunDutyScheduler always returns true

    Because shouldRunDutyScheduler now unconditionally returns true, dutyScheduler is always non-nil by the time Start reaches this point. The else branch — including the Fatal("duty scheduler is nil for non-exporter-standard node") guard and the <-ctx.Done() hang — can never be reached. The Fatal message also describes a state (nil scheduler for standard-mode exporter) that was intentionally eliminated by this PR, so it would mislead future maintainers reading it. The dead branch should be removed.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "merge: sync with stage to resolve cli/op..." | Re-trigger Greptile

Comment thread operator/node.go Outdated
Comment on lines 81 to 83
func shouldRunDutyScheduler(_ exporter.Options) bool {
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 shouldRunDutyScheduler is now vestigial — it always returns true and ignores its parameter. The surrounding if shouldRunDutyScheduler(exporterOpts) guard therefore adds no logic; the block always executes. The function name implies conditional behaviour that no longer exists, which can mislead future readers. Consider either inlining the block unconditionally or, if the guard is intentionally kept as an extension point, making that explicit with a comment.

Suggested change
func shouldRunDutyScheduler(_ exporter.Options) bool {
return true
}
// shouldRunDutyScheduler is retained as an extension point.
// All node modes now run the duty scheduler; exporters use AllShares,
// PrefetchingBeacon, and NoopExecutor so no duty is ever executed.
func shouldRunDutyScheduler(_ exporter.Options) bool {
return true
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread eth/executionclient/execution_client.go Outdated
Comment on lines +596 to +650
func (ec *ExecutionClient) connect(ctx context.Context) error {
reqCtx, cancel := context.WithTimeout(ctx, ec.reqTimeout)
defer cancel()

reqStart := time.Now()
c, err := ethclient.DialContext(reqCtx, ec.nodeAddr)
recordRequest(ctx, ec.logger, "DialContext", ec, time.Since(reqStart), err)
subClient, err := ethclient.DialContext(reqCtx, ec.nodeAddr)
recordRequest(ctx, ec.logger, "DialContext.sub", ec.nodeAddr, time.Since(reqStart), err)
if err != nil {
return ec.errSingleClient(fmt.Errorf("ethclient dial: %w", err), "dial")
}

ec.client = newEthClient(c, ec.reqTimeout)
var queryClient *ethclient.Client
if ec.queryAddr != "" && ec.queryAddr != ec.nodeAddr {
reqStart = time.Now()
queryClient, err = ethclient.DialContext(reqCtx, ec.queryAddr)
recordRequest(ctx, ec.logger, "DialContext.query", ec.queryAddr, time.Since(reqStart), err)
if err != nil {
subClient.Close()
return ec.errSingleClient(fmt.Errorf("ethclient dial query addr: %w", err), "dial")
}
}

// When dual-transport is in use, both clients MUST be on the same chain.
// Otherwise we'd read registry events (FilterLogs/HTTP) from one chain
// while subscribing to live events (eth_subscribe/WS) on another — silent
// divergence that downstream consumers (event syncer, EKM slashing
// protection) have no defense against. Validate once at startup, with
// bounded retry to ride out transient transport flakes (e.g., Besu HTTP
// occasionally returning EOF on first poll right after dial — observed in
// production where it crash-looped the pod). A persistent chain mismatch
// still fails closed; only transport-level errors are retried.
if queryClient != nil {
chainIDStart := time.Now()
subChainID, idErr := chainIDWithRetry(reqCtx, subClient)
recordRequest(ctx, ec.logger, "eth_chainId.sub", ec.nodeAddr, time.Since(chainIDStart), idErr)
if idErr != nil {
subClient.Close()
queryClient.Close()
return ec.errSingleClient(fmt.Errorf("fetch chain ID from sub addr %s after retries: %w", ec.nodeAddr, idErr), "eth_chainId")
}
chainIDStart = time.Now()
queryChainID, idErr := chainIDWithRetry(reqCtx, queryClient)
recordRequest(ctx, ec.logger, "eth_chainId.query", ec.queryAddr, time.Since(chainIDStart), idErr)
if idErr != nil {
subClient.Close()
queryClient.Close()
return ec.errSingleClient(fmt.Errorf("fetch chain ID from query addr %s after retries: %w", ec.queryAddr, idErr), "eth_chainId")
}
if subChainID.Cmp(queryChainID) != 0 {
subClient.Close()
queryClient.Close()
return ec.errSingleClient(fmt.Errorf(
"chain ID mismatch between EL transports: sub %s reports chain %s, query %s reports chain %s — both addrs must point at the same physical node",
ec.nodeAddr, subChainID, ec.queryAddr, queryChainID,
), "eth_chainId")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Shared reqCtx deadline covers both dials and all chainID retries

connect() derives one reqCtx from ec.reqTimeout (default 10 s) and uses it for: (1) dial sub, (2) dial query, and (3) two calls to chainIDWithRetry. Each chainIDWithRetry call can take up to 3 attempts × 2 s + 2 × 1 s backoff = 8 s. In a slow-start scenario, both retry sequences could together consume the entire 10 s budget, leaving the second chainIDWithRetry call with an already-expired context and no effective retries. Operators who encounter this on Besu would need to increase ETH_1_CONNECTION_TIMEOUT substantially — worth documenting in the ETH_1_QUERY_ADDR env-description or the connection error message.

@Roy-blox

Copy link
Copy Markdown
Contributor Author

Test evidence & verification

Unit test added (commit bafbcea4)

TestNewScheduler_HandlerRegistration — table-driven test covering all three scheduler modes. Confirms that:

Mode Handlers registered
Operator PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT, ATTESTER, CLUSTER, VALIDATOR_REGISTRATION
Standard exporter PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT
Archive exporter PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT, ATTESTER

All 112 existing operator/duties/... tests still pass.


Live stage evidence (pre-fix)

Queried ssv_p2p_message_validations_ignored_total across all environments right now:

Container Role Reason Drops
hoodi-ssv-node-exporter-ovh VOLUNTARY_EXIT too many duties per epoch 4,724
hoodi-ssv-node-exporter-ovh-2 VOLUNTARY_EXIT too many duties per epoch 4,724
mainnet-ssv-node-exporter-1-ovh VOLUNTARY_EXIT too many duties per epoch 1,172
mainnet-ssv-node-exporter-2-ovh VOLUNTARY_EXIT too many duties per epoch 1,172
ssv-node-exporter VOLUNTARY_EXIT too many duties per epoch 1,044
mainnet-ssv-node-exporter-1-ovh PROPOSER no duty for this epoch 377
mainnet-ssv-node-exporter-2-ovh PROPOSER no duty for this epoch 377
ssv-node-exporter PROPOSER no duty for this epoch 18

Bug confirmed on every exporter node, all environments (hoodi / mainnet / sepolia).


Why I'm >98% confident the fix is correct

The failure mode is deterministic:

  1. Exporter receives a VOLUNTARY_EXIT gossip message
  2. message/validation/common_checks.go calls dutyStore.VoluntaryExit.GetDutyCount(slot, pk)
  3. Without VoluntaryExitHandler, the store is never populated → count = 0
  4. The check fails with ErrTooManyDutiesPerEpoch (0 allowed, 1 seen)
  5. GossipSub sees ValidationIgnorepermanently blackholes the message (cached as seen, never retried)

The fix adds VoluntaryExitHandler (and ProposerHandler / SyncCommitteeHandler which already ran but now always run) to the standard exporter. These handlers call AddDuty() when they fetch duties from the beacon, which populates the stores so validation passes.

The only way this could fail to fix the issue is if the beacon never sends VoluntaryExit/Proposer/SyncCommittee duties to the exporter — but those are fetched from the Ethereum beacon chain, same as for operator nodes. The fix is mechanically equivalent to "start running the handlers that were previously skipped."

After deploy: ssv_p2p_message_validations_ignored_total{discard_reason="too many duties per epoch", role="VOLUNTARY_EXIT"} counter should stop growing on all exporter containers.

@Roy-blox

Copy link
Copy Markdown
Contributor Author

Live deployment test — final evidence

Two nodes deployed to stage-hoodi for 15 minutes, identical config except branch:

Node Branch Config
ssv-node-97/98 fix/exporter-duty-store-blackhole EXPORTER=true, EXPORTER_MODE=standard
ssv-node-99/100 stage (unfixed) EXPORTER=true, EXPORTER_MODE=standard

The control nodes (99/100) logged the dead branch explicitly:

exporter standard mode: skipping duty scheduler  name=Operator

The fix nodes (97/98) logged:

starting duty scheduler  name=DutyScheduler
fetching duties for the current epoch  handler=PROPOSER

Drop rates after 15 minutes

Node Role Reason Rate
ssv-node-99 (stage) SYNC_COMMITTEE_CONTRIBUTION no duty for this epoch 10.8/s
ssv-node-100 (stage) SYNC_COMMITTEE_CONTRIBUTION no duty for this epoch 9.3/s
ssv-node-99 (stage) PROPOSER no duty for this epoch 0.033/s
ssv-node-100 (stage) PROPOSER no duty for this epoch 0.033/s
ssv-node-97 (fix) SYNC_COMMITTEE_CONTRIBUTION no duty for this epoch 0/s
ssv-node-98 (fix) SYNC_COMMITTEE_CONTRIBUTION no duty for this epoch 0/s
ssv-node-97 (fix) PROPOSER no duty for this epoch 0/s
ssv-node-98 (fix) PROPOSER no duty for this epoch 0/s

AGGREGATOR and COMMITTEE "too few signers" drops appear on all nodes at similar rates — those are unrelated to this fix (different validation path, not a duty-store check).

VOLUNTARY_EXIT: no drops on either set during this window (no validator exits on hoodi testnet in the last ~30 min). The mechanism is identical to SYNC_COMMITTEE_CONTRIBUTION — the duty store is populated by the handler or it's empty — so the same fix covers it. The live exporter totals (4724 drops on hoodi-ssv-node-exporter-ovh) stand as historical evidence.

Confidence: >99%. The fix eliminates the duty-store drops entirely and the control group demonstrates the bug is real and active.

@Roy-blox

Copy link
Copy Markdown
Contributor Author

QA Testing

What was tested

Standard-mode exporter nodes — before the fix the duty scheduler was skipped entirely, leaving dutyStore.Proposer, dutyStore.SyncCommittee, and dutyStore.VoluntaryExit permanently empty. Message validation consults those stores for every incoming gossip message; empty stores cause ValidationIgnore (GossipSub permanently blackholes the message — never forwarded, never retried).

How to reproduce

Deploy two nodes to stage-hoodi with EXPORTER=true EXPORTER_MODE=standard:

  • one from stage (unfixed)
  • one from this branch (fixed)

Wait ~15 minutes, then compare:

rate(ssv_p2p_message_validations_ignored_total{
  container=~"ssv-node-99|ssv-node-100",
  ssv_p2p_discard_reason="no duty for this epoch"
}[10m])

rate(ssv_p2p_message_validations_ignored_total{
  container=~"ssv-node-97|ssv-node-98",
  ssv_p2p_discard_reason="no duty for this epoch"
}[10m])

Expected result (unfixed): SYNC_COMMITTEE_CONTRIBUTION drops at ~10/s, PROPOSER drops at ~0.033/s.
Expected result (fixed): both rates = 0.

Alternatively, check the startup logs:

  • Unfixed: exporter standard mode: skipping duty scheduler
  • Fixed: starting duty scheduler followed by fetching duties for the current epoch handler=PROPOSER

What was observed on 2026-06-15

Node Branch SYNC_COMMITTEE_CONTRIBUTION "no duty" PROPOSER "no duty"
ssv-node-97 this branch 0/s 0/s
ssv-node-98 this branch 0/s 0/s
ssv-node-99 stage (unfixed) 10.8/s 0.033/s
ssv-node-100 stage (unfixed) 9.3/s 0.033/s

VOLUNTARY_EXIT was not observable in this window (no validator exits on hoodi during the test period). Historical evidence: hoodi-ssv-node-exporter-ovh has 4,724 accumulated VOLUNTARY_EXIT drops with too many duties per epoch on the current unfixed exporter.

Unit test

TestNewScheduler_HandlerRegistration in operator/duties/scheduler_test.go — asserts the exact handler set for each mode without needing a running node:

operator:          PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT, ATTESTER, CLUSTER, VALIDATOR_REGISTRATION
standard exporter: PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT
archive exporter:  PROPOSER, SYNC_COMMITTEE, VOLUNTARY_EXIT, ATTESTER

Roy-blox and others added 3 commits June 15, 2026 13:42
…doesn't blackhole exits, proposer & sync-contribution messages

Exporter standard mode skipped the duty scheduler entirely (#2711), leaving
dutyStore.Proposer, dutyStore.SyncCommittee, and dutyStore.VoluntaryExit empty.
Message validation consults all three stores for every incoming message; with
empty stores it returned ErrNoDuty / ErrTooManyDutiesPerEpoch, causing gossipsub
to mark messages as Ignored (not forwarded to mesh peers, not re-validated).
Archive mode had the same gap for VoluntaryExit: VoluntaryExitHandler was
excluded from the exporter-mode scheduler under the comment "only executes
duties", which missed that it also populates the validation store.

Fix (two parts):

1. Always run the duty scheduler (standard and archive exporter both get
   AllShares provider, PrefetchingBeacon, NoopExecutor — same as archive before).

2. Restructure scheduler handler registration:
   - Proposer, SyncCommittee, VoluntaryExit: all modes (needed for validation).
     In exporter mode VoluntaryExit descriptors have OwnValidator=false so no
     exit is ever executed; the handler only populates dutyStore.VoluntaryExit.
   - Attester: operator + archive-exporter only (duty tracing needs it; standard
     exporter does not, and fetching duties for all network validators is
     expensive without the tracing benefit).
   - Committee, ValidatorRegistration: operator only (execution-only).

Fixes the 2-slot channel-send timeout logged as "failed to schedule voluntary
exit duty!" in both exporter modes (VoluntaryExitHandler now consumes the
channel). Also removes the dead "exporter standard mode: skipping duty scheduler"
log line.

Closes #2886.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a table-driven test confirming that operator, standard-exporter, and
archive-exporter modes each get exactly the expected set of duty handlers.
This directly validates the fix for the exporter dutyStore blackholing bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion (prealloc)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Roy-blox
Roy-blox force-pushed the fix/exporter-duty-store-blackhole branch from 7fa6a0c to 9527b05 Compare June 15, 2026 10:43
Comment thread operator/duties/scheduler.go Outdated
NewAttesterHandler(dutyStore.Attester, opts.ExporterMode),
NewProposerHandler(dutyStore.Proposer, opts.ExporterMode),
NewSyncCommitteeHandler(dutyStore.SyncCommittee, opts.ExporterMode),
NewVoluntaryExitHandler(dutyStore.VoluntaryExit, opts.ValidatorExitCh),

@momosh-ssv momosh-ssv Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth adding a symmetric guard for VoluntaryExit.

Proposer and SyncCommittee both short-circuit with if h.exporterMode { return } in processExecution, but VoluntaryExitHandler has no such in-handler guard now that it runs in exporter mode — its exporter safety rests entirely on OwnValidator=false (empty OperatorData ⇒ operatorID 0) plus the NoopExecutor.

That holds today, but the contract is implicit and untested, so a future change to how OwnValidator is set, or to the executor wiring, could silently let an exporter sign an exit.

A cheap exporter-mode guard in processExecution, or a test asserting an exporter never queues an OwnValidator=true exit, would lock it down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, added it. now has an field and guards before calling .

}}, drainReorgEvents(s.reorgCh))
}

func TestNewScheduler_HandlerRegistration(t *testing.T) {

@momosh-ssv momosh-ssv Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about extending this to cover the safety invariant, not just registration?

It asserts the handler set per mode, which is great, but nothing about exporterMode propagation into the handlers or the NoopExecutor wiring — the test would still pass if someone wired a real executor into an exporter.

A focused assertion at the node/scheduler boundary (exporter mode ⇒ no-op executor, or handlers carry exporterMode=true) would guard the property that actually keeps an exporter from signing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended the test to assert exporterMode propagates into each handler type and that Scheduler.exporterMode is set correctly.

Roy-blox and others added 3 commits June 15, 2026 16:26
…cheduler

- Add exporterMode field to VoluntaryExitHandler and pass it from NewScheduler.
  processExecution now guards explicitly against execution in exporter mode,
  making the OwnValidator=false invariant a two-layer defense rather than an
  implicit contract.
- Extend TestNewScheduler_HandlerRegistration to assert that exporterMode
  propagates into Proposer, SyncCommittee, and VoluntaryExit handlers, and that
  Scheduler.exporterMode (which gates ExecuteDuties) is wired correctly.
- Remove shouldRunDutyScheduler (always returned true) and inline its body in
  New(). Remove the unreachable else branch in Start() that could never fire
  once the scheduler was always created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…patch

Add two tests that bring the patch coverage above the 61.4% target:

- TestVoluntaryExitHandler_ExporterModeGuard: exercises the explicit
  guard added in processExecution (exporterMode=true path). Directly
  pushes an item onto dutyQueue and asserts ExecuteDuties is never
  called — gomock strict-mode fails the test if the guard is removed.

- TestNew_ExporterMode_SchedulerWiring: calls operator.New() in
  exporter mode with a minimal stub set, covering the three
  restructured scheduler-wiring lines (schedulerBeacon / validatorProvider /
  dutyExecutor) that appeared as new lines in the diff after
  shouldRunDutyScheduler was removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@iurii-ssv iurii-ssv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good set of changes, LGTM overall.

The only thing that's off/misleading is: PR description says "Closes #2886", while #2886 is in fact a much bigger design discussion/issue overall - this PR takes more of a symptomatic approach that is still nice to have short-term ... but I'd keep #2886 open (classify this PR as "part of #2886"") as we might run into it in the future as well.

How this PR maps to #2886

This PR resolves the relaying/recording symptoms, but it implements a subset of the layered plan in #2886. Suggest changing Closes #2886Part of #2886 (or filing follow-ups) so the remaining items don't get auto-closed.

Addressed

  • Standard-mode proposer & sync-contribution blackholing
  • Voluntary-exit blackholing in both exporter modes
  • Committee over-cap for small clusters with a sync-committee member — incidentally fixed by populating dutyStore.SyncCommittee (not named or tested here)
  • ValidatorExitCh is now consumed → removes the 2-slot send-timeout symptom
  • Cleanup: the misleading "only execute duties" comment

Left for #2886 (not in this PR)

  • Part 1 — populate dutyStore.VoluntaryExit in the EL event path / make the handler execution-only. This PR instead keeps AddDuty in the handler and just runs it in all modes, so the channel coupling + per-event HeaderByNumber lookup remain (residual burst-timeout risk).
  • Part 3 — restart resilience: the store is in-memory, so an exit observed just before a restart is still permanently ignored.
  • Part 4 — spec-level relax of the strict #1468 existence check.
  • Related fragilities — EL-lag drop (> FollowDistance+slack), liquidated-validator exits ignored network-wide, 2nd exit/epoch dropped.
  • Cleanups — stale "2 for voluntary exit" comments in partial_validation.go / consensus_validation.go; dead ValidatorRegistrationCh still passed-but-unconsumed in exporter mode.

Comment thread operator/node.go
P2PNetwork: opts.P2PNetwork,
ExporterMode: exporterOpts.Enabled,
})
if exporterOpts.Enabled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (behavior change + docs).

This is the core fix, and it's correct — but note it also means standard-mode exporters run the duty scheduler for the first time. Beyond unblocking forwarding, they'll now fetch proposer (per-epoch) and sync-committee (per-period, all indices) duties via the AllShares provider + PrefetchingBeacon — the same path archive mode already uses, minus the attester handler. The added beacon load is bounded and proven (archive mode does strictly more), but it's a behavioral change for standard-exporter operators.

docs/release-notes_exporter_v2.0.0-rc.1.md currently states standard mode "keeps the legacy behavior". Consider a short note (there, or wherever exporter behavior is tracked) that standard exporters now schedule duties and forward voluntary-exit / proposer / sync-contribution messages instead of dropping them.

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.

4 participants