|
| 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 | +} |
0 commit comments