Skip to main content

ipfrs_network/
sync_coordinator.rs

1//! Peer sync coordinator for bidirectional synchronization sessions.
2//!
3//! Coordinates bidirectional synchronization sessions between peers, tracking
4//! what each peer has, what's needed, and managing sync state machine transitions.
5
6use std::collections::HashMap;
7
8/// Phase of a sync session's state machine.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum SyncPhase {
11    /// Initial capability exchange.
12    Handshake,
13    /// Exchanging have/want lists.
14    Discovery,
15    /// Actively transferring blocks.
16    Transfer,
17    /// Checksumming received blocks.
18    Verification,
19    /// Sync finished successfully.
20    Complete,
21    /// Terminal failure state.
22    Failed { reason: String },
23}
24
25/// Direction of data flow in a sync session.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum SyncDirection {
28    /// We send to peer.
29    Push,
30    /// We receive from peer.
31    Pull,
32    /// Bidirectional transfer.
33    Bidirectional,
34}
35
36/// A single synchronization session with a remote peer.
37#[derive(Clone, Debug)]
38pub struct SyncSession {
39    /// Unique session identifier.
40    pub session_id: u64,
41    /// Remote peer identifier.
42    pub peer_id: String,
43    /// Direction of data flow.
44    pub direction: SyncDirection,
45    /// Current phase of the state machine.
46    pub phase: SyncPhase,
47    /// CIDs we have confirmed locally.
48    pub have_cids: Vec<String>,
49    /// CIDs we still need from the remote peer.
50    pub want_cids: Vec<String>,
51    /// Total bytes transferred in this session.
52    pub transferred_bytes: u64,
53    /// Tick at which this session was started.
54    pub started_at_tick: u64,
55}
56
57impl SyncSession {
58    /// Returns `true` if the session has reached a terminal state (`Complete` or `Failed`).
59    pub fn is_terminal(&self) -> bool {
60        matches!(self.phase, SyncPhase::Complete | SyncPhase::Failed { .. })
61    }
62
63    /// Returns the number of CIDs still pending (in `want_cids`).
64    pub fn pending_count(&self) -> usize {
65        self.want_cids.len()
66    }
67}
68
69/// Aggregate statistics across all sessions managed by a [`PeerSyncCoordinator`].
70#[derive(Clone, Debug, Default)]
71pub struct SyncStats {
72    /// Total number of sessions ever created.
73    pub total_sessions: usize,
74    /// Number of sessions currently in a non-terminal state.
75    pub active_sessions: usize,
76    /// Number of sessions that reached `Complete`.
77    pub completed_sessions: usize,
78    /// Number of sessions that reached `Failed`.
79    pub failed_sessions: usize,
80    /// Sum of `transferred_bytes` across all sessions.
81    pub total_transferred_bytes: u64,
82}
83
84/// Coordinates bidirectional sync sessions between the local node and its peers.
85#[derive(Debug, Default)]
86pub struct PeerSyncCoordinator {
87    /// All sessions keyed by `session_id`.
88    pub sessions: HashMap<u64, SyncSession>,
89    /// Counter used to assign the next session identifier.
90    pub next_session_id: u64,
91}
92
93impl PeerSyncCoordinator {
94    /// Creates a new, empty coordinator.
95    pub fn new() -> Self {
96        Self {
97            sessions: HashMap::new(),
98            next_session_id: 0,
99        }
100    }
101
102    /// Starts a new sync session with `peer_id` in the `Handshake` phase.
103    ///
104    /// Returns the newly assigned `session_id`.
105    pub fn start_session(&mut self, peer_id: &str, direction: SyncDirection, tick: u64) -> u64 {
106        let session_id = self.next_session_id;
107        self.next_session_id += 1;
108
109        let session = SyncSession {
110            session_id,
111            peer_id: peer_id.to_owned(),
112            direction,
113            phase: SyncPhase::Handshake,
114            have_cids: Vec::new(),
115            want_cids: Vec::new(),
116            transferred_bytes: 0,
117            started_at_tick: tick,
118        };
119
120        self.sessions.insert(session_id, session);
121        session_id
122    }
123
124    /// Advances `session_id` to `next_phase`.
125    ///
126    /// Returns `false` when:
127    /// - the session does not exist, or
128    /// - the session is already in a terminal state.
129    pub fn advance_phase(&mut self, session_id: u64, next_phase: SyncPhase) -> bool {
130        match self.sessions.get_mut(&session_id) {
131            Some(session) if !session.is_terminal() => {
132                session.phase = next_phase;
133                true
134            }
135            _ => false,
136        }
137    }
138
139    /// Appends `cid` to the `want_cids` list of `session_id`.
140    ///
141    /// Returns `false` when the session does not exist or is terminal.
142    pub fn add_want(&mut self, session_id: u64, cid: &str) -> bool {
143        match self.sessions.get_mut(&session_id) {
144            Some(session) if !session.is_terminal() => {
145                session.want_cids.push(cid.to_owned());
146                true
147            }
148            _ => false,
149        }
150    }
151
152    /// Marks `cid` as received: removes it from `want_cids`, adds it to `have_cids`,
153    /// and increments `transferred_bytes` by `bytes`.
154    ///
155    /// Returns `false` if the session does not exist (even if already terminal — callers
156    /// may still want to record bytes for sessions that just completed).
157    pub fn mark_received(&mut self, session_id: u64, cid: &str, bytes: u64) -> bool {
158        match self.sessions.get_mut(&session_id) {
159            Some(session) => {
160                session.want_cids.retain(|c| c != cid);
161                if !session.have_cids.contains(&cid.to_owned()) {
162                    session.have_cids.push(cid.to_owned());
163                }
164                session.transferred_bytes += bytes;
165                true
166            }
167            None => false,
168        }
169    }
170
171    /// Transitions `session_id` to `Failed { reason }`.
172    ///
173    /// Returns `false` if the session does not exist.
174    pub fn fail_session(&mut self, session_id: u64, reason: String) -> bool {
175        match self.sessions.get_mut(&session_id) {
176            Some(session) => {
177                session.phase = SyncPhase::Failed { reason };
178                true
179            }
180            None => false,
181        }
182    }
183
184    /// Transitions `session_id` to `Complete`.
185    ///
186    /// Returns `false` if the session does not exist.
187    pub fn complete_session(&mut self, session_id: u64) -> bool {
188        match self.sessions.get_mut(&session_id) {
189            Some(session) => {
190                session.phase = SyncPhase::Complete;
191                true
192            }
193            None => false,
194        }
195    }
196
197    /// Returns references to all non-terminal sessions, sorted ascending by `session_id`.
198    pub fn active_sessions(&self) -> Vec<&SyncSession> {
199        let mut active: Vec<&SyncSession> = self
200            .sessions
201            .values()
202            .filter(|s| !s.is_terminal())
203            .collect();
204        active.sort_by_key(|s| s.session_id);
205        active
206    }
207
208    /// Returns a reference to the session with `session_id`, or `None` if not found.
209    pub fn session(&self, session_id: u64) -> Option<&SyncSession> {
210        self.sessions.get(&session_id)
211    }
212
213    /// Computes aggregate statistics over all sessions.
214    pub fn stats(&self) -> SyncStats {
215        let total_sessions = self.sessions.len();
216        let mut active_sessions: usize = 0;
217        let mut completed_sessions: usize = 0;
218        let mut failed_sessions: usize = 0;
219        let mut total_transferred_bytes: u64 = 0;
220
221        for session in self.sessions.values() {
222            total_transferred_bytes += session.transferred_bytes;
223
224            match &session.phase {
225                SyncPhase::Complete => completed_sessions += 1,
226                SyncPhase::Failed { .. } => failed_sessions += 1,
227                _ => active_sessions += 1,
228            }
229        }
230
231        SyncStats {
232            total_sessions,
233            active_sessions,
234            completed_sessions,
235            failed_sessions,
236            total_transferred_bytes,
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // ── helpers ──────────────────────────────────────────────────────────────
246
247    fn coordinator() -> PeerSyncCoordinator {
248        PeerSyncCoordinator::new()
249    }
250
251    // ── 1. new() starts empty ────────────────────────────────────────────────
252
253    #[test]
254    fn test_new_starts_empty() {
255        let c = coordinator();
256        assert!(c.sessions.is_empty());
257        assert_eq!(c.next_session_id, 0);
258    }
259
260    // ── 2. start_session creates in Handshake ───────────────────────────────
261
262    #[test]
263    fn test_start_session_creates_in_handshake() {
264        let mut c = coordinator();
265        let id = c.start_session("peer-a", SyncDirection::Pull, 10);
266        let s = c.session(id).expect("session must exist");
267        assert_eq!(s.phase, SyncPhase::Handshake);
268        assert_eq!(s.peer_id, "peer-a");
269        assert_eq!(s.started_at_tick, 10);
270    }
271
272    // ── 3. start_session increments session_id ──────────────────────────────
273
274    #[test]
275    fn test_start_session_increments_id() {
276        let mut c = coordinator();
277        let id0 = c.start_session("peer-a", SyncDirection::Push, 0);
278        let id1 = c.start_session("peer-b", SyncDirection::Pull, 1);
279        assert_eq!(id1, id0 + 1);
280    }
281
282    // ── 4. advance_phase succeeds ───────────────────────────────────────────
283
284    #[test]
285    fn test_advance_phase_succeeds() {
286        let mut c = coordinator();
287        let id = c.start_session("p", SyncDirection::Bidirectional, 0);
288        assert!(c.advance_phase(id, SyncPhase::Discovery));
289        assert_eq!(
290            c.session(id)
291                .expect("test: session must exist after advance_phase")
292                .phase,
293            SyncPhase::Discovery
294        );
295    }
296
297    // ── 5. advance_phase fails on unknown session ───────────────────────────
298
299    #[test]
300    fn test_advance_phase_unknown_session() {
301        let mut c = coordinator();
302        assert!(!c.advance_phase(999, SyncPhase::Transfer));
303    }
304
305    // ── 6. advance_phase fails on terminal session ──────────────────────────
306
307    #[test]
308    fn test_advance_phase_fails_on_terminal() {
309        let mut c = coordinator();
310        let id = c.start_session("p", SyncDirection::Pull, 0);
311        c.complete_session(id);
312        assert!(!c.advance_phase(id, SyncPhase::Transfer));
313    }
314
315    // ── 7. add_want appends cid ─────────────────────────────────────────────
316
317    #[test]
318    fn test_add_want_appends() {
319        let mut c = coordinator();
320        let id = c.start_session("p", SyncDirection::Pull, 0);
321        assert!(c.add_want(id, "cid-1"));
322        assert!(c.add_want(id, "cid-2"));
323        let s = c.session(id).expect("test: session must exist");
324        assert_eq!(s.want_cids, vec!["cid-1", "cid-2"]);
325    }
326
327    // ── 8. add_want fails on unknown ────────────────────────────────────────
328
329    #[test]
330    fn test_add_want_unknown() {
331        let mut c = coordinator();
332        assert!(!c.add_want(999, "cid-x"));
333    }
334
335    // ── 9. mark_received moves cid from want to have ────────────────────────
336
337    #[test]
338    fn test_mark_received_moves_cid() {
339        let mut c = coordinator();
340        let id = c.start_session("p", SyncDirection::Pull, 0);
341        c.add_want(id, "cid-1");
342        c.add_want(id, "cid-2");
343        assert!(c.mark_received(id, "cid-1", 512));
344        let s = c.session(id).expect("test: session must exist");
345        assert!(!s.want_cids.contains(&"cid-1".to_owned()));
346        assert!(s.have_cids.contains(&"cid-1".to_owned()));
347        assert!(s.want_cids.contains(&"cid-2".to_owned()));
348    }
349
350    // ── 10. mark_received accumulates bytes ─────────────────────────────────
351
352    #[test]
353    fn test_mark_received_accumulates_bytes() {
354        let mut c = coordinator();
355        let id = c.start_session("p", SyncDirection::Pull, 0);
356        c.add_want(id, "cid-a");
357        c.add_want(id, "cid-b");
358        c.mark_received(id, "cid-a", 100);
359        c.mark_received(id, "cid-b", 200);
360        assert_eq!(
361            c.session(id)
362                .expect("test: session must exist")
363                .transferred_bytes,
364            300
365        );
366    }
367
368    // ── 11. mark_received returns false if unknown ───────────────────────────
369
370    #[test]
371    fn test_mark_received_unknown() {
372        let mut c = coordinator();
373        assert!(!c.mark_received(999, "cid-x", 0));
374    }
375
376    // ── 12. fail_session sets Failed with reason ─────────────────────────────
377
378    #[test]
379    fn test_fail_session_sets_reason() {
380        let mut c = coordinator();
381        let id = c.start_session("p", SyncDirection::Push, 0);
382        assert!(c.fail_session(id, "timeout".to_owned()));
383        match &c
384            .session(id)
385            .expect("test: session must exist after fail_session")
386            .phase
387        {
388            SyncPhase::Failed { reason } => assert_eq!(reason, "timeout"),
389            other => panic!("expected Failed, got {other:?}"),
390        }
391    }
392
393    // ── 13. fail_session returns false if unknown ────────────────────────────
394
395    #[test]
396    fn test_fail_session_unknown() {
397        let mut c = coordinator();
398        assert!(!c.fail_session(999, "oops".to_owned()));
399    }
400
401    // ── 14. complete_session sets Complete ──────────────────────────────────
402
403    #[test]
404    fn test_complete_session() {
405        let mut c = coordinator();
406        let id = c.start_session("p", SyncDirection::Bidirectional, 0);
407        assert!(c.complete_session(id));
408        assert_eq!(
409            c.session(id)
410                .expect("test: session must exist after complete_session")
411                .phase,
412            SyncPhase::Complete
413        );
414    }
415
416    // ── 15. is_terminal true for Complete ───────────────────────────────────
417
418    #[test]
419    fn test_is_terminal_complete() {
420        let mut c = coordinator();
421        let id = c.start_session("p", SyncDirection::Pull, 0);
422        c.complete_session(id);
423        assert!(c
424            .session(id)
425            .expect("test: session must exist after complete_session")
426            .is_terminal());
427    }
428
429    // ── 16. is_terminal true for Failed ─────────────────────────────────────
430
431    #[test]
432    fn test_is_terminal_failed() {
433        let mut c = coordinator();
434        let id = c.start_session("p", SyncDirection::Pull, 0);
435        c.fail_session(id, "error".to_owned());
436        assert!(c
437            .session(id)
438            .expect("test: session must exist after fail_session")
439            .is_terminal());
440    }
441
442    // ── 17. is_terminal false for Transfer ──────────────────────────────────
443
444    #[test]
445    fn test_is_terminal_false_for_transfer() {
446        let mut c = coordinator();
447        let id = c.start_session("p", SyncDirection::Pull, 0);
448        c.advance_phase(id, SyncPhase::Transfer);
449        assert!(!c
450            .session(id)
451            .expect("test: session must exist after advance_phase")
452            .is_terminal());
453    }
454
455    // ── 18. active_sessions excludes terminal ───────────────────────────────
456
457    #[test]
458    fn test_active_sessions_excludes_terminal() {
459        let mut c = coordinator();
460        let id0 = c.start_session("p0", SyncDirection::Pull, 0);
461        let id1 = c.start_session("p1", SyncDirection::Push, 1);
462        c.complete_session(id0);
463        let active = c.active_sessions();
464        let ids: Vec<u64> = active.iter().map(|s| s.session_id).collect();
465        assert!(!ids.contains(&id0));
466        assert!(ids.contains(&id1));
467    }
468
469    // ── 19. active_sessions sorted by session_id ───────────────────────────
470
471    #[test]
472    fn test_active_sessions_sorted() {
473        let mut c = coordinator();
474        // Insert in reverse order to ensure sort is not relying on insertion order.
475        let id2 = c.start_session("p2", SyncDirection::Pull, 0);
476        let id1 = c.start_session("p1", SyncDirection::Pull, 1);
477        let id0 = c.start_session("p0", SyncDirection::Pull, 2);
478        let active = c.active_sessions();
479        let ids: Vec<u64> = active.iter().map(|s| s.session_id).collect();
480        // id2 < id1 < id0 because they were assigned in ascending order
481        assert_eq!(ids, vec![id2, id1, id0]);
482    }
483
484    // ── 20. stats total_sessions ─────────────────────────────────────────────
485
486    #[test]
487    fn test_stats_total_sessions() {
488        let mut c = coordinator();
489        c.start_session("p0", SyncDirection::Pull, 0);
490        c.start_session("p1", SyncDirection::Push, 1);
491        assert_eq!(c.stats().total_sessions, 2);
492    }
493
494    // ── 21. stats completed / failed ────────────────────────────────────────
495
496    #[test]
497    fn test_stats_completed_and_failed() {
498        let mut c = coordinator();
499        let id0 = c.start_session("p0", SyncDirection::Pull, 0);
500        let id1 = c.start_session("p1", SyncDirection::Push, 1);
501        let _id2 = c.start_session("p2", SyncDirection::Bidirectional, 2);
502        c.complete_session(id0);
503        c.fail_session(id1, "err".to_owned());
504        let stats = c.stats();
505        assert_eq!(stats.completed_sessions, 1);
506        assert_eq!(stats.failed_sessions, 1);
507        assert_eq!(stats.active_sessions, 1);
508    }
509
510    // ── 22. stats total_transferred_bytes ───────────────────────────────────
511
512    #[test]
513    fn test_stats_total_transferred_bytes() {
514        let mut c = coordinator();
515        let id0 = c.start_session("p0", SyncDirection::Pull, 0);
516        let id1 = c.start_session("p1", SyncDirection::Pull, 1);
517        c.add_want(id0, "cid-a");
518        c.add_want(id1, "cid-b");
519        c.mark_received(id0, "cid-a", 1000);
520        c.mark_received(id1, "cid-b", 2500);
521        assert_eq!(c.stats().total_transferred_bytes, 3500);
522    }
523
524    // ── bonus: pending_count ─────────────────────────────────────────────────
525
526    #[test]
527    fn test_pending_count() {
528        let mut c = coordinator();
529        let id = c.start_session("p", SyncDirection::Pull, 0);
530        assert_eq!(
531            c.session(id)
532                .expect("test: session must exist")
533                .pending_count(),
534            0
535        );
536        c.add_want(id, "cid-1");
537        c.add_want(id, "cid-2");
538        assert_eq!(
539            c.session(id)
540                .expect("test: session must exist")
541                .pending_count(),
542            2
543        );
544        c.mark_received(id, "cid-1", 0);
545        assert_eq!(
546            c.session(id)
547                .expect("test: session must exist after mark_received")
548                .pending_count(),
549            1
550        );
551    }
552
553    // ── bonus: add_want on terminal session returns false ───────────────────
554
555    #[test]
556    fn test_add_want_terminal_returns_false() {
557        let mut c = coordinator();
558        let id = c.start_session("p", SyncDirection::Push, 0);
559        c.complete_session(id);
560        assert!(!c.add_want(id, "cid-x"));
561    }
562
563    // ── bonus: advance_phase on Failed terminal returns false ────────────────
564
565    #[test]
566    fn test_advance_phase_on_failed_terminal() {
567        let mut c = coordinator();
568        let id = c.start_session("p", SyncDirection::Pull, 0);
569        c.fail_session(id, "err".to_owned());
570        assert!(!c.advance_phase(id, SyncPhase::Complete));
571        // Phase must remain Failed, not silently overwritten.
572        assert!(matches!(
573            c.session(id).expect("test: session must exist").phase,
574            SyncPhase::Failed { .. }
575        ));
576    }
577}