Skip to content

miner: add producer stall and slow-announce metrics - #2478

Draft
cffls wants to merge 2 commits into
lmartins/backmerge-v2.10.2from
fchen/producer-stall-metrics
Draft

cffls wants to merge 2 commits into
lmartins/backmerge-v2.10.2from
fchen/producer-stall-metrics

Conversation

@cffls

@cffls cffls commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds producer metrics that do not depend on log parsing or the scrape interval, so slow and stalled block production can be alerted on directly.

The "Slow block produced" alert reads log lines that each cover only part of the production path: Commit new sealing work times fill and finalize, and Successfully sealed new block times seal, gate and write. Each one missed a recent incident: mainnet block 95,080,502 (4.4s in finalize, sealed log 19ms) and Amoy block 49,266,577 (4s after seal, commit log 878ms). worker/build_to_announce covers the whole path, but its decaying percentiles stay high for hours after one slow block and cannot count events.

Metric Type Meaning
worker/build_to_announce timer (type unchanged) Now starts when the producer was first allowed to build: the later of the commitWork start and the parent slot boundary. Prepare work (sealing state, sequencer adoption read, bor.Prepare, makeEnv) is included; the deliberate Giugliano wait for the boundary is not. Same rule on the normal and pipelined paths.
worker/build_to_announce/over2s, /over4s counter Announcements slower than 2s / 4s. Exact counts, independent of scrape interval.
worker/producer/stalled gauge Block number this node is stuck on as the span producer, or 0. Set when the next block is not announced within 2s of being allowed to build it; cleared by the announcement, a new head, or stopping the miner.
worker/producer/stalls counter Stall episodes, so stalls shorter than a scrape are still visible.

The producer check uses the existing Bor.IsAuthorizedSigner (as the sequencer does): after Rio each span has a single producer. It only runs after Rio, and only when the 2s clock expires, so its snapshot/span lookup stays off the production path. The stall clock uses the node's monotonic clock, not whole-second chain age, which reads 2 during normal operation.

Suggested alert rules:

increase(worker_build_to_announce_over4s[5m]) > 0       # page
increase(worker_build_to_announce_over2s[1h]) > 3       # warn
worker_producer_stalled > 0                             # stall in progress
increase(worker_producer_stalls[5m]) > 0                # any stall, including short ones

Stacked on #2473 (v2.10.2 backmerge, which brings IsAuthorizedSigner into develop). The base retargets to develop when #2473 merges.

Executed tests

  • go test -race ./miner/... ./consensus/bor/...: pass. New tests (run 25× under -race, no flakes) cover the 2s/4s threshold edges (exactly 2s/4s not counted, 1ms over counted), the slot-boundary start (head before/after the boundary, pre-Giugliano, non-Bor), every way the stall watch starts and clears (threshold, announcement, new head, stale announcement, stop/close, announcement racing the producer check), the producer check before/after Rio, and both announce paths.
  • golangci-lint v2.11.4 (repo config): 0 issues; golangci-lint fmt: clean; go build ./...: ok.
  • diffguard against the base: complexity, size, dependency and dead-code checks pass. Mutation score 94.9% (T1 logic 100%). Remaining survivors:
    • production_monitor.go:50: the timer update is a no-op when metrics are disabled, the test default.
    • pipeline.go:1085: the existing pipelined: true flag on a line this PR edits; production pipelining is disabled.
    • worker.go:1011, :1022, :1531: the start/head/announce hooks. Applied by hand, each mutant fails TestWorkerStallWatchLifecycle / TestWorkerStallWatchFollowsChainHead; diffguard still reports them as surviving.

No devnet or Kurtosis run yet.

Rollout notes

  • Not consensus-affecting: metrics only, no change to block contents, timing or validation. No coordinated upgrade.
  • worker/build_to_announce values shift: the start moves earlier by the time spent in prepare (excluding the Giugliano boundary wait). Dashboards using it will see slightly higher values.
  • New metric names only; nothing removed or renamed in the exported set.
  • Alerts based on the Successfully sealed new block log can move to the counters above once this ships.

cffls added 2 commits October 7, 2026 16:11
Alerting on slow block production relied on log lines that each cover
only part of the path: "Commit new sealing work" times fill and
finalize, "Successfully sealed new block" times seal, gate and write.
worker/build_to_announce covers both, but its decaying percentiles stay
high for hours after one slow block and cannot count events.

- worker/build_to_announce now starts when the producer was first
  allowed to build: the later of the commitWork start and the parent
  slot boundary. Prepare work is included; the deliberate Giugliano
  wait for the boundary is not. Both announce paths use the same start.
- worker/build_to_announce/over2s and /over4s count announcements
  slower than 2s and 4s, independent of the scrape interval.
- worker/producer/stalled holds the block number this node is stuck on
  as primary producer (0 otherwise): set when the next block is not
  announced within 2s of being allowed to build it, cleared by the
  announcement, a new head, or stopping the miner.
  worker/producer/stalls counts stall episodes.
- Bor.IsPrimaryProducer reports whether the local signer is the
  in-turn producer. It shares IsAuthorizedSigner's checks, which now
  go through one succession lookup with unchanged behavior.

The producer check reads the snapshot (possibly a span lookup), so it
runs only when the stall clock expires, off the production path.
…heck

After Rio each span has a single producer, so the authorized signer is
the producer and the existing IsAuthorizedSigner (already used by the
sequencer) answers "is this node the current producer". Drop the new
IsPrimaryProducer helper and its refactor; consensus/bor is unchanged
relative to the base.

The stall check only runs after Rio: before it every validator is
authorized, so no single node would be singled out.
@codecov

codecov Bot commented Oct 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 57.65%. Comparing base (040598e) to head (82ee942).

Additional details and impacted files

Impacted file tree graph

@@                      Coverage Diff                       @@
##           lmartins/backmerge-v2.10.2    #2478      +/-   ##
==============================================================
+ Coverage                       57.60%   57.65%   +0.05%     
==============================================================
  Files                             959      960       +1     
  Lines                          176094   176239     +145     
==============================================================
+ Hits                           101433   101611     +178     
+ Misses                          68984    68957      -27     
+ Partials                         5677     5671       -6     
Files with missing lines Coverage Δ
miner/pipeline.go 90.00% <100.00%> (-0.03%) ⬇️
miner/production_monitor.go 100.00% <100.00%> (ø)
miner/worker.go 84.30% <100.00%> (+0.40%) ⬆️

... and 19 files with indirect coverage changes

Files with missing lines Coverage Δ
miner/pipeline.go 90.00% <100.00%> (-0.03%) ⬇️
miner/production_monitor.go 100.00% <100.00%> (ø)
miner/worker.go 84.30% <100.00%> (+0.40%) ⬆️

... and 19 files with indirect coverage changes

🚀 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.

@cffls

cffls commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding on producerStallWatch re-arming without a generation token, I also checked two other angles and ruled them out: the stall watch's time.AfterFunc design means no goroutine is spawned until expiry and resetLocked/close always Stop the timer, so there's no unbounded goroutine growth; and buildStart is captured at commitWork entry per real block, so an optional pre-Rio empty pre-seal commit doesn't skew the build_to_announce start for the sealed block.

Extended reasoning...

The diff adds a producer-stall and slow-announce metrics feature to miner/, touching producer timing and Giugliano/Rio fork-gated logic flagged as hardfork-shaped and consensus-adjacent by the repo's own rules, though it changes no consensus value (metrics only). No auth/crypto/injection surface is touched. A CONFIRMED race (duplicate watch arming on same-height head events) is already queued as an inline finding, which is reason enough to keep a human in the loop; I'm adding only the two ruled-out checks above since they're not already visible in the PR thread.

Comment on lines +237 to +257
func (s *producerStallWatch) expire(height uint64, next *types.Header) {
if !s.pending(height) {
return
}

// Called without the lock: the producer check can block on a span lookup.
if s.headNumber() >= height || !s.isProducer(next) {
return
}

s.mu.Lock()
defer s.mu.Unlock()

if s.closed || s.height != height || s.isStalled {
return
}

s.isStalled = true
s.stalled.Update(int64(height))
s.stalls.Inc(1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) A one-block tip reorg (or startCh+chainHeadCh landing on the same next-block height) can trigger a false worker/producer/stalled alert and spurious worker/producer/stalls increment. watch() at production_monitor.go:172 re-arms a watch for the same height without any epoch/generation token; if the prior timer already fired and its goroutine is blocked in expire()'s unlocked isProducer/headNumber calls (production_monitor.go:237-257), it resumes after the rewatch, still finds s.height == height in pending()/expire(), and reports the block stalled using the old deadline even though the new watch has more time left. …

Why this was flagged

…Fix: tag each watch() call with a generation id (bumped in resetLocked) and have expire() compare the generation, not just height, before reporting, so a superseded watch's late callback is always a no-op.

Trigger: watchNextBlock (worker.go:1011/1022) computes next.Number = head.Number+1, so two head events whose heads share a number (sibling block from a short reorg, or startCh and chainHeadCh firing close together at startup) call producerStallWatch.watch (production_monitor.go:172) twice for the same height. watch() calls resetLocked (production_monitor.go:266) which Stop()s the old timer and re-sets s.height to the identical value, then arms a fresh timer. If the old timer had already fired, Stop() returns false and its goroutine, already past the pending() check, is mid-flight in expire()'s unlocked isProducer/headNumber calls (production_monitor.go:245-246); on resuming it still finds s.height==height (production_monitor.go:250) and sets isStalled, incrementing stalled/stalls.

Verification: nit — The race is real and reachable. expire() (production_monitor.go:237) passes pending(height) at line 238, then runs the unlocked isProducer/headNumber check at line 243 (comment at 242 notes it "can block on a span lookup"). If a second watch() for the SAME height arrives during that window (watchNextBlock at worker.go:1011/1022 computes head.Number+1, so a 1-block tip reorg to a sibling at the same number re-watches the same H), resetLocked() (line 266) sets s.height→0 then watch() re-sets s.height = H (lines 186-187) and arms a fresh timer2 (line 188).

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.

1 participant