Skip to main content

appcore_sync/sync/
outbox.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: outbox.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/21 10:48:21 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/26 00:00:00 by dnettoRaw
8//      ###########      S: 2.0.0
9// =============================================================================
10
11//! Durable bounded outbox contracts and process-local implementation.
12
13use crate::sync::error::{SyncError, SyncResult};
14use crate::sync::types::SyncMessage;
15use parking_lot::Mutex;
16use std::collections::VecDeque;
17
18pub use crate::sync::outbox_journal::{FileSyncOutbox, SYNC_OUTBOX_FORMAT_V2};
19
20/// Maximum number of messages returned by one bounded outbox read.
21pub const MAX_OUTBOX_PAGE_MESSAGES: usize = 1_024;
22/// Maximum encoded message bytes returned by one bounded outbox read.
23pub const MAX_OUTBOX_PAGE_BYTES: usize = 48 * 1024 * 1024;
24
25/// Bounded acknowledgement for an ordered prefix of the outbox.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SyncOutboxReceipt {
28    batch_ids: Vec<String>,
29}
30
31impl SyncOutboxReceipt {
32    /// Builds a non-empty bounded receipt in delivery order.
33    pub fn new(batch_ids: Vec<String>) -> SyncResult<Self> {
34        if batch_ids.is_empty() || batch_ids.len() > MAX_OUTBOX_PAGE_MESSAGES {
35            return Err(SyncError::InvalidSyncMessage("invalid outbox receipt"));
36        }
37        for batch_id in &batch_ids {
38            validate_outbox_batch_id(batch_id)?;
39        }
40        Ok(Self { batch_ids })
41    }
42
43    /// Returns acknowledged batch identifiers in delivery order.
44    pub fn batch_ids(&self) -> &[String] {
45        &self.batch_ids
46    }
47}
48
49/// Bounded outbox observations without message payloads.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct SyncOutboxStats {
52    /// Number of pending messages.
53    pub pending_messages: usize,
54    /// Total encoded pending bytes when the provider can report them exactly.
55    pub pending_bytes: Option<usize>,
56    /// Number of pending messages that have at least one delivery attempt.
57    pub attempted_messages: Option<usize>,
58    /// Total attempts across pending messages when known.
59    pub total_attempts: Option<u64>,
60    /// Readiness timestamp of the ordered front message when known.
61    pub next_ready_at_ms: Option<u64>,
62}
63
64/// Ordered bounded queue that retains replication batches until acknowledgement.
65pub trait SyncOutbox: Send + Sync {
66    /// Enqueues a batch if the current length is below `max_len`.
67    fn try_enqueue(&self, message: SyncMessage, max_len: usize) -> SyncResult<bool>;
68    /// Returns the oldest pending batch.
69    fn front(&self) -> SyncResult<Option<SyncMessage>>;
70    /// Removes the oldest batch only when its identifier matches `batch_id`.
71    fn acknowledge_front(&self, batch_id: &str) -> SyncResult<()>;
72    /// Returns all pending batches in delivery order.
73    fn messages(&self) -> SyncResult<Vec<SyncMessage>>;
74    /// Returns the number of pending batches.
75    fn len(&self) -> SyncResult<usize>;
76    /// Returns a delivery-order page bounded before cloning message payloads.
77    ///
78    /// The compatibility default returns at most the front message. Providers
79    /// should override this method to offer real multi-message pagination.
80    fn peek(&self, limit: usize, max_bytes: usize) -> SyncResult<Vec<SyncMessage>> {
81        validate_page_limits(limit, max_bytes)?;
82        let Some(message) = self.front()? else {
83            return Ok(Vec::new());
84        };
85        if limit == 0 || max_bytes == 0 || encoded_message_bytes(&message)? > max_bytes {
86            return Ok(Vec::new());
87        }
88        Ok(vec![message])
89    }
90    /// Returns payload-free queue statistics.
91    ///
92    /// The compatibility default reports only the exact pending count and
93    /// leaves unavailable observations from pre-extension providers as `None`.
94    fn stats(&self) -> SyncResult<SyncOutboxStats> {
95        Ok(SyncOutboxStats {
96            pending_messages: self.len()?,
97            pending_bytes: None,
98            attempted_messages: None,
99            total_attempts: None,
100            next_ready_at_ms: None,
101        })
102    }
103    /// Records one failed delivery attempt and its next eligible timestamp.
104    ///
105    /// Providers without this extension fail explicitly instead of pretending
106    /// to persist retry state.
107    fn mark_attempt(&self, _batch_id: &str, _next_ready_at_ms: u64) -> SyncResult<u32> {
108        Err(SyncError::OutboxOperationUnsupported("mark_attempt"))
109    }
110    /// Returns the ready delivery-order prefix within both page bounds.
111    ///
112    /// The compatibility default preserves pre-extension immediate readiness
113    /// and returns at most one message.
114    fn next_ready(
115        &self,
116        _now_ms: u64,
117        limit: usize,
118        max_bytes: usize,
119    ) -> SyncResult<Vec<SyncMessage>> {
120        validate_page_limits(limit, max_bytes)?;
121        self.peek(limit.min(1), max_bytes)
122    }
123    /// Acknowledges the exact ordered prefix named by a partial receipt.
124    ///
125    /// The compatibility default accepts one identifier only. Providers should
126    /// override this method to apply a multi-message receipt atomically.
127    fn acknowledge_receipt(&self, receipt: &SyncOutboxReceipt) -> SyncResult<usize> {
128        let [batch_id] = receipt.batch_ids() else {
129            return Err(SyncError::OutboxOperationUnsupported(
130                "multi-message receipt",
131            ));
132        };
133        self.acknowledge_front(batch_id)?;
134        Ok(1)
135    }
136    /// Reports whether no batches are pending.
137    fn is_empty(&self) -> SyncResult<bool> {
138        Ok(self.len()? == 0)
139    }
140}
141
142#[derive(Debug, Default)]
143/// Process-local synchronization outbox.
144pub struct InMemorySyncOutbox {
145    messages: Mutex<VecDeque<PendingMessage>>,
146}
147
148#[derive(Debug)]
149struct PendingMessage {
150    message: SyncMessage,
151    encoded_bytes: usize,
152    attempts: u32,
153    next_ready_at_ms: u64,
154}
155
156impl InMemorySyncOutbox {
157    /// Creates an empty in-memory outbox.
158    pub fn new() -> Self {
159        Self::default()
160    }
161}
162
163impl SyncOutbox for InMemorySyncOutbox {
164    fn try_enqueue(&self, message: SyncMessage, max_len: usize) -> SyncResult<bool> {
165        let encoded_bytes = encoded_message_bytes(&message)?;
166        let mut messages = self.messages.lock();
167        if messages.len() >= max_len {
168            return Ok(false);
169        }
170        messages.push_back(PendingMessage {
171            message,
172            encoded_bytes,
173            attempts: 0,
174            next_ready_at_ms: 0,
175        });
176        Ok(true)
177    }
178
179    fn front(&self) -> SyncResult<Option<SyncMessage>> {
180        Ok(self
181            .messages
182            .lock()
183            .front()
184            .map(|pending| pending.message.clone()))
185    }
186
187    fn acknowledge_front(&self, batch_id: &str) -> SyncResult<()> {
188        let mut messages = self.messages.lock();
189        if messages
190            .front()
191            .map(|pending| pending.message.batch_id.as_str())
192            != Some(batch_id)
193        {
194            return Err(SyncError::InvalidSyncMessage(
195                "outbox acknowledgement mismatch",
196            ));
197        }
198        messages.pop_front();
199        Ok(())
200    }
201
202    fn messages(&self) -> SyncResult<Vec<SyncMessage>> {
203        Ok(self
204            .messages
205            .lock()
206            .iter()
207            .map(|pending| pending.message.clone())
208            .collect())
209    }
210
211    fn len(&self) -> SyncResult<usize> {
212        Ok(self.messages.lock().len())
213    }
214
215    fn peek(&self, limit: usize, max_bytes: usize) -> SyncResult<Vec<SyncMessage>> {
216        validate_page_limits(limit, max_bytes)?;
217        Ok(page(self.messages.lock().iter(), limit, max_bytes, None))
218    }
219
220    fn stats(&self) -> SyncResult<SyncOutboxStats> {
221        let messages = self.messages.lock();
222        let pending_bytes = messages.iter().try_fold(0usize, |total, pending| {
223            total
224                .checked_add(pending.encoded_bytes)
225                .ok_or(SyncError::InvalidSyncMessage("outbox byte overflow"))
226        })?;
227        let attempted_messages = messages
228            .iter()
229            .filter(|pending| pending.attempts > 0)
230            .count();
231        let total_attempts = messages.iter().try_fold(0u64, |total, pending| {
232            total
233                .checked_add(u64::from(pending.attempts))
234                .ok_or(SyncError::InvalidSyncMessage("outbox attempt overflow"))
235        })?;
236        Ok(SyncOutboxStats {
237            pending_messages: messages.len(),
238            pending_bytes: Some(pending_bytes),
239            attempted_messages: Some(attempted_messages),
240            total_attempts: Some(total_attempts),
241            next_ready_at_ms: messages.front().map(|pending| pending.next_ready_at_ms),
242        })
243    }
244
245    fn mark_attempt(&self, batch_id: &str, next_ready_at_ms: u64) -> SyncResult<u32> {
246        validate_outbox_batch_id(batch_id)?;
247        let mut messages = self.messages.lock();
248        let pending = messages
249            .front_mut()
250            .filter(|pending| pending.message.batch_id == batch_id)
251            .ok_or(SyncError::InvalidSyncMessage("outbox attempt mismatch"))?;
252        pending.attempts = pending
253            .attempts
254            .checked_add(1)
255            .ok_or(SyncError::InvalidSyncMessage("outbox attempt overflow"))?;
256        pending.next_ready_at_ms = next_ready_at_ms;
257        Ok(pending.attempts)
258    }
259
260    fn next_ready(
261        &self,
262        now_ms: u64,
263        limit: usize,
264        max_bytes: usize,
265    ) -> SyncResult<Vec<SyncMessage>> {
266        validate_page_limits(limit, max_bytes)?;
267        Ok(page(
268            self.messages.lock().iter(),
269            limit,
270            max_bytes,
271            Some(now_ms),
272        ))
273    }
274
275    fn acknowledge_receipt(&self, receipt: &SyncOutboxReceipt) -> SyncResult<usize> {
276        let mut messages = self.messages.lock();
277        validate_receipt_prefix(&messages, receipt)?;
278        for _ in receipt.batch_ids() {
279            messages.pop_front();
280        }
281        Ok(receipt.batch_ids().len())
282    }
283}
284
285fn page<'a>(
286    messages: impl Iterator<Item = &'a PendingMessage>,
287    limit: usize,
288    max_bytes: usize,
289    ready_at_ms: Option<u64>,
290) -> Vec<SyncMessage> {
291    let mut page = Vec::new();
292    let mut bytes = 0usize;
293    for pending in messages.take(limit) {
294        if ready_at_ms.is_some_and(|now| pending.next_ready_at_ms > now)
295            || bytes
296                .checked_add(pending.encoded_bytes)
297                .is_none_or(|total| total > max_bytes)
298        {
299            break;
300        }
301        bytes += pending.encoded_bytes;
302        page.push(pending.message.clone());
303    }
304    page
305}
306
307fn validate_receipt_prefix(
308    messages: &VecDeque<PendingMessage>,
309    receipt: &SyncOutboxReceipt,
310) -> SyncResult<()> {
311    if messages.len() < receipt.batch_ids().len()
312        || messages
313            .iter()
314            .zip(receipt.batch_ids())
315            .any(|(pending, batch_id)| pending.message.batch_id != *batch_id)
316    {
317        return Err(SyncError::InvalidSyncMessage(
318            "outbox acknowledgement mismatch",
319        ));
320    }
321    Ok(())
322}
323
324pub(crate) fn encoded_message_bytes(message: &SyncMessage) -> SyncResult<usize> {
325    serde_json::to_vec(message)
326        .map(|encoded| encoded.len())
327        .map_err(|_| SyncError::InvalidSyncMessage("outbox serialization failed"))
328}
329
330pub(crate) fn validate_page_limits(limit: usize, max_bytes: usize) -> SyncResult<()> {
331    if limit > MAX_OUTBOX_PAGE_MESSAGES || max_bytes > MAX_OUTBOX_PAGE_BYTES {
332        return Err(SyncError::InvalidSyncMessage("invalid outbox page limits"));
333    }
334    Ok(())
335}
336
337pub(crate) fn validate_outbox_batch_id(batch_id: &str) -> SyncResult<()> {
338    if batch_id.is_empty() || batch_id.len() > 1_024 || batch_id.chars().any(char::is_control) {
339        return Err(SyncError::InvalidSyncMessage("invalid outbox batch id"));
340    }
341    Ok(())
342}