Skip to content

Commit b132c83

Browse files
committed
feature(collator): add validator events collector
1 parent 6c150f0 commit b132c83

10 files changed

Lines changed: 725 additions & 16 deletions

File tree

cli/src/node/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ impl Node {
181181
},
182182
base.keypair.clone(),
183183
self.validator_config,
184+
Arc::new(vec![]),
184185
);
185186

186187
// Explicitly handle the initial state
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
use std::collections::{BTreeMap, HashMap};
2+
use std::ops::RangeInclusive;
3+
use std::sync::Arc;
4+
5+
use anyhow::Result;
6+
use arc_swap::ArcSwap;
7+
use tracing::instrument;
8+
use tycho_network::PeerId;
9+
use tycho_types::models::{BlockId, BlockIdShort};
10+
use tycho_util::metrics::HistogramGuard;
11+
use tycho_util::{DashMapEntry, FastDashMap};
12+
13+
use crate::tracing_targets;
14+
use crate::validator::ValidationSessionId;
15+
use crate::validator::event::EventError::{SessionAlreadyExists, SessionNotFound};
16+
use crate::validator::event::{SessionCtx, SigStatus, SignatureEvent, ValidationEvents};
17+
18+
type PeerMap = HashMap<PeerId, SigStatus>;
19+
20+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21+
pub struct PeerStat {
22+
pub valid: u32,
23+
pub invalid: u32,
24+
}
25+
26+
pub struct ValidationEventCollector {
27+
committed: ArcSwap<BTreeMap<BlockIdShort, Arc<PeerMap>>>,
28+
pending: FastDashMap<ValidationSessionId, FastDashMap<BlockId, FastDashMap<PeerId, SigStatus>>>,
29+
}
30+
31+
impl Default for ValidationEventCollector {
32+
fn default() -> Self {
33+
Self {
34+
committed: ArcSwap::from_pointee(BTreeMap::new()),
35+
pending: FastDashMap::default(),
36+
}
37+
}
38+
}
39+
40+
// Public API for the collector
41+
42+
impl ValidationEventCollector {
43+
/// peer -> (valid, invalid) statistics for blocks in the given range.
44+
pub fn stats_for_blocks(
45+
&self,
46+
range: RangeInclusive<BlockIdShort>,
47+
) -> HashMap<PeerId, PeerStat> {
48+
let _histogram =
49+
HistogramGuard::begin("tycho_validator_collector_get_stats_for_blocks_time");
50+
let mut out = HashMap::<PeerId, PeerStat>::new();
51+
let snap = self.committed.load();
52+
53+
for (_, peers) in snap.range(range) {
54+
for (&peer, &status) in peers.iter() {
55+
let entry = out.entry(peer).or_default();
56+
match status {
57+
SigStatus::Valid => entry.valid += 1,
58+
SigStatus::Invalid => entry.invalid += 1,
59+
}
60+
}
61+
}
62+
out
63+
}
64+
65+
/// Remove all blocks in the given range from the committed map.
66+
#[instrument(skip(self), fields(?range))]
67+
pub fn truncate_range(&self, range: RangeInclusive<BlockIdShort>) {
68+
let _histogram = HistogramGuard::begin("tycho_validator_collector_truncate_range_time");
69+
tracing::debug!(target: tracing_targets::VALIDATOR, "truncate_range");
70+
self.committed.rcu(|cur| {
71+
let mut new = (**cur).clone();
72+
new.retain(|&blk, _| !range.contains(&blk));
73+
Arc::new(new)
74+
});
75+
}
76+
}
77+
78+
// Implementation of the `ValidationEvents` trait for the collector
79+
80+
impl ValidationEvents for ValidationEventCollector {
81+
#[instrument(skip(self), fields(?ctx))]
82+
fn on_session_open(&self, ctx: &SessionCtx) -> Result<()> {
83+
tracing::debug!(target: tracing_targets::VALIDATOR, "on_session_open");
84+
match self.pending.entry(ctx.session_id) {
85+
DashMapEntry::Occupied(_) => Err(SessionAlreadyExists(ctx.session_id).into()),
86+
DashMapEntry::Vacant(v) => {
87+
v.insert(FastDashMap::default());
88+
Ok(())
89+
}
90+
}
91+
}
92+
93+
#[instrument(skip(self), fields(?ctx))]
94+
fn on_session_drop(&self, ctx: &SessionCtx) -> Result<()> {
95+
tracing::debug!(target: tracing_targets::VALIDATOR, "on_session_drop");
96+
if self.pending.remove(&ctx.session_id).is_some() {
97+
Ok(())
98+
} else {
99+
Err(SessionNotFound(ctx.session_id).into())
100+
}
101+
}
102+
103+
#[instrument(skip(self), fields(?ev))]
104+
fn on_signature_event(&self, ev: &SignatureEvent) -> Result<()> {
105+
let _histogram = HistogramGuard::begin("tycho_validator_collector_on_signature_event_time");
106+
tracing::debug!(target: tracing_targets::VALIDATOR, "on_signature_event");
107+
let Some(session) = self.pending.get(&ev.ctx.session_id) else {
108+
tracing::warn!(
109+
target: tracing_targets::VALIDATOR,
110+
"session not found, ignoring signature event"
111+
);
112+
return Ok(());
113+
};
114+
115+
let bucket = session
116+
.entry(ev.block_id)
117+
.or_insert_with(FastDashMap::default);
118+
119+
match bucket.entry(ev.peer_id) {
120+
DashMapEntry::Vacant(e) => {
121+
e.insert(ev.status);
122+
}
123+
DashMapEntry::Occupied(mut e) => {
124+
if *e.get() == SigStatus::Invalid && ev.status == SigStatus::Valid {
125+
e.insert(SigStatus::Valid);
126+
}
127+
}
128+
}
129+
Ok(())
130+
}
131+
132+
#[instrument(skip(self), fields(?ctx, ?block_id))]
133+
fn on_validation_skipped(&self, ctx: &SessionCtx, block_id: &BlockId) -> Result<()> {
134+
tracing::debug!(target: tracing_targets::VALIDATOR, "on_validation_skipped");
135+
if let Some(session) = self.pending.get(&ctx.session_id) {
136+
session.remove(block_id);
137+
} else {
138+
tracing::warn!(
139+
target: tracing_targets::VALIDATOR,
140+
"session not found, skipping validation_skipped event"
141+
);
142+
}
143+
Ok(())
144+
}
145+
146+
#[instrument(skip(self), fields(?ctx, ?block_id))]
147+
fn on_validation_complete(&self, ctx: &SessionCtx, block_id: &BlockId) -> Result<()> {
148+
let _histogram =
149+
HistogramGuard::begin("tycho_validator_collector_on_validation_complete_time");
150+
151+
tracing::debug!(target: tracing_targets::VALIDATOR, "on_validation_complete");
152+
let Some(session) = self.pending.get(&ctx.session_id) else {
153+
tracing::warn!(
154+
target: tracing_targets::VALIDATOR,
155+
"session not found, ignoring validation_complete event"
156+
);
157+
return Ok(());
158+
};
159+
160+
let Some((_k, bucket)) = session.remove(block_id) else {
161+
tracing::debug!(target: tracing_targets::VALIDATOR, "no signatures for block, skipping");
162+
return Ok(());
163+
};
164+
165+
let peer_map: PeerMap = bucket.iter().map(|e| (*e.key(), *e.value())).collect();
166+
let arc_pm = Arc::new(peer_map);
167+
168+
self.committed.rcu(|cur| {
169+
let mut new = (**cur).clone();
170+
let block_id_short = block_id.as_short_id();
171+
if new.insert(block_id_short, arc_pm.clone()).is_some() {
172+
tracing::error!(target: tracing_targets::VALIDATOR,
173+
%block_id_short, "block already present in committed");
174+
}
175+
Arc::new(new)
176+
});
177+
178+
// TODO: -- DEBUG LOGIC
179+
// push stats to a metrics collector
180+
// clean stats every 100 blocks
181+
182+
if block_id.seqno % 50 == 0 {
183+
let zerostate_short = BlockIdShort::from((block_id.shard, 0));
184+
let end = block_id.seqno.saturating_sub(50);
185+
let end_short = BlockIdShort::from((block_id.shard, end));
186+
self.truncate_range(zerostate_short..=end_short);
187+
}
188+
189+
let end_block_id = BlockIdShort::from((block_id.shard, block_id.seqno + 1));
190+
let stats = self.stats_for_blocks(BlockIdShort::from((block_id.shard, 0))..=end_block_id);
191+
192+
let valid_sigs = stats.iter().filter(|(_, stat)| stat.valid > 0).count();
193+
let invalid_sigs = stats.iter().filter(|(_, stat)| stat.invalid > 0).count();
194+
let labels: [(&str, String); 1] = [("workchain", block_id.shard.workchain().to_string())];
195+
196+
metrics::gauge!("tycho_validator_collector_valid_sigs_total_count", &labels)
197+
.set(valid_sigs as f64);
198+
199+
metrics::gauge!(
200+
"tycho_validator_collector_invalid_sigs_total_count",
201+
&labels
202+
)
203+
.set(invalid_sigs as f64);
204+
205+
// TODO: -- DEBUG LOGIC END
206+
207+
Ok(())
208+
}
209+
}
210+
211+
// ---------- tests ----------
212+
213+
#[cfg(test)]
214+
mod tests {
215+
use tycho_types::models::ShardIdent;
216+
217+
use super::*;
218+
use crate::validator::event::SigStatus::{Invalid, Valid};
219+
220+
const S: ValidationSessionId = (1, 1);
221+
222+
fn ctx() -> SessionCtx {
223+
SessionCtx { session_id: S }
224+
}
225+
226+
#[test]
227+
228+
fn basic_flow() {
229+
let c = ValidationEventCollector::default();
230+
// open
231+
c.on_session_open(&ctx()).unwrap();
232+
233+
let peer1 = PeerId([1u8; 32]);
234+
let peer2 = PeerId([2u8; 32]);
235+
236+
let zerostate_id = BlockId {
237+
shard: ShardIdent::default(),
238+
seqno: 0,
239+
root_hash: Default::default(),
240+
file_hash: Default::default(),
241+
};
242+
243+
let block_id = BlockId {
244+
shard: Default::default(),
245+
seqno: 10,
246+
root_hash: Default::default(),
247+
file_hash: Default::default(),
248+
};
249+
// signatures (block 10, peers A/B)
250+
let ev_a = SignatureEvent {
251+
ctx: ctx(),
252+
block_id,
253+
peer_id: peer1,
254+
status: Valid,
255+
};
256+
let ev_b = SignatureEvent {
257+
ctx: ctx(),
258+
block_id,
259+
peer_id: peer2,
260+
status: Invalid,
261+
};
262+
c.on_signature_event(&ev_a).unwrap();
263+
c.on_signature_event(&ev_b).unwrap();
264+
265+
// complete block
266+
c.on_validation_complete(&ctx(), &block_id).unwrap();
267+
268+
// stats over exact block
269+
let stats = c.stats_for_blocks(block_id.as_short_id()..=block_id.as_short_id());
270+
assert_eq!(stats.len(), 2);
271+
assert_eq!(stats[&peer1].valid, 1);
272+
assert_eq!(stats[&peer2].invalid, 1);
273+
274+
// truncate that block
275+
c.truncate_range(zerostate_id.as_short_id()..=block_id.as_short_id());
276+
let stats = c.stats_for_blocks(
277+
BlockIdShort::from((block_id.shard, 0))..=BlockIdShort::from((block_id.shard, 20)),
278+
);
279+
assert!(stats.is_empty());
280+
}
281+
282+
#[test]
283+
fn skip_validation_drops_bucket() {
284+
let peer1 = PeerId([1u8; 32]);
285+
286+
let block_id = BlockId {
287+
shard: Default::default(),
288+
seqno: 10,
289+
root_hash: Default::default(),
290+
file_hash: Default::default(),
291+
};
292+
293+
let c = ValidationEventCollector::default();
294+
c.on_session_open(&ctx()).unwrap();
295+
let ev = SignatureEvent {
296+
ctx: ctx(),
297+
block_id,
298+
peer_id: peer1,
299+
status: Valid,
300+
};
301+
c.on_signature_event(&ev).unwrap();
302+
// skip -> bucket removed, no stats
303+
c.on_validation_skipped(&ctx(), &block_id).unwrap();
304+
c.on_validation_complete(&ctx(), &block_id).unwrap(); // nothing happens
305+
assert!(
306+
c.stats_for_blocks(
307+
BlockIdShort::from((block_id.shard, 0))..=BlockIdShort::from((block_id.shard, 10))
308+
)
309+
.is_empty()
310+
);
311+
}
312+
313+
#[test]
314+
315+
fn duplicate_session_errors() {
316+
let c = ValidationEventCollector::default();
317+
assert!(c.on_session_open(&ctx()).is_ok());
318+
assert!(c.on_session_open(&ctx()).is_err());
319+
assert!(c.on_session_drop(&ctx()).is_ok());
320+
assert!(c.on_session_drop(&ctx()).is_err());
321+
}
322+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use std::sync::Arc;
2+
3+
use anyhow::Result;
4+
use tycho_types::models::BlockId;
5+
6+
use crate::validator::event::{SessionCtx, SignatureEvent, ValidationEvents};
7+
8+
impl<T> ValidationEvents for Arc<T>
9+
where
10+
T: ValidationEvents,
11+
{
12+
fn on_session_open(&self, ctx: &SessionCtx) -> Result<()> {
13+
(**self).on_session_open(ctx)
14+
}
15+
fn on_session_drop(&self, ctx: &SessionCtx) -> Result<()> {
16+
(**self).on_session_drop(ctx)
17+
}
18+
fn on_signature_event(&self, ev: &SignatureEvent) -> Result<()> {
19+
(**self).on_signature_event(ev)
20+
}
21+
fn on_validation_skipped(&self, ctx: &SessionCtx, block_id_short: &BlockId) -> Result<()> {
22+
(**self).on_validation_skipped(ctx, block_id_short)
23+
}
24+
fn on_validation_complete(&self, ctx: &SessionCtx, block_id_short: &BlockId) -> Result<()> {
25+
(**self).on_validation_complete(ctx, block_id_short)
26+
}
27+
}
28+
29+
impl ValidationEvents for Vec<Arc<dyn ValidationEvents>> {
30+
fn on_session_open(&self, ctx: &SessionCtx) -> Result<()> {
31+
propagate(self, |s| s.on_session_open(ctx))
32+
}
33+
fn on_session_drop(&self, ctx: &SessionCtx) -> Result<()> {
34+
propagate(self, |s| s.on_session_drop(ctx))
35+
}
36+
fn on_signature_event(&self, ev: &SignatureEvent) -> Result<()> {
37+
propagate(self, |s| s.on_signature_event(ev))
38+
}
39+
fn on_validation_skipped(&self, ctx: &SessionCtx, block_id_short: &BlockId) -> Result<()> {
40+
propagate(self, |s| s.on_validation_skipped(ctx, block_id_short))
41+
}
42+
fn on_validation_complete(&self, ctx: &SessionCtx, block_id_short: &BlockId) -> Result<()> {
43+
propagate(self, |s| s.on_validation_complete(ctx, block_id_short))
44+
}
45+
}
46+
47+
/// helper: call all sinks, return the first error (if any)
48+
fn propagate<F>(sinks: &[Arc<dyn ValidationEvents>], mut f: F) -> Result<()>
49+
where
50+
F: FnMut(&Arc<dyn ValidationEvents>) -> Result<()>,
51+
{
52+
let mut first_err: Option<anyhow::Error> = None;
53+
for s in sinks {
54+
if let Err(e) = f(s) {
55+
if first_err.is_none() {
56+
first_err = Some(e);
57+
}
58+
}
59+
}
60+
if let Some(e) = first_err {
61+
Err(e)
62+
} else {
63+
Ok(())
64+
}
65+
}

0 commit comments

Comments
 (0)