Skip to main content

nodedb_lite/sync/
flow_control.rs

1//! Flow control, adaptive batching, and sync metrics.
2//!
3//! Manages the delta push pipeline with:
4//! - **ACK-based flow control**: in-flight window limits concurrent unACK'd deltas
5//! - **Adaptive batch sizing**: AIMD algorithm adjusts batch size based on observed RTT
6//! - **Bounded pending queue**: configurable limits on pending delta count and bytes
7//! - **Sync metrics**: structured, observable sync state for monitoring
8//!
9//! All state is behind `tokio::sync::Mutex` for use from the async sync transport.
10
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::Instant;
14
15use serde::Serialize;
16
17/// Flow control configuration.
18#[derive(Debug, Clone)]
19pub struct FlowControlConfig {
20    /// Maximum in-flight (unACK'd) deltas before pausing pushes.
21    /// Default: 64.
22    pub max_in_flight: usize,
23    /// Minimum batch size (floor for AIMD decrease).
24    /// Default: 10.
25    pub min_batch_size: usize,
26    /// Maximum batch size (ceiling for AIMD increase).
27    /// Default: 500.
28    pub max_batch_size: usize,
29    /// Initial batch size before RTT data is available.
30    /// Default: 50.
31    pub initial_batch_size: usize,
32    /// Maximum pending deltas in queue before rejecting writes.
33    /// Default: 10_000.
34    pub max_pending_count: usize,
35    /// Maximum pending bytes in queue before rejecting writes.
36    /// Default: 50 MB.
37    pub max_pending_bytes: usize,
38}
39
40impl Default for FlowControlConfig {
41    fn default() -> Self {
42        Self {
43            max_in_flight: 64,
44            min_batch_size: 10,
45            max_batch_size: 500,
46            initial_batch_size: 50,
47            max_pending_count: 10_000,
48            max_pending_bytes: 50 * 1024 * 1024,
49        }
50    }
51}
52
53/// Observable sync metrics — all atomic for lock-free reads.
54///
55/// Serialize to JSON for health endpoints and monitoring.
56#[derive(Debug, Serialize)]
57pub struct SyncMetricsSnapshot {
58    /// Current sync connection state.
59    pub state: &'static str,
60    /// Number of pending (unsent + unACK'd) deltas.
61    pub pending_count: u64,
62    /// Total bytes of pending deltas.
63    pub pending_bytes: u64,
64    /// Total deltas pushed to Origin (lifetime).
65    pub deltas_pushed: u64,
66    /// Total deltas received from Origin (lifetime).
67    pub deltas_received: u64,
68    /// Total deltas rejected by Origin (lifetime).
69    pub deltas_rejected: u64,
70    /// Exponential moving average RTT in milliseconds.
71    pub avg_rtt_ms: u64,
72    /// Current in-flight (pushed but not yet ACK'd) count.
73    pub in_flight: u64,
74    /// Total reconnect attempts (lifetime).
75    pub reconnect_count: u64,
76    /// Timestamp (millis) of last successful sync activity.
77    pub last_sync_ts: u64,
78    /// Total CRC32C checksum failures detected (lifetime).
79    pub checksum_failures: u64,
80    /// Current adaptive batch size.
81    pub current_batch_size: u64,
82    /// Total conflict-related rejections (lifetime).
83    pub conflicts_total: u64,
84    /// In-flight entries evicted by stale-timeout (lifetime).
85    pub stale_timeouts: u64,
86}
87
88/// Sync metrics — atomic counters for lock-free concurrent access.
89pub struct SyncMetrics {
90    pub deltas_pushed: AtomicU64,
91    pub deltas_received: AtomicU64,
92    pub deltas_rejected: AtomicU64,
93    pub reconnect_count: AtomicU64,
94    pub last_sync_ts: AtomicU64,
95    pub checksum_failures: AtomicU64,
96    /// Total conflict-related rejections (UNIQUE, FK, schema violations).
97    pub conflicts_total: AtomicU64,
98    /// Per-collection conflict counts. Protected by std::sync::Mutex for HashMap.
99    conflicts_by_collection: std::sync::Mutex<HashMap<String, u64>>,
100    /// Clock-skew warnings received from Origin on DeltaAck.
101    pub clock_skew_warnings: AtomicU64,
102    /// In-flight entries evicted by stale-timeout (lifetime).
103    pub stale_timeouts: AtomicU64,
104}
105
106impl SyncMetrics {
107    pub fn new() -> Self {
108        Self {
109            deltas_pushed: AtomicU64::new(0),
110            deltas_received: AtomicU64::new(0),
111            deltas_rejected: AtomicU64::new(0),
112            reconnect_count: AtomicU64::new(0),
113            last_sync_ts: AtomicU64::new(0),
114            checksum_failures: AtomicU64::new(0),
115            conflicts_total: AtomicU64::new(0),
116            conflicts_by_collection: std::sync::Mutex::new(HashMap::new()),
117            clock_skew_warnings: AtomicU64::new(0),
118            stale_timeouts: AtomicU64::new(0),
119        }
120    }
121
122    /// Record stale in-flight evictions (from `cleanup_stale`).
123    pub fn record_stale_timeouts(&self, count: u64) {
124        self.stale_timeouts.fetch_add(count, Ordering::Relaxed);
125    }
126
127    /// Record a clock-skew warning reported by Origin in a DeltaAck.
128    pub fn record_clock_skew_warning(&self) {
129        self.clock_skew_warnings.fetch_add(1, Ordering::Relaxed);
130    }
131
132    pub fn record_push(&self, count: u64) {
133        self.deltas_pushed.fetch_add(count, Ordering::Relaxed);
134        self.last_sync_ts
135            .store(crate::runtime::now_millis(), Ordering::Relaxed);
136    }
137
138    pub fn record_received(&self) {
139        self.deltas_received.fetch_add(1, Ordering::Relaxed);
140        self.last_sync_ts
141            .store(crate::runtime::now_millis(), Ordering::Relaxed);
142    }
143
144    pub fn record_reject(&self) {
145        self.deltas_rejected.fetch_add(1, Ordering::Relaxed);
146    }
147
148    pub fn record_reconnect(&self) {
149        self.reconnect_count.fetch_add(1, Ordering::Relaxed);
150    }
151
152    pub fn record_checksum_failure(&self) {
153        self.checksum_failures.fetch_add(1, Ordering::Relaxed);
154    }
155
156    /// Record a conflict (constraint-related rejection) for a collection.
157    pub fn record_conflict(&self, collection: &str) {
158        self.conflicts_total.fetch_add(1, Ordering::Relaxed);
159        if let Ok(mut map) = self.conflicts_by_collection.lock() {
160            *map.entry(collection.to_string()).or_insert(0) += 1;
161        }
162    }
163
164    /// Get per-collection conflict counts snapshot.
165    pub fn conflicts_by_collection(&self) -> HashMap<String, u64> {
166        self.conflicts_by_collection
167            .lock()
168            .map(|m| m.clone())
169            .unwrap_or_default()
170    }
171}
172
173impl Default for SyncMetrics {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179/// Flow controller — manages the delta push pipeline.
180///
181/// Tracks in-flight deltas, computes RTT for adaptive batch sizing,
182/// and enforces pending queue bounds.
183pub struct FlowController {
184    config: FlowControlConfig,
185
186    /// In-flight tracking: mutation_id → send timestamp.
187    in_flight: HashMap<u64, Instant>,
188
189    /// Exponential moving average of RTT in milliseconds.
190    /// Uses α = 0.125 (TCP-style EWMA).
191    ema_rtt_ms: f64,
192
193    /// Current adaptive batch size.
194    current_batch_size: usize,
195
196    /// Count of consecutive ACK successes (for AIMD additive increase).
197    consecutive_acks: usize,
198
199    /// Current pending delta count (tracked externally, updated here).
200    pending_count: usize,
201
202    /// Current pending delta bytes.
203    pending_bytes: usize,
204}
205
206impl FlowController {
207    pub fn new(config: FlowControlConfig) -> Self {
208        let initial_batch = config.initial_batch_size;
209        Self {
210            config,
211            in_flight: HashMap::new(),
212            ema_rtt_ms: 0.0,
213            current_batch_size: initial_batch,
214            consecutive_acks: 0,
215            pending_count: 0,
216            pending_bytes: 0,
217        }
218    }
219
220    /// Check if the push pipeline can accept more deltas (flow control window).
221    pub fn can_push(&self) -> bool {
222        self.in_flight.len() < self.config.max_in_flight
223    }
224
225    /// How many deltas should be pushed in the next batch.
226    /// Returns 0 if the flow control window is full.
227    pub fn next_batch_size(&self) -> usize {
228        let window_remaining = self
229            .config
230            .max_in_flight
231            .saturating_sub(self.in_flight.len());
232        self.current_batch_size.min(window_remaining)
233    }
234
235    /// Record that deltas were pushed (track in-flight).
236    pub fn record_push(&mut self, mutation_ids: &[u64]) {
237        let now = Instant::now();
238        for &mid in mutation_ids {
239            self.in_flight.insert(mid, now);
240        }
241    }
242
243    /// Record an ACK from Origin — update RTT, adjust batch size (AIMD).
244    ///
245    /// Returns the measured RTT in milliseconds for this ACK.
246    pub fn record_ack(&mut self, mutation_id: u64) -> Option<u64> {
247        let send_time = self.in_flight.remove(&mutation_id)?;
248        let rtt_ms = send_time.elapsed().as_millis() as f64;
249
250        // EWMA update: α = 0.125 (TCP-style smoothing).
251        if self.ema_rtt_ms == 0.0 {
252            self.ema_rtt_ms = rtt_ms;
253        } else {
254            self.ema_rtt_ms = 0.875 * self.ema_rtt_ms + 0.125 * rtt_ms;
255        }
256
257        // AIMD additive increase: every 8 consecutive ACKs, increase batch by 1.
258        self.consecutive_acks += 1;
259        if self.consecutive_acks >= 8 {
260            self.consecutive_acks = 0;
261            self.current_batch_size = (self.current_batch_size + 1).min(self.config.max_batch_size);
262        }
263
264        Some(rtt_ms as u64)
265    }
266
267    /// Record a rejection from Origin — halve the batch size (AIMD multiplicative decrease).
268    pub fn record_reject(&mut self, mutation_id: u64) {
269        self.in_flight.remove(&mutation_id);
270        self.consecutive_acks = 0;
271        // Multiplicative decrease: halve the batch size.
272        self.current_batch_size = (self.current_batch_size / 2).max(self.config.min_batch_size);
273    }
274
275    /// Update pending queue stats (called when deltas are added/removed).
276    pub fn update_pending(&mut self, count: usize, bytes: usize) {
277        self.pending_count = count;
278        self.pending_bytes = bytes;
279    }
280
281    /// Check if the pending queue is at capacity.
282    pub fn is_queue_full(&self) -> bool {
283        self.pending_count >= self.config.max_pending_count
284            || self.pending_bytes >= self.config.max_pending_bytes
285    }
286
287    /// Current in-flight count.
288    pub fn in_flight_count(&self) -> usize {
289        self.in_flight.len()
290    }
291
292    /// Current EMA RTT in milliseconds.
293    pub fn avg_rtt_ms(&self) -> u64 {
294        self.ema_rtt_ms as u64
295    }
296
297    /// Current adaptive batch size.
298    pub fn current_batch_size(&self) -> usize {
299        self.current_batch_size
300    }
301
302    /// Pending count.
303    pub fn pending_count(&self) -> usize {
304        self.pending_count
305    }
306
307    /// Pending bytes.
308    pub fn pending_bytes(&self) -> usize {
309        self.pending_bytes
310    }
311
312    /// Clean up in-flight entries older than a timeout (stale ACKs).
313    ///
314    /// Returns the number of timed-out entries cleaned. On any eviction applies
315    /// AIMD multiplicative decrease (halves `current_batch_size` to `min_batch_size`)
316    /// and resets `consecutive_acks`.
317    pub fn cleanup_stale(&mut self, timeout: std::time::Duration) -> usize {
318        let now = Instant::now();
319        let before = self.in_flight.len();
320        self.in_flight
321            .retain(|_, sent_at| now.duration_since(*sent_at) < timeout);
322        let cleaned = before - self.in_flight.len();
323        if cleaned > 0 {
324            // Treat stale in-flight as losses → multiplicative decrease.
325            self.consecutive_acks = 0;
326            self.current_batch_size = (self.current_batch_size / 2).max(self.config.min_batch_size);
327        }
328        cleaned
329    }
330
331    /// Clean up stale in-flight entries and record evictions in `metrics`.
332    ///
333    /// Called periodically from the ping loop. `timeout` is the maximum age
334    /// an unACK'd in-flight entry may have before it is evicted.
335    pub fn cleanup_stale_and_record(
336        &mut self,
337        timeout: std::time::Duration,
338        metrics: &SyncMetrics,
339    ) {
340        let cleaned = self.cleanup_stale(timeout);
341        if cleaned > 0 {
342            metrics.record_stale_timeouts(cleaned as u64);
343            tracing::warn!(
344                evicted = cleaned,
345                "stale in-flight entries evicted; AIMD batch-size decreased"
346            );
347        }
348    }
349
350    /// Build a snapshot of all sync metrics (for health API / monitoring).
351    pub fn snapshot(&self, state: &'static str, metrics: &SyncMetrics) -> SyncMetricsSnapshot {
352        SyncMetricsSnapshot {
353            state,
354            pending_count: self.pending_count as u64,
355            pending_bytes: self.pending_bytes as u64,
356            deltas_pushed: metrics.deltas_pushed.load(Ordering::Relaxed),
357            deltas_received: metrics.deltas_received.load(Ordering::Relaxed),
358            deltas_rejected: metrics.deltas_rejected.load(Ordering::Relaxed),
359            avg_rtt_ms: self.avg_rtt_ms(),
360            in_flight: self.in_flight.len() as u64,
361            reconnect_count: metrics.reconnect_count.load(Ordering::Relaxed),
362            last_sync_ts: metrics.last_sync_ts.load(Ordering::Relaxed),
363            checksum_failures: metrics.checksum_failures.load(Ordering::Relaxed),
364            current_batch_size: self.current_batch_size as u64,
365            conflicts_total: metrics.conflicts_total.load(Ordering::Relaxed),
366            stale_timeouts: metrics.stale_timeouts.load(Ordering::Relaxed),
367        }
368    }
369
370    /// Reset flow control state on reconnect.
371    pub fn reset(&mut self) {
372        self.in_flight.clear();
373        self.consecutive_acks = 0;
374        // Keep the current batch size — don't reset the learned value.
375        // The RTT will naturally adapt to the new connection.
376    }
377}
378
379impl Default for FlowController {
380    fn default() -> Self {
381        Self::new(FlowControlConfig::default())
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn default_config_values() {
391        let cfg = FlowControlConfig::default();
392        assert_eq!(cfg.max_in_flight, 64);
393        assert_eq!(cfg.min_batch_size, 10);
394        assert_eq!(cfg.max_batch_size, 500);
395        assert_eq!(cfg.initial_batch_size, 50);
396        assert_eq!(cfg.max_pending_count, 10_000);
397        assert_eq!(cfg.max_pending_bytes, 50 * 1024 * 1024);
398    }
399
400    #[test]
401    fn flow_control_window() {
402        let mut fc = FlowController::new(FlowControlConfig {
403            max_in_flight: 3,
404            ..Default::default()
405        });
406
407        assert!(fc.can_push());
408        assert_eq!(fc.next_batch_size(), 3); // Window = 3, batch = min(50, 3) = 3.
409
410        fc.record_push(&[1, 2, 3]);
411        assert!(!fc.can_push());
412        assert_eq!(fc.next_batch_size(), 0);
413        assert_eq!(fc.in_flight_count(), 3);
414
415        // ACK one — window opens.
416        fc.record_ack(2);
417        assert!(fc.can_push());
418        assert_eq!(fc.next_batch_size(), 1);
419    }
420
421    #[test]
422    fn aimd_additive_increase() {
423        let mut fc = FlowController::new(FlowControlConfig {
424            initial_batch_size: 10,
425            max_batch_size: 500,
426            max_in_flight: 1000,
427            ..Default::default()
428        });
429
430        assert_eq!(fc.current_batch_size(), 10);
431
432        // 8 consecutive ACKs → batch size increases by 1.
433        for i in 0..8 {
434            fc.record_push(&[i]);
435            fc.record_ack(i);
436        }
437        assert_eq!(fc.current_batch_size(), 11);
438
439        // 8 more → 12.
440        for i in 8..16 {
441            fc.record_push(&[i]);
442            fc.record_ack(i);
443        }
444        assert_eq!(fc.current_batch_size(), 12);
445    }
446
447    #[test]
448    fn aimd_multiplicative_decrease() {
449        let mut fc = FlowController::new(FlowControlConfig {
450            initial_batch_size: 100,
451            min_batch_size: 10,
452            max_in_flight: 1000,
453            ..Default::default()
454        });
455
456        assert_eq!(fc.current_batch_size(), 100);
457
458        // Rejection → halve.
459        fc.record_push(&[1]);
460        fc.record_reject(1);
461        assert_eq!(fc.current_batch_size(), 50);
462
463        // Another rejection → halve again.
464        fc.record_push(&[2]);
465        fc.record_reject(2);
466        assert_eq!(fc.current_batch_size(), 25);
467
468        // Keep halving until floor.
469        fc.record_push(&[3]);
470        fc.record_reject(3);
471        assert_eq!(fc.current_batch_size(), 12);
472
473        fc.record_push(&[4]);
474        fc.record_reject(4);
475        assert_eq!(fc.current_batch_size(), 10); // Floor.
476
477        fc.record_push(&[5]);
478        fc.record_reject(5);
479        assert_eq!(fc.current_batch_size(), 10); // Can't go below floor.
480    }
481
482    #[test]
483    fn rtt_ewma() {
484        let mut fc = FlowController::default();
485        fc.record_push(&[1]);
486        // Can't precisely test RTT since Instant::now() is real time,
487        // but we can verify the structure works.
488        let rtt = fc.record_ack(1);
489        assert!(rtt.is_some());
490        assert!(fc.avg_rtt_ms() < 100); // Should be near-instant in test.
491    }
492
493    #[test]
494    fn bounded_pending_queue_count() {
495        let mut fc = FlowController::new(FlowControlConfig {
496            max_pending_count: 100,
497            max_pending_bytes: 1_000_000,
498            ..Default::default()
499        });
500
501        fc.update_pending(99, 500);
502        assert!(!fc.is_queue_full());
503
504        fc.update_pending(100, 500);
505        assert!(fc.is_queue_full());
506    }
507
508    #[test]
509    fn bounded_pending_queue_bytes() {
510        let mut fc = FlowController::new(FlowControlConfig {
511            max_pending_count: 100_000,
512            max_pending_bytes: 1000,
513            ..Default::default()
514        });
515
516        fc.update_pending(5, 999);
517        assert!(!fc.is_queue_full());
518
519        fc.update_pending(5, 1000);
520        assert!(fc.is_queue_full());
521    }
522
523    #[test]
524    fn cleanup_stale_in_flight() {
525        let mut fc = FlowController::new(FlowControlConfig {
526            initial_batch_size: 100,
527            min_batch_size: 10,
528            max_in_flight: 1000,
529            ..Default::default()
530        });
531
532        fc.record_push(&[1, 2, 3]);
533        assert_eq!(fc.in_flight_count(), 3);
534
535        // Cleanup with a very long timeout — nothing should be cleaned.
536        let cleaned = fc.cleanup_stale(std::time::Duration::from_secs(3600));
537        assert_eq!(cleaned, 0);
538        assert_eq!(fc.in_flight_count(), 3);
539
540        // Cleanup with zero timeout — everything should be cleaned.
541        let cleaned = fc.cleanup_stale(std::time::Duration::ZERO);
542        assert_eq!(cleaned, 3);
543        assert_eq!(fc.in_flight_count(), 0);
544        // Stale cleanup triggers multiplicative decrease.
545        assert_eq!(fc.current_batch_size(), 50);
546    }
547
548    #[test]
549    fn reset_clears_in_flight() {
550        let mut fc = FlowController::default();
551        fc.record_push(&[1, 2, 3]);
552        assert_eq!(fc.in_flight_count(), 3);
553
554        fc.reset();
555        assert_eq!(fc.in_flight_count(), 0);
556        // Batch size preserved across reset (learned value).
557        assert_eq!(fc.current_batch_size(), 50);
558    }
559
560    #[test]
561    fn metrics_snapshot() {
562        let mut fc = FlowController::default();
563        let metrics = SyncMetrics::new();
564        metrics.record_push(5);
565        metrics.record_received();
566        metrics.record_reject();
567        metrics.record_reconnect();
568
569        fc.update_pending(42, 8192);
570
571        let snap = fc.snapshot("connected", &metrics);
572        assert_eq!(snap.state, "connected");
573        assert_eq!(snap.pending_count, 42);
574        assert_eq!(snap.pending_bytes, 8192);
575        assert_eq!(snap.deltas_pushed, 5);
576        assert_eq!(snap.deltas_received, 1);
577        assert_eq!(snap.deltas_rejected, 1);
578        assert_eq!(snap.reconnect_count, 1);
579        assert_eq!(snap.current_batch_size, 50);
580    }
581
582    #[test]
583    fn cleanup_stale_removes_old_entries_and_halves_batch() {
584        let mut fc = FlowController::new(FlowControlConfig {
585            initial_batch_size: 80,
586            min_batch_size: 10,
587            max_in_flight: 1000,
588            ..Default::default()
589        });
590        let metrics = SyncMetrics::new();
591
592        fc.record_push(&[10, 20, 30]);
593        assert_eq!(fc.in_flight_count(), 3);
594
595        // Zero timeout → all three entries are stale.
596        fc.cleanup_stale_and_record(std::time::Duration::ZERO, &metrics);
597        assert_eq!(fc.in_flight_count(), 0);
598        // Batch size halved: 80 / 2 = 40.
599        assert_eq!(fc.current_batch_size(), 40);
600        // Metric incremented by 3.
601        assert_eq!(
602            metrics
603                .stale_timeouts
604                .load(std::sync::atomic::Ordering::Relaxed),
605            3
606        );
607
608        // No entries → no change on second call.
609        fc.cleanup_stale_and_record(std::time::Duration::ZERO, &metrics);
610        assert_eq!(
611            metrics
612                .stale_timeouts
613                .load(std::sync::atomic::Ordering::Relaxed),
614            3
615        );
616        // Batch size unchanged when nothing was evicted.
617        assert_eq!(fc.current_batch_size(), 40);
618    }
619
620    #[test]
621    fn ack_unknown_mutation_returns_none() {
622        let mut fc = FlowController::default();
623        assert!(fc.record_ack(999).is_none());
624    }
625
626    #[test]
627    fn batch_size_capped_at_max() {
628        let mut fc = FlowController::new(FlowControlConfig {
629            initial_batch_size: 499,
630            max_batch_size: 500,
631            max_in_flight: 10_000,
632            ..Default::default()
633        });
634
635        // 8 ACKs → increase by 1 → 500 (max).
636        for i in 0..8 {
637            fc.record_push(&[i]);
638            fc.record_ack(i);
639        }
640        assert_eq!(fc.current_batch_size(), 500);
641
642        // 8 more → still 500 (capped).
643        for i in 8..16 {
644            fc.record_push(&[i]);
645            fc.record_ack(i);
646        }
647        assert_eq!(fc.current_batch_size(), 500);
648    }
649}