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}
85
86/// Sync metrics — atomic counters for lock-free concurrent access.
87pub struct SyncMetrics {
88    pub deltas_pushed: AtomicU64,
89    pub deltas_received: AtomicU64,
90    pub deltas_rejected: AtomicU64,
91    pub reconnect_count: AtomicU64,
92    pub last_sync_ts: AtomicU64,
93    pub checksum_failures: AtomicU64,
94    /// Total conflict-related rejections (UNIQUE, FK, schema violations).
95    pub conflicts_total: AtomicU64,
96    /// Per-collection conflict counts. Protected by std::sync::Mutex for HashMap.
97    conflicts_by_collection: std::sync::Mutex<HashMap<String, u64>>,
98}
99
100impl SyncMetrics {
101    pub fn new() -> Self {
102        Self {
103            deltas_pushed: AtomicU64::new(0),
104            deltas_received: AtomicU64::new(0),
105            deltas_rejected: AtomicU64::new(0),
106            reconnect_count: AtomicU64::new(0),
107            last_sync_ts: AtomicU64::new(0),
108            checksum_failures: AtomicU64::new(0),
109            conflicts_total: AtomicU64::new(0),
110            conflicts_by_collection: std::sync::Mutex::new(HashMap::new()),
111        }
112    }
113
114    pub fn record_push(&self, count: u64) {
115        self.deltas_pushed.fetch_add(count, Ordering::Relaxed);
116        self.last_sync_ts
117            .store(crate::runtime::now_millis(), Ordering::Relaxed);
118    }
119
120    pub fn record_received(&self) {
121        self.deltas_received.fetch_add(1, Ordering::Relaxed);
122        self.last_sync_ts
123            .store(crate::runtime::now_millis(), Ordering::Relaxed);
124    }
125
126    pub fn record_reject(&self) {
127        self.deltas_rejected.fetch_add(1, Ordering::Relaxed);
128    }
129
130    pub fn record_reconnect(&self) {
131        self.reconnect_count.fetch_add(1, Ordering::Relaxed);
132    }
133
134    pub fn record_checksum_failure(&self) {
135        self.checksum_failures.fetch_add(1, Ordering::Relaxed);
136    }
137
138    /// Record a conflict (constraint-related rejection) for a collection.
139    pub fn record_conflict(&self, collection: &str) {
140        self.conflicts_total.fetch_add(1, Ordering::Relaxed);
141        if let Ok(mut map) = self.conflicts_by_collection.lock() {
142            *map.entry(collection.to_string()).or_insert(0) += 1;
143        }
144    }
145
146    /// Get per-collection conflict counts snapshot.
147    pub fn conflicts_by_collection(&self) -> HashMap<String, u64> {
148        self.conflicts_by_collection
149            .lock()
150            .map(|m| m.clone())
151            .unwrap_or_default()
152    }
153}
154
155impl Default for SyncMetrics {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// Flow controller — manages the delta push pipeline.
162///
163/// Tracks in-flight deltas, computes RTT for adaptive batch sizing,
164/// and enforces pending queue bounds.
165pub struct FlowController {
166    config: FlowControlConfig,
167
168    /// In-flight tracking: mutation_id → send timestamp.
169    in_flight: HashMap<u64, Instant>,
170
171    /// Exponential moving average of RTT in milliseconds.
172    /// Uses α = 0.125 (TCP-style EWMA).
173    ema_rtt_ms: f64,
174
175    /// Current adaptive batch size.
176    current_batch_size: usize,
177
178    /// Count of consecutive ACK successes (for AIMD additive increase).
179    consecutive_acks: usize,
180
181    /// Current pending delta count (tracked externally, updated here).
182    pending_count: usize,
183
184    /// Current pending delta bytes.
185    pending_bytes: usize,
186}
187
188impl FlowController {
189    pub fn new(config: FlowControlConfig) -> Self {
190        let initial_batch = config.initial_batch_size;
191        Self {
192            config,
193            in_flight: HashMap::new(),
194            ema_rtt_ms: 0.0,
195            current_batch_size: initial_batch,
196            consecutive_acks: 0,
197            pending_count: 0,
198            pending_bytes: 0,
199        }
200    }
201
202    /// Check if the push pipeline can accept more deltas (flow control window).
203    pub fn can_push(&self) -> bool {
204        self.in_flight.len() < self.config.max_in_flight
205    }
206
207    /// How many deltas should be pushed in the next batch.
208    /// Returns 0 if the flow control window is full.
209    pub fn next_batch_size(&self) -> usize {
210        let window_remaining = self
211            .config
212            .max_in_flight
213            .saturating_sub(self.in_flight.len());
214        self.current_batch_size.min(window_remaining)
215    }
216
217    /// Record that deltas were pushed (track in-flight).
218    pub fn record_push(&mut self, mutation_ids: &[u64]) {
219        let now = Instant::now();
220        for &mid in mutation_ids {
221            self.in_flight.insert(mid, now);
222        }
223    }
224
225    /// Record an ACK from Origin — update RTT, adjust batch size (AIMD).
226    ///
227    /// Returns the measured RTT in milliseconds for this ACK.
228    pub fn record_ack(&mut self, mutation_id: u64) -> Option<u64> {
229        let send_time = self.in_flight.remove(&mutation_id)?;
230        let rtt_ms = send_time.elapsed().as_millis() as f64;
231
232        // EWMA update: α = 0.125 (TCP-style smoothing).
233        if self.ema_rtt_ms == 0.0 {
234            self.ema_rtt_ms = rtt_ms;
235        } else {
236            self.ema_rtt_ms = 0.875 * self.ema_rtt_ms + 0.125 * rtt_ms;
237        }
238
239        // AIMD additive increase: every 8 consecutive ACKs, increase batch by 1.
240        self.consecutive_acks += 1;
241        if self.consecutive_acks >= 8 {
242            self.consecutive_acks = 0;
243            self.current_batch_size = (self.current_batch_size + 1).min(self.config.max_batch_size);
244        }
245
246        Some(rtt_ms as u64)
247    }
248
249    /// Record a rejection from Origin — halve the batch size (AIMD multiplicative decrease).
250    pub fn record_reject(&mut self, mutation_id: u64) {
251        self.in_flight.remove(&mutation_id);
252        self.consecutive_acks = 0;
253        // Multiplicative decrease: halve the batch size.
254        self.current_batch_size = (self.current_batch_size / 2).max(self.config.min_batch_size);
255    }
256
257    /// Update pending queue stats (called when deltas are added/removed).
258    pub fn update_pending(&mut self, count: usize, bytes: usize) {
259        self.pending_count = count;
260        self.pending_bytes = bytes;
261    }
262
263    /// Check if the pending queue is at capacity.
264    pub fn is_queue_full(&self) -> bool {
265        self.pending_count >= self.config.max_pending_count
266            || self.pending_bytes >= self.config.max_pending_bytes
267    }
268
269    /// Current in-flight count.
270    pub fn in_flight_count(&self) -> usize {
271        self.in_flight.len()
272    }
273
274    /// Current EMA RTT in milliseconds.
275    pub fn avg_rtt_ms(&self) -> u64 {
276        self.ema_rtt_ms as u64
277    }
278
279    /// Current adaptive batch size.
280    pub fn current_batch_size(&self) -> usize {
281        self.current_batch_size
282    }
283
284    /// Pending count.
285    pub fn pending_count(&self) -> usize {
286        self.pending_count
287    }
288
289    /// Pending bytes.
290    pub fn pending_bytes(&self) -> usize {
291        self.pending_bytes
292    }
293
294    /// Clean up in-flight entries older than a timeout (stale ACKs).
295    /// Returns the number of timed-out entries cleaned.
296    pub fn cleanup_stale(&mut self, timeout: std::time::Duration) -> usize {
297        let now = Instant::now();
298        let before = self.in_flight.len();
299        self.in_flight
300            .retain(|_, sent_at| now.duration_since(*sent_at) < timeout);
301        let cleaned = before - self.in_flight.len();
302        if cleaned > 0 {
303            // Treat stale in-flight as losses → multiplicative decrease.
304            self.consecutive_acks = 0;
305            self.current_batch_size = (self.current_batch_size / 2).max(self.config.min_batch_size);
306        }
307        cleaned
308    }
309
310    /// Build a snapshot of all sync metrics (for health API / monitoring).
311    pub fn snapshot(&self, state: &'static str, metrics: &SyncMetrics) -> SyncMetricsSnapshot {
312        SyncMetricsSnapshot {
313            state,
314            pending_count: self.pending_count as u64,
315            pending_bytes: self.pending_bytes as u64,
316            deltas_pushed: metrics.deltas_pushed.load(Ordering::Relaxed),
317            deltas_received: metrics.deltas_received.load(Ordering::Relaxed),
318            deltas_rejected: metrics.deltas_rejected.load(Ordering::Relaxed),
319            avg_rtt_ms: self.avg_rtt_ms(),
320            in_flight: self.in_flight.len() as u64,
321            reconnect_count: metrics.reconnect_count.load(Ordering::Relaxed),
322            last_sync_ts: metrics.last_sync_ts.load(Ordering::Relaxed),
323            checksum_failures: metrics.checksum_failures.load(Ordering::Relaxed),
324            current_batch_size: self.current_batch_size as u64,
325            conflicts_total: metrics.conflicts_total.load(Ordering::Relaxed),
326        }
327    }
328
329    /// Reset flow control state on reconnect.
330    pub fn reset(&mut self) {
331        self.in_flight.clear();
332        self.consecutive_acks = 0;
333        // Keep the current batch size — don't reset the learned value.
334        // The RTT will naturally adapt to the new connection.
335    }
336}
337
338impl Default for FlowController {
339    fn default() -> Self {
340        Self::new(FlowControlConfig::default())
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn default_config_values() {
350        let cfg = FlowControlConfig::default();
351        assert_eq!(cfg.max_in_flight, 64);
352        assert_eq!(cfg.min_batch_size, 10);
353        assert_eq!(cfg.max_batch_size, 500);
354        assert_eq!(cfg.initial_batch_size, 50);
355        assert_eq!(cfg.max_pending_count, 10_000);
356        assert_eq!(cfg.max_pending_bytes, 50 * 1024 * 1024);
357    }
358
359    #[test]
360    fn flow_control_window() {
361        let mut fc = FlowController::new(FlowControlConfig {
362            max_in_flight: 3,
363            ..Default::default()
364        });
365
366        assert!(fc.can_push());
367        assert_eq!(fc.next_batch_size(), 3); // Window = 3, batch = min(50, 3) = 3.
368
369        fc.record_push(&[1, 2, 3]);
370        assert!(!fc.can_push());
371        assert_eq!(fc.next_batch_size(), 0);
372        assert_eq!(fc.in_flight_count(), 3);
373
374        // ACK one — window opens.
375        fc.record_ack(2);
376        assert!(fc.can_push());
377        assert_eq!(fc.next_batch_size(), 1);
378    }
379
380    #[test]
381    fn aimd_additive_increase() {
382        let mut fc = FlowController::new(FlowControlConfig {
383            initial_batch_size: 10,
384            max_batch_size: 500,
385            max_in_flight: 1000,
386            ..Default::default()
387        });
388
389        assert_eq!(fc.current_batch_size(), 10);
390
391        // 8 consecutive ACKs → batch size increases by 1.
392        for i in 0..8 {
393            fc.record_push(&[i]);
394            fc.record_ack(i);
395        }
396        assert_eq!(fc.current_batch_size(), 11);
397
398        // 8 more → 12.
399        for i in 8..16 {
400            fc.record_push(&[i]);
401            fc.record_ack(i);
402        }
403        assert_eq!(fc.current_batch_size(), 12);
404    }
405
406    #[test]
407    fn aimd_multiplicative_decrease() {
408        let mut fc = FlowController::new(FlowControlConfig {
409            initial_batch_size: 100,
410            min_batch_size: 10,
411            max_in_flight: 1000,
412            ..Default::default()
413        });
414
415        assert_eq!(fc.current_batch_size(), 100);
416
417        // Rejection → halve.
418        fc.record_push(&[1]);
419        fc.record_reject(1);
420        assert_eq!(fc.current_batch_size(), 50);
421
422        // Another rejection → halve again.
423        fc.record_push(&[2]);
424        fc.record_reject(2);
425        assert_eq!(fc.current_batch_size(), 25);
426
427        // Keep halving until floor.
428        fc.record_push(&[3]);
429        fc.record_reject(3);
430        assert_eq!(fc.current_batch_size(), 12);
431
432        fc.record_push(&[4]);
433        fc.record_reject(4);
434        assert_eq!(fc.current_batch_size(), 10); // Floor.
435
436        fc.record_push(&[5]);
437        fc.record_reject(5);
438        assert_eq!(fc.current_batch_size(), 10); // Can't go below floor.
439    }
440
441    #[test]
442    fn rtt_ewma() {
443        let mut fc = FlowController::default();
444        fc.record_push(&[1]);
445        // Can't precisely test RTT since Instant::now() is real time,
446        // but we can verify the structure works.
447        let rtt = fc.record_ack(1);
448        assert!(rtt.is_some());
449        assert!(fc.avg_rtt_ms() < 100); // Should be near-instant in test.
450    }
451
452    #[test]
453    fn bounded_pending_queue_count() {
454        let mut fc = FlowController::new(FlowControlConfig {
455            max_pending_count: 100,
456            max_pending_bytes: 1_000_000,
457            ..Default::default()
458        });
459
460        fc.update_pending(99, 500);
461        assert!(!fc.is_queue_full());
462
463        fc.update_pending(100, 500);
464        assert!(fc.is_queue_full());
465    }
466
467    #[test]
468    fn bounded_pending_queue_bytes() {
469        let mut fc = FlowController::new(FlowControlConfig {
470            max_pending_count: 100_000,
471            max_pending_bytes: 1000,
472            ..Default::default()
473        });
474
475        fc.update_pending(5, 999);
476        assert!(!fc.is_queue_full());
477
478        fc.update_pending(5, 1000);
479        assert!(fc.is_queue_full());
480    }
481
482    #[test]
483    fn cleanup_stale_in_flight() {
484        let mut fc = FlowController::new(FlowControlConfig {
485            initial_batch_size: 100,
486            min_batch_size: 10,
487            max_in_flight: 1000,
488            ..Default::default()
489        });
490
491        fc.record_push(&[1, 2, 3]);
492        assert_eq!(fc.in_flight_count(), 3);
493
494        // Cleanup with a very long timeout — nothing should be cleaned.
495        let cleaned = fc.cleanup_stale(std::time::Duration::from_secs(3600));
496        assert_eq!(cleaned, 0);
497        assert_eq!(fc.in_flight_count(), 3);
498
499        // Cleanup with zero timeout — everything should be cleaned.
500        let cleaned = fc.cleanup_stale(std::time::Duration::ZERO);
501        assert_eq!(cleaned, 3);
502        assert_eq!(fc.in_flight_count(), 0);
503        // Stale cleanup triggers multiplicative decrease.
504        assert_eq!(fc.current_batch_size(), 50);
505    }
506
507    #[test]
508    fn reset_clears_in_flight() {
509        let mut fc = FlowController::default();
510        fc.record_push(&[1, 2, 3]);
511        assert_eq!(fc.in_flight_count(), 3);
512
513        fc.reset();
514        assert_eq!(fc.in_flight_count(), 0);
515        // Batch size preserved across reset (learned value).
516        assert_eq!(fc.current_batch_size(), 50);
517    }
518
519    #[test]
520    fn metrics_snapshot() {
521        let mut fc = FlowController::default();
522        let metrics = SyncMetrics::new();
523        metrics.record_push(5);
524        metrics.record_received();
525        metrics.record_reject();
526        metrics.record_reconnect();
527
528        fc.update_pending(42, 8192);
529
530        let snap = fc.snapshot("connected", &metrics);
531        assert_eq!(snap.state, "connected");
532        assert_eq!(snap.pending_count, 42);
533        assert_eq!(snap.pending_bytes, 8192);
534        assert_eq!(snap.deltas_pushed, 5);
535        assert_eq!(snap.deltas_received, 1);
536        assert_eq!(snap.deltas_rejected, 1);
537        assert_eq!(snap.reconnect_count, 1);
538        assert_eq!(snap.current_batch_size, 50);
539    }
540
541    #[test]
542    fn ack_unknown_mutation_returns_none() {
543        let mut fc = FlowController::default();
544        assert!(fc.record_ack(999).is_none());
545    }
546
547    #[test]
548    fn batch_size_capped_at_max() {
549        let mut fc = FlowController::new(FlowControlConfig {
550            initial_batch_size: 499,
551            max_batch_size: 500,
552            max_in_flight: 10_000,
553            ..Default::default()
554        });
555
556        // 8 ACKs → increase by 1 → 500 (max).
557        for i in 0..8 {
558            fc.record_push(&[i]);
559            fc.record_ack(i);
560        }
561        assert_eq!(fc.current_batch_size(), 500);
562
563        // 8 more → still 500 (capped).
564        for i in 8..16 {
565            fc.record_push(&[i]);
566            fc.record_ack(i);
567        }
568        assert_eq!(fc.current_batch_size(), 500);
569    }
570}