Skip to main content

appcore_sync/sync/
client.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: client.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/06/02 13:08:16 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Follower push client with bounded queue and retry behavior.
12
13use crate::sync::error::{SyncError, SyncResult};
14use crate::sync::outbox::{
15    FileSyncOutbox, InMemorySyncOutbox, SyncOutbox, SyncOutboxReceipt, SyncOutboxStats,
16    MAX_OUTBOX_PAGE_BYTES,
17};
18use crate::sync::retry::{SyncPushMetrics, SyncRetryPolicy};
19use crate::sync::transport::HttpSyncTransport;
20use crate::sync::types::SyncMessage;
21use parking_lot::Mutex;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26/// Pushes leader events to a follower over transport.
27#[derive(Clone)]
28pub struct FollowerSyncClient {
29    transport: HttpSyncTransport,
30    retry_policy: SyncRetryPolicy,
31    outbox: Arc<dyn SyncOutbox>,
32    flush_lock: Arc<Mutex<()>>,
33    metrics: Arc<Mutex<SyncPushMetrics>>,
34}
35
36impl std::fmt::Debug for FollowerSyncClient {
37    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        formatter
39            .debug_struct("FollowerSyncClient")
40            .field("retry_policy", &self.retry_policy)
41            .field("pending_len", &self.pending_len())
42            .field("metrics", &self.metrics())
43            .finish()
44    }
45}
46
47impl FollowerSyncClient {
48    /// Creates a follower client with bounded in-memory buffering and default retries.
49    pub fn new(transport: HttpSyncTransport) -> Self {
50        Self {
51            transport,
52            retry_policy: SyncRetryPolicy::default(),
53            outbox: Arc::new(InMemorySyncOutbox::new()),
54            flush_lock: Arc::new(Mutex::new(())),
55            metrics: Arc::new(Mutex::new(SyncPushMetrics::default())),
56        }
57    }
58
59    /// Replaces retry, backoff, and queue limits.
60    pub fn with_retry_policy(mut self, retry_policy: SyncRetryPolicy) -> Self {
61        self.retry_policy = retry_policy;
62        self
63    }
64
65    /// Returns the configured retry policy.
66    pub fn retry_policy(&self) -> SyncRetryPolicy {
67        self.retry_policy
68    }
69
70    /// Replaces the pending-message outbox implementation.
71    pub fn with_outbox(mut self, outbox: Arc<dyn SyncOutbox>) -> Self {
72        self.outbox = outbox;
73        self
74    }
75
76    /// Configures a durable file outbox at `file_path`.
77    pub fn with_file_outbox(self, file_path: impl Into<PathBuf>) -> SyncResult<Self> {
78        Ok(self.with_outbox(Arc::new(FileSyncOutbox::new(file_path)?)))
79    }
80
81    /// Returns a snapshot of cumulative push counters.
82    pub fn metrics(&self) -> SyncPushMetrics {
83        *self.metrics.lock()
84    }
85
86    /// Returns the pending count, or zero if the outbox cannot be read.
87    pub fn pending_len(&self) -> usize {
88        self.outbox.len().unwrap_or(0)
89    }
90
91    /// Returns a complete compatibility snapshot of pending batches.
92    ///
93    /// New consumers should use [`Self::pending_page`] and [`Self::outbox_stats`]
94    /// so queue growth cannot determine one allocation.
95    pub fn pending_messages(&self) -> SyncResult<Vec<SyncMessage>> {
96        self.outbox.messages()
97    }
98
99    /// Returns one pending page bounded before payload clones.
100    pub fn pending_page(&self, limit: usize, max_bytes: usize) -> SyncResult<Vec<SyncMessage>> {
101        self.outbox.peek(limit, max_bytes)
102    }
103
104    /// Returns payload-free outbox observations.
105    pub fn outbox_stats(&self) -> SyncResult<SyncOutboxStats> {
106        self.outbox.stats()
107    }
108
109    /// Cancels active transport I/O and retry waits.
110    pub fn cancel(&self) {
111        self.transport.cancel();
112    }
113
114    /// Reports whether this client has been cancelled.
115    pub fn is_cancelled(&self) -> bool {
116        self.transport.is_cancelled()
117    }
118
119    /// Attempts delivery of all currently queued batches.
120    pub fn flush_pending(&self) -> SyncResult<()> {
121        self.flush_queue().map(|_| ())
122    }
123
124    /// Attempts queued delivery and returns the last batch durably acknowledged.
125    pub fn flush_pending_with_progress(&self) -> SyncResult<Option<SyncMessage>> {
126        self.flush_queue()
127    }
128
129    /// Enqueues a batch durably before attempting ordered delivery.
130    pub fn push_events(&self, message: &SyncMessage) -> SyncResult<()> {
131        if !self
132            .outbox
133            .try_enqueue(message.clone(), self.retry_policy.max_queue_len)?
134        {
135            let mut metrics = self.metrics.lock();
136            metrics.push_dropped += 1;
137            return Err(SyncError::TransportFailed("sync queue full".to_string()));
138        }
139        self.flush_queue().map(|_| ())
140    }
141
142    fn flush_queue(&self) -> SyncResult<Option<SyncMessage>> {
143        let _flush_guard = self.flush_lock.lock();
144        let mut last_acknowledged = None;
145        loop {
146            let now_ms = unix_time_ms();
147            let message = match self
148                .outbox
149                .next_ready(now_ms, 1, MAX_OUTBOX_PAGE_BYTES)?
150                .into_iter()
151                .next()
152            {
153                Some(message) => message,
154                None if self.outbox.is_empty()? => return Ok(last_acknowledged),
155                None => {
156                    return Err(SyncError::TransportFailed(
157                        "sync push retry deferred".to_string(),
158                    ));
159                }
160            };
161            if let Err(error) = self.try_send_with_retry(&message) {
162                let mut metrics = self.metrics.lock();
163                metrics.push_failed += 1;
164                return Err(error);
165            }
166            let receipt = SyncOutboxReceipt::new(vec![message.batch_id.clone()])?;
167            self.outbox.acknowledge_receipt(&receipt)?;
168            let mut metrics = self.metrics.lock();
169            metrics.push_success += 1;
170            last_acknowledged = Some(message);
171        }
172    }
173
174    fn try_send_with_retry(&self, message: &SyncMessage) -> SyncResult<()> {
175        let max_attempts = self.retry_policy.max_attempts.max(1);
176        for attempt in 1..=max_attempts {
177            if self.transport.is_cancelled() {
178                return Err(SyncError::TransportFailed(
179                    "sync push cancelled".to_string(),
180                ));
181            }
182            let mut metrics = self.metrics.lock();
183            metrics.push_attempt += 1;
184            drop(metrics);
185            if self.transport.post_sync_events(message).is_ok() {
186                return Ok(());
187            }
188            let next_ready_at_ms = unix_time_ms().saturating_add(self.retry_policy.backoff_ms);
189            match self
190                .outbox
191                .mark_attempt(&message.batch_id, next_ready_at_ms)
192            {
193                Ok(_) | Err(SyncError::OutboxOperationUnsupported(_)) => {}
194                Err(error) => return Err(error),
195            }
196            if attempt < max_attempts
197                && self.retry_policy.backoff_ms > 0
198                && self
199                    .transport
200                    .cancellation_token()
201                    .wait_timeout(Duration::from_millis(self.retry_policy.backoff_ms))
202            {
203                return Err(SyncError::TransportFailed(
204                    "sync push cancelled".to_string(),
205                ));
206            }
207        }
208        Err(SyncError::TransportFailed(
209            "sync push retry exhausted".to_string(),
210        ))
211    }
212}
213
214fn unix_time_ms() -> u64 {
215    SystemTime::now()
216        .duration_since(UNIX_EPOCH)
217        .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
218        .unwrap_or(0)
219}