|
| 1 | +//! Handoff store for SIP-94 §6 envelope disseminations. |
| 2 | +//! |
| 3 | +//! The message receiver writes the first validator-accepted `EnvelopeDissemination` per |
| 4 | +//! `(validator, slot)`; the envelope duty runner awaits it. Message validation's first-valid |
| 5 | +//! rule delivers at most one dissemination per key for the process lifetime, so the store is |
| 6 | +//! first-write-wins and never replaces an entry. |
| 7 | +
|
| 8 | +use std::collections::HashMap; |
| 9 | + |
| 10 | +use bls::PublicKeyBytes; |
| 11 | +use parking_lot::Mutex; |
| 12 | +use ssv_types::dissemination::EnvelopeDissemination; |
| 13 | +use tokio::{sync::oneshot, time::Instant}; |
| 14 | +use types::Slot; |
| 15 | + |
| 16 | +/// Number of slots an entry stays readable, mirroring the decided-block context retention. |
| 17 | +const MAX_DISSEMINATION_AGE_SLOTS: u64 = 4; |
| 18 | + |
| 19 | +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] |
| 20 | +struct Key { |
| 21 | + validator: PublicKeyBytes, |
| 22 | + slot: Slot, |
| 23 | +} |
| 24 | + |
| 25 | +enum Entry { |
| 26 | + /// The accepted dissemination for the key. |
| 27 | + Ready(EnvelopeDissemination), |
| 28 | + /// Runners awaiting the dissemination. Senders are pruned when closed, so timed-out or |
| 29 | + /// cancelled waiters do not accumulate. |
| 30 | + Waiting(Vec<oneshot::Sender<EnvelopeDissemination>>), |
| 31 | +} |
| 32 | + |
| 33 | +/// Shared store connecting the message receiver (writer) to the envelope duty runner (reader). |
| 34 | +#[derive(Default)] |
| 35 | +pub struct DisseminationStore { |
| 36 | + inner: Mutex<HashMap<Key, Entry>>, |
| 37 | +} |
| 38 | + |
| 39 | +impl DisseminationStore { |
| 40 | + pub fn new() -> Self { |
| 41 | + Self::default() |
| 42 | + } |
| 43 | + |
| 44 | + /// Records the accepted dissemination for `(validator, slot)` and wakes every waiter. |
| 45 | + /// |
| 46 | + /// First-write-wins: a `Ready` entry is never replaced. Entries older than |
| 47 | + /// `MAX_DISSEMINATION_AGE_SLOTS` relative to the inserted slot are dropped on insert |
| 48 | + /// (addition on the stored side, so an early slot cannot underflow). |
| 49 | + pub fn insert(&self, validator: PublicKeyBytes, dissemination: EnvelopeDissemination) { |
| 50 | + let slot = dissemination.slot; |
| 51 | + let key = Key { validator, slot }; |
| 52 | + let mut inner = self.inner.lock(); |
| 53 | + Self::sweep(&mut inner, slot); |
| 54 | + |
| 55 | + match inner.entry(key) { |
| 56 | + std::collections::hash_map::Entry::Vacant(entry) => { |
| 57 | + entry.insert(Entry::Ready(dissemination)); |
| 58 | + } |
| 59 | + std::collections::hash_map::Entry::Occupied(mut entry) => match entry.get_mut() { |
| 60 | + Entry::Ready(_) => {} |
| 61 | + Entry::Waiting(waiters) => { |
| 62 | + for waiter in waiters.drain(..) { |
| 63 | + // A dropped receiver (timed-out waiter) is fine; the value stays Ready. |
| 64 | + let _ = waiter.send(dissemination.clone()); |
| 65 | + } |
| 66 | + entry.insert(Entry::Ready(dissemination)); |
| 67 | + } |
| 68 | + }, |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + /// Awaits the dissemination for `(validator, slot)` until `deadline`. |
| 73 | + /// |
| 74 | + /// Returns immediately when the entry is already `Ready`. A timed-out or cancelled wait |
| 75 | + /// leaves a `Ready` value available for a later call. Waiter registrations sweep stale |
| 76 | + /// keys and prune closed senders, so unanswered waits stay bounded even when no |
| 77 | + /// dissemination ever arrives. |
| 78 | + pub async fn wait( |
| 79 | + &self, |
| 80 | + validator: PublicKeyBytes, |
| 81 | + slot: Slot, |
| 82 | + deadline: Instant, |
| 83 | + ) -> Option<EnvelopeDissemination> { |
| 84 | + let receiver = { |
| 85 | + let key = Key { validator, slot }; |
| 86 | + let mut inner = self.inner.lock(); |
| 87 | + Self::sweep(&mut inner, slot); |
| 88 | + for entry in inner.values_mut() { |
| 89 | + if let Entry::Waiting(waiters) = entry { |
| 90 | + waiters.retain(|waiter| !waiter.is_closed()); |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + match inner |
| 95 | + .entry(key) |
| 96 | + .or_insert_with(|| Entry::Waiting(Vec::new())) |
| 97 | + { |
| 98 | + Entry::Ready(dissemination) => return Some(dissemination.clone()), |
| 99 | + Entry::Waiting(waiters) => { |
| 100 | + let (sender, receiver) = oneshot::channel(); |
| 101 | + waiters.push(sender); |
| 102 | + receiver |
| 103 | + } |
| 104 | + } |
| 105 | + }; |
| 106 | + |
| 107 | + tokio::time::timeout_at(deadline, receiver).await.ok()?.ok() |
| 108 | + } |
| 109 | + |
| 110 | + /// Drops entries older than `MAX_DISSEMINATION_AGE_SLOTS` relative to `slot`. A call for |
| 111 | + /// an old slot cannot evict a newer entry. |
| 112 | + fn sweep(inner: &mut HashMap<Key, Entry>, slot: Slot) { |
| 113 | + inner.retain(|stored_key, _| stored_key.slot + MAX_DISSEMINATION_AGE_SLOTS >= slot); |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +#[cfg(test)] |
| 118 | +mod tests { |
| 119 | + use std::{sync::Arc, time::Duration}; |
| 120 | + |
| 121 | + use ssv_types::VariableList; |
| 122 | + |
| 123 | + use super::*; |
| 124 | + |
| 125 | + fn dissemination(slot: u64) -> EnvelopeDissemination { |
| 126 | + EnvelopeDissemination { |
| 127 | + slot: Slot::new(slot), |
| 128 | + envelope: VariableList::new(vec![0xAA; 8]).unwrap(), |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + fn pubkey(byte: u8) -> PublicKeyBytes { |
| 133 | + PublicKeyBytes::deserialize(&[byte; 48]).expect("48 bytes is a valid pubkey length") |
| 134 | + } |
| 135 | + |
| 136 | + fn soon() -> Instant { |
| 137 | + Instant::now() + Duration::from_millis(200) |
| 138 | + } |
| 139 | + |
| 140 | + #[tokio::test] |
| 141 | + async fn ready_before_wait_returns_immediately() { |
| 142 | + let store = DisseminationStore::new(); |
| 143 | + store.insert(pubkey(1), dissemination(5)); |
| 144 | + |
| 145 | + let got = store.wait(pubkey(1), Slot::new(5), soon()).await; |
| 146 | + assert_eq!(got, Some(dissemination(5))); |
| 147 | + } |
| 148 | + |
| 149 | + #[tokio::test] |
| 150 | + async fn wait_before_ready_wakes_all_same_key_waiters() { |
| 151 | + let store = Arc::new(DisseminationStore::new()); |
| 152 | + let deadline = Instant::now() + Duration::from_secs(5); |
| 153 | + let w1 = tokio::spawn({ |
| 154 | + let store = store.clone(); |
| 155 | + async move { store.wait(pubkey(1), Slot::new(5), deadline).await } |
| 156 | + }); |
| 157 | + let w2 = tokio::spawn({ |
| 158 | + let store = store.clone(); |
| 159 | + async move { store.wait(pubkey(1), Slot::new(5), deadline).await } |
| 160 | + }); |
| 161 | + // Let both waiters register before the insert. |
| 162 | + tokio::time::sleep(Duration::from_millis(50)).await; |
| 163 | + |
| 164 | + store.insert(pubkey(1), dissemination(5)); |
| 165 | + |
| 166 | + assert_eq!(w1.await.unwrap(), Some(dissemination(5))); |
| 167 | + assert_eq!(w2.await.unwrap(), Some(dissemination(5))); |
| 168 | + } |
| 169 | + |
| 170 | + #[tokio::test] |
| 171 | + async fn timed_out_wait_can_retry_against_ready() { |
| 172 | + let store = DisseminationStore::new(); |
| 173 | + |
| 174 | + let got = store.wait(pubkey(1), Slot::new(5), soon()).await; |
| 175 | + assert_eq!(got, None, "no insert: the wait must time out"); |
| 176 | + |
| 177 | + store.insert(pubkey(1), dissemination(5)); |
| 178 | + let got = store.wait(pubkey(1), Slot::new(5), soon()).await; |
| 179 | + assert_eq!( |
| 180 | + got, |
| 181 | + Some(dissemination(5)), |
| 182 | + "the value must stay available after an earlier timed-out wait" |
| 183 | + ); |
| 184 | + } |
| 185 | + |
| 186 | + #[tokio::test] |
| 187 | + async fn first_write_wins() { |
| 188 | + let store = DisseminationStore::new(); |
| 189 | + let first = dissemination(5); |
| 190 | + let mut second = dissemination(5); |
| 191 | + second.envelope = VariableList::new(vec![0xBB; 8]).unwrap(); |
| 192 | + |
| 193 | + store.insert(pubkey(1), first.clone()); |
| 194 | + store.insert(pubkey(1), second); |
| 195 | + |
| 196 | + let got = store.wait(pubkey(1), Slot::new(5), soon()).await; |
| 197 | + assert_eq!(got, Some(first), "a Ready entry must never be replaced"); |
| 198 | + } |
| 199 | + |
| 200 | + #[tokio::test] |
| 201 | + async fn keys_are_isolated() { |
| 202 | + let store = DisseminationStore::new(); |
| 203 | + store.insert(pubkey(1), dissemination(5)); |
| 204 | + |
| 205 | + assert_eq!(store.wait(pubkey(2), Slot::new(5), soon()).await, None); |
| 206 | + assert_eq!(store.wait(pubkey(1), Slot::new(6), soon()).await, None); |
| 207 | + } |
| 208 | + |
| 209 | + #[tokio::test] |
| 210 | + async fn insert_evicts_entries_past_the_age_window() { |
| 211 | + let store = DisseminationStore::new(); |
| 212 | + store.insert(pubkey(1), dissemination(5)); |
| 213 | + store.insert( |
| 214 | + pubkey(1), |
| 215 | + dissemination(5 + MAX_DISSEMINATION_AGE_SLOTS + 1), |
| 216 | + ); |
| 217 | + |
| 218 | + assert_eq!( |
| 219 | + store.wait(pubkey(1), Slot::new(5), soon()).await, |
| 220 | + None, |
| 221 | + "an insert past the age window must evict the older entry" |
| 222 | + ); |
| 223 | + } |
| 224 | + |
| 225 | + #[tokio::test] |
| 226 | + async fn unanswered_waits_stay_bounded() { |
| 227 | + let store = DisseminationStore::new(); |
| 228 | + // Many timed-out waits across distinct slots, no inserts at all. |
| 229 | + for slot in 0..100u64 { |
| 230 | + let _ = store.wait(pubkey(1), Slot::new(slot), Instant::now()).await; |
| 231 | + } |
| 232 | + |
| 233 | + let inner = store.inner.lock(); |
| 234 | + assert!( |
| 235 | + inner.len() as u64 <= MAX_DISSEMINATION_AGE_SLOTS + 1, |
| 236 | + "wait-created entries must be swept; got {} keys", |
| 237 | + inner.len() |
| 238 | + ); |
| 239 | + let waiters: usize = inner |
| 240 | + .values() |
| 241 | + .map(|entry| match entry { |
| 242 | + Entry::Ready(_) => 0, |
| 243 | + Entry::Waiting(waiters) => waiters.len(), |
| 244 | + }) |
| 245 | + .sum(); |
| 246 | + // The final wait's own sender has no later registration to prune it; everything |
| 247 | + // older must be gone. |
| 248 | + assert!( |
| 249 | + waiters <= 1, |
| 250 | + "closed senders must be pruned on later registrations; got {waiters}" |
| 251 | + ); |
| 252 | + } |
| 253 | +} |
0 commit comments