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