Skip to content
17 changes: 12 additions & 5 deletions protocol/v2/ssv/runner/aggregator_committee.go
Original file line number Diff line number Diff line change
Expand Up @@ -887,11 +887,18 @@ func (r *AggregatorCommitteeRunner) ProcessPostConsensus(
return fmt.Errorf("could not get expected post consensus roots and beacon objects: %w", err)
}
if len(beaconObjects) == 0 {
// Empty post-quorum (all beacon objects failed to build) is terminal and non-recoverable:
// committee_queue drops the message and terminates the runner on this error. Classify as
// failed (matching CommitteeRunner) rather than leaving the watcher to report a false stuck.
r.markDutyFailed(ErrNoValidDutiesToExecute)
return ErrNoValidDutiesToExecute
// NOT the benign terminal it is in the sibling CommitteeRunner. There, beaconObjects is built
// from LOCAL state, so divergent validator sets legitimately empty it on one operator. Here it
// is built purely from the DECIDED value with every error surfaced above, so an empty map means
// the decided data had zero aggregators and zero contributors — a value
// AggregatorCommitteeConsensusData.Validate() rejects and validateDecidedConsensusData enforces
// before it is ever stored as decided. Reaching this branch is an invariant violation (bypassed
// or regressed decided-value validation), a consensus-integrity signal that must stay loud:
// classify as failed. The sentinel is preserved in the chain so committee_queue still drops the
// message and terminates the runner.
err := fmt.Errorf("no beacon objects from decided data, decided-value validation should have rejected it: %w", ErrNoValidDutiesToExecute)
r.markDutyFailed(err)
return err
}

sort.Slice(roots, func(i, j int) bool {
Expand Down
42 changes: 42 additions & 0 deletions protocol/v2/ssv/runner/aggregator_committee_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,48 @@ func TestAggregatorCommitteeRunnerProcessPostConsensus_MarksFailedOnSubmitError(
require.False(t, env.runner.State.Succeeded, "a failed duty must not be marked succeeded")
}

// TestAggregatorCommitteeRunnerProcessPostConsensus_MarksFailedOnNoBeaconObjects pins the
// empty-objects terminal as an invariant violation, NOT a benign no-op: beaconObjects is built purely
// from the decided value (every construction error surfaced), and a decided value with zero
// aggregators and zero contributors is rejected by AggregatorCommitteeConsensusData.Validate() before
// it can ever be stored as decided. So reaching the branch means decided-value validation was
// bypassed or regressed — it must conclude failed (loud, via the outcome watcher's warn) while still
// carrying the sentinel for the queue's terminal-drop handling. The test necessarily hand-installs
// State.DecidedValue to force the condition, because the normal flow cannot produce it.
func TestAggregatorCommitteeRunnerProcessPostConsensus_MarksFailedOnNoBeaconObjects(t *testing.T) {
ctx := t.Context()
const version = spec.DataVersionElectra

base := protocoltesting.NewTestingBeaconNodeWrapped().(*protocoltesting.BeaconNodeWrapped)
env := newAggregatorCommitteeRunnerEnv(t, []int{1}, base)
duty := spectestingutils.TestingAggregatorCommitteeDutyForValidators([]int{1}, []int{}, version)

concluded := env.startAndFeedThroughConsensus(t, ctx, duty, version)

emptyDecided := &spectypes.AggregatorCommitteeConsensusData{Version: version}
encoded, err := emptyDecided.Encode()
require.NoError(t, err)
env.runner.State.DecidedValue = encoded

var postConsensusErr error
for _, psig := range postConsensusMsgsFromFixture(duty, env.keySetMap, version) {
if err := env.runner.ProcessPostConsensus(ctx, env.logger, psig); err != nil {
postConsensusErr = err
}
}

require.ErrorIs(t, postConsensusErr, ErrNoValidDutiesToExecute, "the sentinel must surface so the queue drops the message and terminates the runner")

select {
case c := <-concluded:
require.Equal(t, dutyOutcomeFailed, c.outcome, "empty beacon objects from decided data is an invariant violation and must conclude failed")
require.ErrorIs(t, c.reason, ErrNoValidDutiesToExecute)
default:
t.Fatal("expected a failed duty conclusion, got none")
}
require.False(t, env.runner.State.Succeeded, "an invariant violation must not be marked succeeded")
}

// TestAggregatorCommitteeRunnerProcessPostConsensus_DoesNotMarkFailedOnInvalidSigs asserts that the
// recoverable reconstruct-invalid-signatures case is NOT concluded failed: the root can later re-cross
// quorum on a subsequent message, so concluding here would mask a duty that still completes.
Expand Down
59 changes: 59 additions & 0 deletions protocol/v2/ssv/runner/committee.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,26 @@ listener:
)

if totalAttestations == 0 && totalSyncCommittee == 0 {
// A canceled context also lands here with zero counts: the duty feeder and the workers bail
// out on ctx.Err() before incrementing any counter. That is shutdown — the duty was abandoned,
// not completed-with-nothing-to-do — so return without concluding an outcome, mirroring
// markDutyFailed's context.Canceled filter (cancellation is never an outcome).
if err := ctx.Err(); err != nil {
return err
}
// Benign terminal: the committee decided but this operator ended up with zero valid duties to
// sign. Conclude as not_required so the watcher doesn't report a false "stuck"; 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.

Finding 2 · [MINOR] Guard the consensus-phase not_required against context cancellation (shutdown now records a benign outcome)

In ProcessConsensus, the zero-duties branch triggers not only when every duty is filtered by the DutyGuard, but also when the context is cancelled — neither the feeder nor the workers increment counters on cancellation, and nothing re-checks ctx.Err() before concluding not_required. A cancellation observed here means the parent context (node/committee shutdown) was cancelled.

This matters because the codebase deliberately treats cancellation as "not attempted", never as an outcome: markDutyFailed explicitly filters context.Canceled, with a comment noting a cancelled duty was abandoned, not attempted-and-failed. markDutyNotRequired has no equivalent filter, so a shutdown mid-signing now produces a positive duty.outcome=not_required data point claiming the duty completed correctly — even though whether it lands at all is non-deterministic (watchDutyOutcome picks pseudo-randomly between the conclusion and ctx.Done() when both fire together).

On a rolling restart across a large fleet, this shows up as a burst of spurious not_required outcomes for committee duties that were simply abandoned mid-flight — exactly the kind of noise this PR is trying to remove from the metric.

@momosh-ssv momosh-ssv Aug 18, 2026

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.

Fixed in b0ef6a9.
The zero-counts branch now re-checks ctx.Err() and returns the cancellation without concluding any outcome, mirroring markDutyFailed's context.Canceled filter.

Added a cancelled-context regression test.

r.measurements.EndDutyFlow()
recordTotalDutyDuration(ctx, r.measurements.TotalDutyTime(), spectypes.RoleCommittee, r.State.RunningInstance.State.Round)
const dutyFinishedNoValidDutiesEvent = "✔️successfully finished duty processing (no valid duties to sign)"
logger.Info(dutyFinishedNoValidDutiesEvent,
fields.ConsensusTime(r.measurements.ConsensusTime()),
fields.ConsensusRounds(uint64(r.State.RunningInstance.State.Round)),
fields.TotalDutyTime(r.measurements.TotalDutyTime()),
)
span.AddEvent(dutyFinishedNoValidDutiesEvent)
return ErrNoValidDutiesToExecute
}

Expand Down Expand Up @@ -514,6 +534,9 @@ func (r *CommitteeRunner) ProcessPostConsensus(ctx context.Context, logger *zap.
// are tagged recoverableReconstructError and must not be recorded as failed.
// Shutdown (context cancellation) needs no special-casing — markDutyFailed drops a context.Canceled
// reason, so a submission aborted by shutdown isn't recorded as a failure.
// The benign no-beacon-objects sentinel (ErrNoValidDutiesToExecute) pre-concludes the duty as
// not_required before returning, which makes this deferred markDutyFailed a no-op (concludeDuty
// is idempotent) — it must not be recorded as failed either.
defer func() {
if err != nil && !isRecoverableReconstructError(err) {
r.markDutyFailed(err)
Expand All @@ -529,6 +552,24 @@ func (r *CommitteeRunner) ProcessPostConsensus(ctx context.Context, logger *zap.
return fmt.Errorf("could not get expected post consensus roots and beacon objects: %w", err)
}
if len(beaconObjects) == 0 {
// Benign terminal: the committee reached consensus but this operator has no beacon objects to
// submit (divergent validator sets across the committee's operators — every duty was skipped
// as guard-invalid). An empty map here is guaranteed benign: an all-construction-failure empty
// result is surfaced as an error by expectedPostConsensusRootsAndBeaconObjects above and
// classified failed by the defer. Conclude as not_required 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.

@momosh-ssv momosh-ssv Aug 18, 2026

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.

Fixed in c92bad9.
Went with the realignment you suggested, scoped to the empty-map terminal: expectedPostConsensusRootsAndBeaconObjects now collects the construct/DomainData/signing-root errors and surfaces them when nothing could be built, so that empty result concludes failed via the defer, while partial failures keep the per-validator debug-and-continue behavior (one validator's failure shouldn't block the others' submissions).

A guard-invalid-only empty map stays not_required. Added a regression test with DomainData failing after consensus to pin the all-failure path.

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.

Finding 3 · [MINOR] New not_required terminals skip EndDutyFlow/recordTotalDutyDuration and leave no operator-visible completion line

Every pre-existing markDutyNotRequired() call site pairs the conclusion with duty-flow bookkeeping (EndDutyFlow + recordTotalDutyDuration) and usually an Info completion log. The three new sites this PR adds (committee.go:385, committee.go:544, aggregator_committee.go:894) call the marker alone.

Two consequences. First, metric skew: ssv.runner.duty.outcome{not_required} increments with no matching total-duty-duration sample, and the duty-flow measurement is left open — any dashboard reconciling outcome counts against the duration histogram shows a permanent, unexplained gap. Second, zero operator-visible evidence: before this PR the operator saw duty failed plus an error-level queue log; after, the metric is benign, the runner itself logs nothing, and a related change in this PR downgrades the queue's own log line to Debug. A committee runner terminating early for the slot becomes invisible at default log level — which matters independently of whether the classification itself is correct, since it's the operator's only handle if that classification turns out wrong.

@momosh-ssv momosh-ssv Aug 18, 2026

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.

Fixed in 82e1c78.
Both committee sites now close the duty flow (EndDutyFlow + recordTotalDutyDuration) and log an Info completion line, matching the pre-existing not_required sites.

The aggregator-committee site went back to failed in cba5938, so operator visibility there comes from the outcome watcher's warn.

r.measurements.EndDutyFlow()
recordTotalDutyDuration(ctx, r.measurements.TotalDutyTime(), spectypes.RoleCommittee, r.State.RunningInstance.State.Round)
const dutyFinishedNoBeaconObjectsEvent = "✔️successfully finished duty processing (no beacon objects to submit)"
logger.Info(dutyFinishedNoBeaconObjectsEvent,
fields.ConsensusTime(r.measurements.ConsensusTime()),
fields.ConsensusRounds(uint64(r.State.RunningInstance.State.Round)),
fields.PostConsensusTime(r.measurements.PostConsensusTime()),
fields.TotalDutyTime(r.measurements.TotalDutyTime()),
)
span.AddEvent(dutyFinishedNoBeaconObjectsEvent)
return ErrNoValidDutiesToExecute
}

Expand Down Expand Up @@ -995,6 +1036,16 @@ func (r *CommitteeRunner) expectedPostConsensusRootsAndBeaconObjects(ctx context
epoch := r.NetworkConfig.EstimatedEpochAtSlot(slot)
dataVersion, _ := r.NetworkConfig.ForkAtEpoch(epoch)

// Skips fall into two classes: guard invalidations are benign (the #2903 divergent-validator-sets
// case — the duty is genuinely not this operator's to submit), while construction / domain-data /
// signing-root failures mean a submission was missed. The distinction only matters when NOTHING
// could be built: partial failures keep the per-validator debug-and-continue behavior so one
// validator's failure never blocks the others' submissions, but an all-failure empty result must
// surface as an error — otherwise the caller's len(beaconObjects)==0 branch would conclude the
// duty not_required, masking the miss (the sibling AggregatorCommitteeRunner surfaces these
// errors for the same reason).
var constructionErr error

for _, validatorDuty := range committeeDuty.ValidatorDuties {
if validatorDuty == nil {
continue
Expand All @@ -1013,19 +1064,22 @@ func (r *CommitteeRunner) expectedPostConsensusRootsAndBeaconObjects(ctx context
attestationResponse, err := specssv.ConstructVersionedAttestationWithoutSignature(attestationData, dataVersion, validatorDuty)
if err != nil {
logger.Debug("failed to construct attestation", zap.Error(err))
constructionErr = errors.Join(constructionErr, fmt.Errorf("construct attestation (validator %d): %w", validatorDuty.ValidatorIndex, err))
continue
}

// Root
domain, err := r.GetBeaconNode().DomainData(ctx, epoch, spectypes.DomainAttester)
if err != nil {
logger.Debug("failed to get attester domain", zap.Error(err))
constructionErr = errors.Join(constructionErr, fmt.Errorf("get attester domain (validator %d): %w", validatorDuty.ValidatorIndex, err))
continue
}

root, err := spectypes.ComputeETHSigningRoot(attestationData, domain)
if err != nil {
logger.Debug("failed to compute attester root", zap.Error(err))
constructionErr = errors.Join(constructionErr, fmt.Errorf("compute attester root (validator %d): %w", validatorDuty.ValidatorIndex, err))
continue
}

Expand All @@ -1047,13 +1101,15 @@ func (r *CommitteeRunner) expectedPostConsensusRootsAndBeaconObjects(ctx context
domain, err := r.GetBeaconNode().DomainData(ctx, epoch, spectypes.DomainSyncCommittee)
if err != nil {
logger.Debug("failed to get sync committee domain", zap.Error(err))
constructionErr = errors.Join(constructionErr, fmt.Errorf("get sync committee domain (validator %d): %w", validatorDuty.ValidatorIndex, err))
continue
}
// Eth root
blockRoot := spectypes.SSZBytes(beaconVote.BlockRoot[:])
root, err := spectypes.ComputeETHSigningRoot(blockRoot, domain)
if err != nil {
logger.Debug("failed to compute sync committee root", zap.Error(err))
constructionErr = errors.Join(constructionErr, fmt.Errorf("compute sync committee root (validator %d): %w", validatorDuty.ValidatorIndex, err))
continue
}

Expand All @@ -1067,6 +1123,9 @@ func (r *CommitteeRunner) expectedPostConsensusRootsAndBeaconObjects(ctx context
return nil, nil, nil, fmt.Errorf("invalid duty type: %s", validatorDuty.Type)
}
}
if len(beaconObjects) == 0 && constructionErr != nil {
return nil, nil, nil, fmt.Errorf("no beacon objects could be built: %w", constructionErr)
}
return attestationMap, syncCommitteeMap, beaconObjects, nil
}

Expand Down
Loading