Skip to content

feat: instruments proposer QBFT lifecycle - #1105

Draft
jnhsigmap wants to merge 13 commits into
sigp:unstablefrom
jnhsigmap:feat/1067/boundary-instrumentation
Draft

feat: instruments proposer QBFT lifecycle#1105
jnhsigmap wants to merge 13 commits into
sigp:unstablefrom
jnhsigmap:feat/1067/boundary-instrumentation

Conversation

@jnhsigmap

@jnhsigmap jnhsigmap commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Problem, Evidence, and Context (Required)

Note: I understand that #1066 drifted materially from the original agreed observability model in #919 and I think that we should explicitly aim to realign. This would obviously touch already-merged components and should sensibly land in a separate change that follows this one. See #1104 for detail.

Change Overview (Required)

  • Observes a proposer QBFT instance's state at the boundary by calling into it.
  • Observability added:
    • tracing span with lifecycle checkpoint events (start, proposal accepted, prepare quorum, round advance, decided/timed-out/channel-closed).
    • 5 Prometheus metrics (decided round, round advances by reason, handoff budget, outcome, total duration).
  • Handoff budget (slot time remaining at consensus start) computed inside qbft_manager::decide_instance from the duty slot for proposer instances only.
  • QBFT state is read at recv-loop boundaries via existing accessors. Emission is encapsulated in an observer instance owned by the instrumentation.rs module. Hot loop controls instrumentation semantically through methods offered by observer.
  • The decided-round metric/span now records the round from the decided certificate via a new read-only Qbft::decided_round() and not the local round cursor. This is a better choice for cross-round decided commits.
  • Instance span/start event now carries start_round/start_state so round advances during buffered-message replay (if applicable) before the observer exists are at least traceable through recording the starting state.
  • Reviewer guide: Start with the instrumentation.rs module (observer + taxonomy), then handoff_budget calculation in qbft_manager/src/lib.rs method decide_instance, and the common/qbft determination of decided_round, then the instance loop call sites in instance.rs.
  • Intentionally unchanged: common/qbft consensus logic (gains only a read-only state accessor from feat(qbft_manager): add instrumentation taxonomy and metrics surface #1068), timeout formulas, leader selection, and all non-proposer duties.

Risks, Trade-offs, and Mitigations (Required)

  • Blast radius: qbft_manager (instance loop, instrumentation, decide_instance internals) and a read-only behavior-neutral addition to common/qbft (a decided_round field and getter. current_round is never mutated).
  • Trade-off: Observation is implemented for proposer instances only. Non-proposer duties skip the state machine snapshot on each loop iteration. Other duties bypass instrumentation gated by observer and snapshot being created.
  • Mitigation: Round-advance classifier is pure and unit-tested. Proposer path covered end-to-end for both terminal outcomes (instance decided and max-round timeout). metric/label cardinality is bounded (high-cardinality values stay in spans, never in Prometheus labels).

Validation (Required)

  • Existing: qbft_manager tests and real-Qbft correspondence tests (from feat(qbft_manager): add instrumentation taxonomy and metrics surface #1068).
  • New:
    • 3 proposer e2e tests (decided / max-round-timeout / channel-closed).
    • max-timeout test now asserts the handoff-budget histogram (count + ms→s sum).
    • decided_round coverage in common/qbft (a discriminating certificate-round != cursor case and a commit-quorum case).
    • unit tests for the relocated compute_handoff_budget_ms helper.
  • make cargo-fmt-check, make lint (-D warnings), and make test all pass.

Rollback (Required for behavior or runtime changes; optional otherwise)

  • Revert this commit. This will remove instrumentation and metric wiring that has no behavioural impact beyond adding instrumentation gates for proposer duty flow.
  • Operational impact is additive. New metric series and span/log events and the tracing span is a no-op without a OTLP collector. No existing metric is removed or renamed in this PR.

Additional Info / Next Steps (Optional)

@codecov-commenter

codecov-commenter commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.26415% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.09%. Comparing base (eb138a6) to head (c5841e9).

Files with missing lines Patch % Lines
anchor/qbft_manager/src/instance.rs 83.52% 14 Missing ⚠️
anchor/qbft_manager/src/instrumentation.rs 86.74% 11 Missing ⚠️
anchor/qbft_manager/src/lib.rs 94.73% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #1105      +/-   ##
============================================
+ Coverage     62.70%   63.09%   +0.38%     
============================================
  Files           161      161              
  Lines         27000    27203     +203     
============================================
+ Hits          16930    17163     +233     
+ Misses        10070    10040      -30     
Flag Coverage Δ
rust 63.09% <87.26%> (+0.38%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

@jnhsigmap
jnhsigmap requested a review from petarjuki7 June 29, 2026 06:20
@jnhsigmap

Copy link
Copy Markdown
Contributor Author

@claude review this PR based on the criteria of #1066/#1067 and #921. Then provide separate analysis and evaluation of recommendation in #1104 regarding drift from original taxonomy.

@claude-code-actions-sigp

claude-code-actions-sigp Bot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @jnhsigmap's task in 4m 37s —— View job


PR Review: feat: instruments proposer QBFT lifecycle

Note on context access: GitHub API and WebFetch were not permitted in this environment, so I evaluated against the acceptance criteria you summarised in the PR body and the source diff rather than the canonical issue text. Treat the AC mapping below as a structural read, not a textual one.


Part 1 — Review against #1066/#1067/#921 criteria

Mapping to the four acceptance criteria stated in the PR body:

Criterion (per PR body) Implementation Verdict
tracing span with lifecycle checkpoint events ProposerObserver opens info_span!("proposer_qbft_instance", …) and emits start / proposal-accepted / prepare-quorum / round-advance / decided / timed-out / channel-closed via in_scope ✅ wired
5 Prometheus metrics PROPOSER_QBFT_DECIDED_ROUND, PROPOSER_QBFT_DURATION_SECONDS, PROPOSER_QBFT_HANDOFF_BUDGET_SECONDS, PROPOSER_QBFT_OUTCOME_TOTAL, PROPOSER_ROUND_ADVANCE_TOTAL ✅ all 5 created and observed
Handoff budget pre-computed at the proposer caller validator_store::lib.rs:584-587 derives slot_duration_ms - slot_elapsed_ms (saturating) and threads it through decide_instance to QbftInitialization ✅ correct site (validator_store), correct semantic (None when slot clock unknown)
State read at recv-loop boundaries only via existing accessors; no consensus-logic change instance.rs:307-311 snapshots (state_kind, get_round) only when Initialized and observer exists; common/qbft exposes only state_kind() (added in #1068) and get_round() ✅ — consensus logic untouched; observation is read-only at the boundary

Things done well

  • Pure classifier (classify_round_advance) with payload-free InstanceStateKind is the right abstraction — separates "what happened" from "what to emit" and is unit-testable without a live Qbft. Real-Qbft correspondence tests (Timeout, FPlusOne, RoundChangeQuorum-no-advance, RoundChangeQuorum-above-f+1, FutureProposal) close the loop and de-risk classifier drift against the consensus crate. This matches what was promised in feat(qbft_manager): add instrumentation taxonomy and metrics surface #1068's scaffolding.
  • Cardinality is bounded: 3 outcomes × 4 round-advance reasons; high-cardinality fields (round numbers, height) live in span fields/events only, never in Prom labels. ✅ matches the PR's stated mitigation.
  • handoff_budget_ms: Option<u64> (rather than 0 sentinel) preserves the "absent vs. zero budget" distinction — important for distinguishing missing slot clock from a real near-zero budget.
  • Outcome ordering: in the RecvResult::Closed branch the observer records ChannelClosed before complete(Completed::TimedOut) is sent to listeners, and the post-loop completed() block is unreachable after break, so outcomes are not double-counted. Confirmed by reading instance.rs:440-477.
  • Non-proposer paths threading None for handoff_budget_ms is explicit at each call site with a comment — slightly noisy but unambiguous.

Concerns (confidence-rated)

1. observe_state_transition fires alongside observe_round_advance on the same iteration — likely intentional, but the comment is misleading (high confidence, low severity)

In instance.rs:362-402, when a message arrives and the round advances and state changed (e.g., FutureRoundProposal lands in Prepare), both observers fire on the same (before_kind, after_kind) pair. observe_state_transition has no "same-round" guard despite the doc comment on instrumentation.rs:197-198 stating "when the instance changes state into Prepare or Commit within the same round".

This means a single message can emit both qbft_round_advance{reason=future_proposal} and qbft_proposal_accepted against the same boundary. If that's intended (catch proposal-accepted regardless of round), update the doc on observe_state_transition to match. Otherwise, gate emission on before_round == after_round. Either way the metric labels stay bounded.

2. PROPOSER_QBFT_DECIDED_ROUND description is incomplete on the ChannelClosed outcome (medium confidence, low severity)

metrics.rs:11 describes the histogram as "Round on which proposer QBFT decided or timed out", but ProposerObserver::finish is also called from the RecvResult::Closed branch (instance.rs:445-450). For ChannelClosed, this records the current round at shutdown which is a third semantic. Either:

  • Skip the PROPOSER_QBFT_DECIDED_ROUND observation on ChannelClosed (probably more honest — there is no decision), or
  • Update the help text to "Round on which proposer QBFT terminated (decided, timed out, or channel closed)".

PROPOSER_QBFT_OUTCOME_TOTAL{outcome="channel_closed"} already conveys the distinction; the round histogram becomes noisy if abnormal shutdowns are mixed in.

3. u64::try_from(*get_instance_height()).unwrap_or(u64::MAX) saturates to a misleading sentinel (low confidence, low severity)

instance.rs:336-339: on the "unreachable on 64-bit targets" path, recording instance_height = u64::MAX in the span would be actively misleading if it ever did surface. Given the comment, either drop the saturate and prefer as u64 (lossless on 64-bit) or refuse to construct the observer at all on overflow. Pragmatically a no-op concern, but a sentinel-as-truth is a latent footgun.

4. Per-call-site handoff_budget_ms: Option<u64> parameter has 5 None propagations (design-question, no defect)

Every non-proposer decide-instance caller writes None with an identical comment. An alternative shape would be enum InstrumentationContext { Proposer { handoff_budget_ms: Option<u64> }, NonProposer } or letting the observer derive proposer-ness internally (it already does via is_proposer() in instance.rs:202-204). The current shape is correct, just noisy; not blocking.

5. Span time vs. recorded duration_ms (informational)

The info_span! is never entered as a long-lived guard — in_scope only enters momentarily for each event. This is the right pattern (entering across .await is awkward), but downstream OTLP consumers will see a span whose built-in duration ≈ sum of event-emission moments, not the instance lifetime. You're recording duration_ms as a span field explicitly to compensate, which is correct. Worth noting in operator docs so dashboards know to use duration_ms, not the span's wall time.

6. Initialization-window buffered replay is unobserved (known limitation, called out in PR body)

instance.rs:325-342: the observer is created only after initialize() returns, so any round advances that happen while Uninitialized::initialize replays buffered messages (lines 164-174) are not classified. The code comment acknowledges this and finish() still captures the terminal outcome. Acceptable for v1; might be worth filing as follow-up if late-arriving proposer instances become a real-world hotspot.


Validation

  • The two new e2e tests (test_proposer_instance_decided_runs_observer, test_proposer_instance_max_round_timeout_runs_observer) drive both terminal branches and assert the outcome counter advances. The MaxRoundTimeout test also passes a non-None HANDOFF_BUDGET_MS = 4_000 so the budget-recording path is exercised. ✅
  • Classifier coverage in instrumentation.rs::tests includes pure-state cases + real-Qbft correspondence for each RoundAdvanceReason. ✅
  • I did not run make cargo-fmt-check, make lint, or make test in this review run; the PR body asserts they all pass and CI should confirm.

Part 2 — Recommendation in #1104: taxonomy drift from #919

I could not load #1104 / #919 directly in this environment, so this is a principled evaluation of the trade-off you described in the PR body, not a textual evaluation of #1104.

The case for deferring realignment (PR body's position):

  • Splitting taxonomy realignment from observability wiring keeps each PR independently reviewable. This PR is additive (new metrics, new spans, new tests); a taxonomy realignment is transformative (renames affecting already-merged components and sibling milestone spans). Mixing them obscures both the behavioral change here and the design intent of the realignment.
  • The realignment scope is broader than this PR (feat(qbft_manager): add instrumentation taxonomy and metrics surface #1068's already-merged surface), and forcing it through here either expands the blast radius beyond what's already declared or partially realigns and leaves the codebase in an inconsistent middle state.

The case for realigning before this lands:

  • Prometheus metric names and label sets are effectively a public operator API once deployed. anchor_proposer_qbft_* series will start being scraped, dashboards and alerts will reference them, and operators will pin queries. Renaming a Prom counter post-deploy is an operationally breaking change (it shows as a gap until the new series exists in storage; alert rules need synchronized updates).
  • Span field names and event names have a softer compatibility surface (consumers are usually internal OTLP pipelines), but every consumer query that gets written before realignment is wasted work.
  • The drift-cost is monotonic in time-to-realign × users-of-the-current-naming. If Re-align proposer QBFT instrumentation taxonomy with the original observability model #1104 will land before anyone scrapes these series in production, deferral is essentially free. If there's any window where these metrics will be consumed before realignment, the cost compounds.

My recommended decision rule:

Drift category Recommendation
Span/event names and field names Defer — internal consumers, low rename cost. Land #1104 after.
Prom metric names (the anchor_proposer_qbft_* identifiers) Realign before merge — once these are scraped in any environment that anyone builds a dashboard against, renames become operator-visible breakage. The list is only 5 metrics; if their final names disagree with #919, fix the names in this PR.
Prom label keys/values (e.g., outcome, reason, f_plus_1_rc) Realign before merge — same argument; label-name churn breaks queries.
Internal Rust enum/variant names (ProposerOutcome, RoundAdvanceReason) Defer — no external surface.

Concretely: if #1104 only catalogues span/field/enum drift, the deferral is sound and this PR can ship as-is. If #1104 names changes to the Prometheus metric or label identifiers introduced by #1066 and re-used here, those should land before this PR is scraped by any observability backend, otherwise reconciliation later is an operator-visible event. Worth a one-line "what is in scope for #1104" inventory on the issue so the freeze-point on metric names is unambiguous.

Approach for the realignment PR itself (when it lands):

  • Treat metric renames as a deprecation cycle if any operator is already scraping: emit both old and new series for one release with the old marked deprecated in description, then drop. If no operators are scraping yet, hard-rename in a single PR is fine and far cleaner.
  • Keep the realignment PR purely textual where possible (renames + doc tweaks + test-string updates), with zero behavioral change, so it can be reviewed and reverted as a unit.

Summary

Approve direction; consider addressing the six concerns above before merge. The implementation cleanly separates classification (pure) from emission (side-effecting), keeps the consensus crate read-only, and threads the handoff budget at the right layer. The remaining items are docstring/help-text accuracy, a low-severity sentinel concern, and a design question on the Option<u64> plumbing — none of which block merge on their own. The #1104 deferral is defensible for span/field/enum drift, but I'd push to realign Prometheus metric and label names in-flight if #1104 touches them, since those become operator-visible the moment scraping starts.
• branch: feat/1067/boundary-instrumentation

@jnhsigmap

Copy link
Copy Markdown
Contributor Author

@claude, be1aefd addresses the following:

observe_state_transition doc comment clarified

  • The dual-emit is intentional as the state change and round change events are independent facts and only overlap in this particular case.

PROPOSER_QBFT_DECIDED_ROUND description is incomplete on the ChannelClosed outcome

  • This was the correct interpretation. Decided round and duration are not meaningful pieces of information when a still-initialized instance is terminated and these metrics may be polluted by recordings from this scenario (though, it is unlikely).
  • finish() now only records decided-round and duration histograms for consensus-terminal outcomes and as such gates on !=ChannelClosed.
  • outcome counter remains intact.

*u64::try_from(get_instance_height()).unwrap_or(u64::MAX) saturates to a misleading sentinel

  • Replaced dead u64::try_from().unwrap_or(u64::MAX) with lossless as u64 cast

Additionally for test coverage

  • compute_handoff_budget_ms() extracted as a testable pure helper function with unit tests. decide_abstract_block calls it directly.

Items acknowledged without action:

5× None handoff-budget propagation is noisy

  • Agreed, however is a design artefact, noted.

**OTLP span "built-in duration ≈ event moments, not lifetime" **

  • The span is built in ProposerObserver::start and lives for the whole instance until ProposerObserver::finish when a terminal outcome is recorded and then is dropped at function end. duration_ms is a field relied upon by metrics and log consumers.

Buffered-replay window during initialize() is unobserved

  • Agreed and accepted for now. Mentioned in PR body.

Part 2 — Recommendation in #1104: taxonomy drift from #919

Review again given this angle, also provide a critique of my assessment here.

@claude-code-actions-sigp

claude-code-actions-sigp Bot commented Jul 3, 2026

Copy link
Copy Markdown

Claude finished @jnhsigmap's task in 2m 0s —— View job


Re-review of be1aefd and critique of the taxonomy-deferral argument

  • Read be1aefd diff
  • Verify observe_state_transition doc comment
  • Verify finish() gates decided-round/duration on non-ChannelClosed
  • Verify as u64 cast
  • Verify compute_handoff_budget_ms() extraction + unit tests
  • Critique the taxonomy-drift/provisional-note argument

Verification of the four addressed items

Item Status Evidence
Doc comment for observe_state_transition clarifies dual-emit intent instrumentation.rs:195-199 — "Fires independently of round advances …"
finish() gates decided-round + duration on outcome != ChannelClosed; outcome counter unaffected instrumentation.rs:236-247 — counter unconditional; histograms guarded
Lossless as u64 cast replaces saturating try_from().unwrap_or(u64::MAX) instance.rs:335
compute_handoff_budget_ms() extracted with 4 unit tests validator_store/src/lib.rs:161-168, tests at :3408-3474 (0ms, 3000ms, 11999ms, pre-genesis→None)

Notes:

  • PROPOSER_QBFT_DECIDED_ROUND help text ("Round on which proposer QBFT decided or timed out") is now accurate — the histogram is no longer written on ChannelClosed, so the help matches the population.
  • finish() still writes decided_round and duration_ms as span fields and into the completion log event for ChannelClosed. That's per-instance context (log-side) and won't pollute Prometheus aggregates; semantically fine.
  • Pre-genesis test correctly exercises the Option::None path via slot_clock.millis_from_current_slot_start() returning None. The other three cover full-slot, mid-slot, and near-boundary. That's the right shape.
  • Correction on my earlier OTLP-span comment: you're right — a tracing::Span object's exported duration is bounded by its construction/drop, not by .in_scope entries. ProposerObserver holds the Span until finish() and drops with the observer at the end of qbft_instance, so exported span duration ≈ instance lifetime. Withdraw that concern; duration_ms as a field is redundant with span wall time, not compensating for it. Keeping it as a field is still fine (log-side consumers can read it without span-timing math).

One minor observation on the ChannelClosed gating (not blocking): finish() still takes decided_round as an argument in the RecvResult::Closed branch (instance.rs:442-445). The value is dead-for-metrics on that path but live-for-span-field. If you want to underline the semantic split, the observer could expose finish_terminal(outcome, decided_round) and finish_teardown() — but this is a design nit and the current shape is clear enough given the code comment.


Critique of your assessment on the #1104 deferral

Your framing is basically sound. Three sub-points where I'd sharpen it:

1. The "PR-body provisional note" is weaker than a HELP-string provisional note.
Operators consuming these metrics almost never read the PR body — they read # HELP lines exported alongside the metrics, which show up in Grafana metric descriptions, Prometheus UI, and any dashboard tooling. A PR-body note only reaches reviewers today; it won't reach the operator writing a dashboard next quarter. If you want the "provisional pending #1104" signal to survive contact with users, put it in the try_create_* help strings themselves, e.g.:

"Count of proposer QBFT round advances by reason (provisional; renaming tracked in #1104)"

That's a one-line change per affected metric, adds zero blast radius, and gets scraped into every downstream consumer for free. A PR-body-only note is invisible after the PR merges.

2. "It's going into unstable" is a weaker gate than it sounds.
unstablestable promotion is a discrete event, not a continuous filter. If #1104 hasn't landed by the time the next release cuts, the provisional names ship to stable and become operator-visible. The unstable argument is only load-bearing if there is an explicit release-gate checklist that blocks promotion until #1104 is merged. If no such gate exists, "landing in unstable" is functionally equivalent to "landing", just with a lag. Worth stating explicitly: what mechanism prevents the provisional names from riding a release train past the point of no return?

3. The counter-split (round_advance_total{reason}round_timeout_total{round}) is the highest-cost item to defer, not the lowest.
A counter rename with label re-parameterisation isn't just a name change — it changes the query semantics (label cardinality goes from ~4 reasons to ~12 rounds, aggregation formulas change, dashboards break, alert thresholds re-baseline). Splitting or reshaping a counter after operators have written queries against it is materially more expensive than renaming an unused span field. I agree that doing the split inside this PR would blur its objectives, but the deferral argument should acknowledge that this is the most expensive item to defer, not the least — and therefore deserves the strongest pre-merge signal (HELP-string note + explicit release-gate).

Where your argument holds up well:

Concrete recommendation:

That gives you the smallest merge here, the strongest possible "these names will change" signal on the surface operators actually see, and a durable gate that prevents the provisional names from silently graduating.


Overall: the four addressed items are correctly implemented, the OTLP-duration point should be withdrawn on my side, and the taxonomy-deferral argument is right in shape but under-signals on operator-visible surfaces — a HELP-string note and a release-gate would close that gap without expanding this PR's blast radius.
• branch: feat/1067/boundary-instrumentation

@jnhsigmap

jnhsigmap commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

fair enough, however, if #1104 is agreed upon then I disagree on the perceived severity of splitting the change between this PR and leaving renamed metrics and taxonomy changes to a future change.

If this is direction is approved and agreed upon, it would be a priority to ensure #1104 follows promptly and that the general nature of unstable is honoured here. We would never merge to stable an incomplete or fragile surface knowing that it is still volatile due to an ongoing initiative.

Marking automated review as 'done' pending team consensus on actual direction and whether my beliefs outlined in #1104 are valid and worth pursuing or whether we remain on the current course with taxonomy shape 👍

@jnhsigmap

Copy link
Copy Markdown
Contributor Author

regarding the run-local-testnet error: gh api repos/Zacholme7/ssv-network returns 404.

Zacholme7/ssv-network is not accessible, either deleted, renamed, or made private. The Kurtosis image build appears to hard-code a clone of it. This not being found is causing the run to fail.

Comment thread anchor/validator_store/src/lib.rs Outdated
Comment thread anchor/qbft_manager/src/instance.rs
Comment thread anchor/qbft_manager/src/instance.rs
Comment thread anchor/qbft_manager/src/tests/timeout_tests.rs
Comment thread anchor/qbft_manager/src/instance.rs Outdated
@jnhsigmap
jnhsigmap requested a review from shane-moore July 10, 2026 04:11

@shane-moore shane-moore left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM man! unstable branch is unfrozen now, so good to merge this whenver you feel good about it

@mergify

mergify Bot commented Jul 22, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@jnhsigmap

Copy link
Copy Markdown
Contributor Author

thanks @shane-moore, I'm not really happy with it in its current form. I think it needs more thought before we go ahead.

Once we're done sprinting on epbs I'll come back to this and pull it apart again with those new changes in mind. Going to put it back to Draft until then 👍

@jnhsigmap
jnhsigmap marked this pull request as draft July 24, 2026 05:57
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.

3 participants