Skip to content

fix(runner): conclude benign ErrNoValidDutiesToExecute as not_required, not failed - #2988

Open
momosh-ssv wants to merge 2 commits into
stagefrom
fix/2903-benign-no-duties-outcome
Open

fix(runner): conclude benign ErrNoValidDutiesToExecute as not_required, not failed#2988
momosh-ssv wants to merge 2 commits into
stagefrom
fix/2903-benign-no-duties-outcome

Conversation

@momosh-ssv

@momosh-ssv momosh-ssv commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What this fixes

Fixes #2903.

Sometimes a committee reaches consensus but one of its operators ends up with nothing to submit to the beacon node. This is normal — it happens when the operators in a committee don't run the exact same set of validators. The node handled the situation correctly, but it reported it as a failure: operators saw a ⚠️ duty failed warning in their logs and a failed data point in the duty outcome metric, for something that wasn't a failure at all.

This PR makes the node report that situation for what it is: a duty that completed correctly with nothing to submit (not_required).

What changed

First commit — how the duty outcome is classified:

  • In the aggregator-committee runner, the "nothing to submit" case now records the duty as not_required instead of failed.
  • In the committee runner, the same case now records not_required before the error-handling defer can mark it failed (recording an outcome twice is safe — only the first one counts).
  • The committee runner had one more spot with the same problem, during the consensus phase: deciding with zero duties to sign recorded no outcome at all, which the watcher would later report as a false "stuck". That spot now records not_required as well.

Second commit — the log line in the message queue:

When this situation occurs, the queue also printed an error-level log line (❗ could not handle message...) and marked the trace as an error. Same false alarm, same harmless trigger. It's now a debug-level line with neutral wording, and the trace is marked OK.

What did NOT change

  • Real failures are still reported as failed — beacon submission errors, signature reconstruction failures, and so on.
  • The queue still drops the message and shuts down the runner in this situation, exactly as before. Only the reporting changed.
  • No impact on consensus or on what gets submitted to the beacon node. This is observability only.

Why not_required and not a new label

The issue asked for a decision here. not_required already exists and means exactly this ("completed correctly with nothing to submit") — it's what the aggregator runner uses when a validator turns out not to be an aggregator. Reusing it avoids adding a new metric label for the same idea.

Testing

Three new regression tests, one per fixed spot. Each was verified to fail on the old code and pass with the fix. The full runner and validator test suites pass.

…d, not failed

A committee or aggregator-committee duty that reaches post-consensus quorum
but leaves this operator with no beacon objects to submit is a normal
"nothing to do" terminal (divergent validator sets across the committee's
operators), yet both runners classified it as a failed outcome — emitting a
spurious "duty failed" warning and a false ssv.runner.duty.outcome=failed
data point, while the queue simultaneously treated the same sentinel as a
benign terminal drop.

Conclude the branch as not_required instead, in both runners:
- AggregatorCommitteeRunner: direct swap of markDutyFailed for
  markDutyNotRequired at the len(beaconObjects) == 0 branch.
- CommitteeRunner: markDutyNotRequired before the sentinel return; the
  deferred markDutyFailed becomes a no-op via concludeDuty idempotency.
- CommitteeRunner.ProcessConsensus: the zero-valid-duties sentinel had no
  marker at all (false "stuck"); conclude it as not_required too.

The sentinel is still returned in every case, preserving committee_queue's
terminal-drop handling. Genuine terminal failures (expected-roots errors,
terminal BLS reconstruction, submit failures) remain classified failed.

Closes #2903
@momosh-ssv
momosh-ssv requested review from a team as code owners August 11, 2026 13:09
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.2%. Comparing base (48d4f3a) to head (5cbe3bc).

☔ 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 Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes zero-work committee-runner terminals from failed or unconcluded to not_required while preserving ErrNoValidDutiesToExecute for queue termination.

  • Marks zero valid consensus duties as not_required.
  • Reclassifies empty post-consensus object sets for committee and aggregator-committee runners.
  • Adds regression coverage for all three paths.

Confidence Score: 4/5

The PR should not merge until the committee post-consensus path distinguishes benign absence of duties from failures that prevent every beacon object from being constructed.

CommitteeRunner can swallow all per-validator construction or beacon-data errors into an empty object set, and the changed branch then records that missed submission as a successful not_required outcome.

Files Needing Attention: protocol/v2/ssv/runner/committee.go

Important Files Changed

Filename Overview
protocol/v2/ssv/runner/committee.go Adds not_required conclusions to zero-duty and empty-object paths, but the latter also masks cases where all object-building operations fail.
protocol/v2/ssv/runner/aggregator_committee.go Reclassifies a genuinely empty aggregator/contributor assignment as not_required while retaining propagated derivation errors as failures.
protocol/v2/ssv/runner/committee_postconsensus_classification_test.go Covers guard-invalidated empty-object and zero-duty outcomes but not an empty result caused by object-construction or beacon-data failures.
protocol/v2/ssv/runner/aggregator_committee_test.go Verifies that decided data with no aggregator or contributor assignment concludes not_required and preserves the queue sentinel.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Consensus decides] --> B{Valid local duties?}
  B -->|No| C[Conclude not_required]
  B -->|Yes| D[Collect post-consensus quorum]
  D --> E[Build beacon objects]
  E --> F{Objects produced?}
  F -->|Yes| G[Submit to beacon node]
  F -->|No| C
  C --> H[Return ErrNoValidDutiesToExecute]
  H --> I[Committee queue terminates runner]
Loading

Reviews (1): Last reviewed commit: "fix(runner): conclude benign ErrNoValidD..." | Re-trigger Greptile

Comment on lines +542 to +544
// the deferred markDutyFailed becomes a no-op. The sentinel still tells committee_queue to
// drop the message and terminate the runner.
r.markDutyNotRequired()

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.

P1 Empty objects mask construction failures

When every validator is skipped because duty validation, object construction, domain-data retrieval, or signing-root computation fails, this branch records the empty result as successful not_required, suppressing the failed outcome and warning for a missed submission.

Knowledge Base Used: Protocol v2 Duty Runners and QBFT Consensus

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!

The committee_queue case that catches ErrNoValidDutiesToExecute logged
"❗ could not handle message, dropping message and terminating
committee-runner" at Error level and marked the trace span as Error —
the same false alarm as the failed outcome classification, fired by the
same benign trigger. The runner now concludes the duty as not_required
on this sentinel, so the drop is a correct completion: log it at Debug
with neutral wording and set the span status to Ok. The drop-and-
terminate behavior is unchanged.
// not_required — not failed — before returning the sentinel; concludeDuty is idempotent, so
// the deferred markDutyFailed becomes a no-op. The sentinel still tells committee_queue to
// drop the message and terminate the runner.
r.markDutyNotRequired()

@iurii-ssv iurii-ssv Aug 11, 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.

Building on the existing P1 by Greptile from above (empty objects masking construction failures) rather than repeating it — two clarifications that should help decide the fix:

A template already exists in the sibling runner. AggregatorCommitteeRunner hits the same empty-objects terminal but deliberately surfaces construction/domain errors instead of swallowing them, so a genuine failure stays failed rather than becoming not_required — see the note at aggregator_committee.go#L1441-L1445. Here, expectedPostConsensusRootsAndBeaconObjects instead does logger.Debug(...); continue on every construct / DomainData / signing-root failure, so the two siblings disagree on what an empty map means. Returning an error when a validator is skipped for a non-benign reason (vs. guard-invalid) would realign them and preserve failed.

Severity is likely below P1 in practice. DomainData for this domain/epoch is already fetched successfully during consensus-phase signing (signBeaconObject) and is normally cached, so an all-validators failure surfacing only at post-consensus is an unlikely path. The guard-invalid case (divergent validator sets — the actual #2903 trigger) is the dominant real cause of an empty map.

Scope note: only this post-consensus branch is affected. The consensus-phase sibling change at L381-L387 is unambiguous — totalAttesterDuties is incremented before signing and signing errors return early via errCh, so a zero count there can only mean guard-invalid, never a swallowed failure.

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