-
Notifications
You must be signed in to change notification settings - Fork 150
fix(runner): conclude benign ErrNoValidDutiesToExecute as not_required, not failed #2988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
54b0116
5cbe3bc
c92bad9
cba5938
b0ef6a9
82e1c78
e4be291
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
| 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 | ||
| } | ||
|
|
||
|
|
@@ -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) | ||
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Severity is likely below P1 in practice. Scope note: only this post-consensus branch is affected. The consensus-phase sibling change at L381-L387 is unambiguous —
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c92bad9. A guard-invalid-only empty map stays not_required. Added a regression test with DomainData failing after consensus to pin the all-failure path.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Two consequences. First, metric skew:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 82e1c78. 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 | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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-checksctx.Err()before concludingnot_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:
markDutyFailedexplicitly filterscontext.Canceled, with a comment noting a cancelled duty was abandoned, not attempted-and-failed.markDutyNotRequiredhas no equivalent filter, so a shutdown mid-signing now produces a positiveduty.outcome=not_requireddata point claiming the duty completed correctly — even though whether it lands at all is non-deterministic (watchDutyOutcomepicks pseudo-randomly between the conclusion andctx.Done()when both fire together).On a rolling restart across a large fleet, this shows up as a burst of spurious
not_requiredoutcomes for committee duties that were simply abandoned mid-flight — exactly the kind of noise this PR is trying to remove from the metric.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.