Goal
Make signature_collector resolve a no-threshold collection (a still-pending collector evicted at cleanup) to a distinguishable error instead of the ambiguous QueueClosedError, so the PTC reconstruction-failure metric added in #1077 can isolate a clean observation_divergence rate instead of the coarse {reason="no_signature"} upper bound. Observability improvement, not a protocol requirement.
Context / motivation
Under the no-QBFT PTC design (#1077, milestone #6), a minority operator whose beacon-node observation never reaches threshold records a non-slashable, penalty-free silent miss. How often that happens is useful telemetry to watch (the question was raised in the SIP-94 thread), but it is not a normative SIP-94 §3 requirement, and nothing functional depends on it: #1077 ships a coarse {reason="no_signature"} counter that works today.
The blocker to a clean rate is in the collector: CollectionError::CollectionTimeout is dead code (never constructed; signature_collector/src/lib.rs:514), and sign_and_collect ends at Ok(result_rx.await?) (:294) with no timeout. So a minority/no-threshold operator blocks until the cleaner (:411-429, retains for_slot >= slot - SIGNATURE_COLLECTOR_RETAIN_SLOTS) evicts its collector, dropping the notifier -> RecvError -> QueueClosedError (:529-532) -- indistinguishable from a genuine channel close.
Blocked by: #1077 (the reconstruction-failure metric this would refine).
Suggested approach
Make eviction of a still-pending collector resolve its registered waiters to a distinguishable no-threshold error instead of QueueClosedError. Two shapes:
- (a) clean protocol fix: change the notifier oneshot to carry
Result<Arc<Signature>, CollectionError> and have the cleanup / State-drop path send Err(CollectionTimeout) (or a new NoThreshold variant) before dropping. Touches every collect_signature caller's success unwrap (Ok((*collector.await…)?).clone() -> handle the inner Result).
- (b) lower blast radius: at eviction, detect that the collector still had registered notifiers and signal those waiters distinctly without changing the success payload type (a dedicated close-reason).
Either way, a no-threshold expiry must reach collect_signature's caller as a variant distinct from a genuine queue close. Pick at implementation time.
Then (reclassification in #1077's sign path): split the sign_payload_attestation error arm so CollectionTimeout / NoThreshold -> {reason="observation_divergence"} and QueueClosedError -> {reason="queue_closed"} (genuine close). The interim {reason="no_signature"} bucket dissolves.
Acceptance criteria
- A test where shares never reach threshold (collector evicted) asserts the waiter receives the distinguishable no-threshold error, NOT
QueueClosedError.
- All existing
collect_signature callers compile and pass (option (a) touches the success unwrap site).
cargo test -p signature_collector -p validator_store.
Notes
Tagged research because the path forward is open (two shapes with different blast radius, and whether the rate is worth the collector churn at all, since it is not a requirement). Not part of milestone #6: #1077 ships the coarse {reason="no_signature"} proxy in the meantime. Issues are directionally correct, not prescriptive; verify symbols at PR time.
Goal
Make
signature_collectorresolve a no-threshold collection (a still-pending collector evicted at cleanup) to a distinguishable error instead of the ambiguousQueueClosedError, so the PTC reconstruction-failure metric added in #1077 can isolate a cleanobservation_divergencerate instead of the coarse{reason="no_signature"}upper bound. Observability improvement, not a protocol requirement.Context / motivation
Under the no-QBFT PTC design (#1077, milestone #6), a minority operator whose beacon-node observation never reaches threshold records a non-slashable, penalty-free silent miss. How often that happens is useful telemetry to watch (the question was raised in the SIP-94 thread), but it is not a normative SIP-94 §3 requirement, and nothing functional depends on it: #1077 ships a coarse
{reason="no_signature"}counter that works today.The blocker to a clean rate is in the collector:
CollectionError::CollectionTimeoutis dead code (never constructed;signature_collector/src/lib.rs:514), andsign_and_collectends atOk(result_rx.await?)(:294) with no timeout. So a minority/no-threshold operator blocks until thecleaner(:411-429, retainsfor_slot >= slot - SIGNATURE_COLLECTOR_RETAIN_SLOTS) evicts its collector, dropping the notifier ->RecvError->QueueClosedError(:529-532) -- indistinguishable from a genuine channel close.Blocked by: #1077 (the reconstruction-failure metric this would refine).
Suggested approach
Make eviction of a still-pending collector resolve its registered waiters to a distinguishable no-threshold error instead of
QueueClosedError. Two shapes:Result<Arc<Signature>, CollectionError>and have the cleanup / State-drop path sendErr(CollectionTimeout)(or a newNoThresholdvariant) before dropping. Touches everycollect_signaturecaller's success unwrap (Ok((*collector.await…)?).clone()-> handle the innerResult).Either way, a no-threshold expiry must reach
collect_signature's caller as a variant distinct from a genuine queue close. Pick at implementation time.Then (reclassification in #1077's sign path): split the
sign_payload_attestationerror arm soCollectionTimeout/NoThreshold->{reason="observation_divergence"}andQueueClosedError->{reason="queue_closed"}(genuine close). The interim{reason="no_signature"}bucket dissolves.Acceptance criteria
QueueClosedError.collect_signaturecallers compile and pass (option (a) touches the success unwrap site).cargo test -p signature_collector -p validator_store.Notes
Tagged
researchbecause the path forward is open (two shapes with different blast radius, and whether the rate is worth the collector churn at all, since it is not a requirement). Not part of milestone #6: #1077 ships the coarse{reason="no_signature"}proxy in the meantime. Issues are directionally correct, not prescriptive; verify symbols at PR time.