use crate::sync::error::{SyncError, SyncResult};
use crate::sync::types::SyncMessage;
use parking_lot::Mutex;
use std::collections::VecDeque;
pub use crate::sync::outbox_journal::{FileSyncOutbox, SYNC_OUTBOX_FORMAT_V2};
pub const MAX_OUTBOX_PAGE_MESSAGES: usize = 1_024;
pub const MAX_OUTBOX_PAGE_BYTES: usize = 48 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncOutboxReceipt {
batch_ids: Vec<String>,
}
impl SyncOutboxReceipt {
pub fn new(batch_ids: Vec<String>) -> SyncResult<Self> {
if batch_ids.is_empty() || batch_ids.len() > MAX_OUTBOX_PAGE_MESSAGES {
return Err(SyncError::InvalidSyncMessage("invalid outbox receipt"));
}
for batch_id in &batch_ids {
validate_outbox_batch_id(batch_id)?;
}
Ok(Self { batch_ids })
}
pub fn batch_ids(&self) -> &[String] {
&self.batch_ids
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyncOutboxStats {
pub pending_messages: usize,
pub pending_bytes: Option<usize>,
pub attempted_messages: Option<usize>,
pub total_attempts: Option<u64>,
pub next_ready_at_ms: Option<u64>,
}
pub trait SyncOutbox: Send + Sync {
fn try_enqueue(&self, message: SyncMessage, max_len: usize) -> SyncResult<bool>;
fn front(&self) -> SyncResult<Option<SyncMessage>>;
fn acknowledge_front(&self, batch_id: &str) -> SyncResult<()>;
fn messages(&self) -> SyncResult<Vec<SyncMessage>>;
fn len(&self) -> SyncResult<usize>;
fn peek(&self, limit: usize, max_bytes: usize) -> SyncResult<Vec<SyncMessage>> {
validate_page_limits(limit, max_bytes)?;
let Some(message) = self.front()? else {
return Ok(Vec::new());
};
if limit == 0 || max_bytes == 0 || encoded_message_bytes(&message)? > max_bytes {
return Ok(Vec::new());
}
Ok(vec![message])
}
fn stats(&self) -> SyncResult<SyncOutboxStats> {
Ok(SyncOutboxStats {
pending_messages: self.len()?,
pending_bytes: None,
attempted_messages: None,
total_attempts: None,
next_ready_at_ms: None,
})
}
fn mark_attempt(&self, _batch_id: &str, _next_ready_at_ms: u64) -> SyncResult<u32> {
Err(SyncError::OutboxOperationUnsupported("mark_attempt"))
}
fn next_ready(
&self,
_now_ms: u64,
limit: usize,
max_bytes: usize,
) -> SyncResult<Vec<SyncMessage>> {
validate_page_limits(limit, max_bytes)?;
self.peek(limit.min(1), max_bytes)
}
fn acknowledge_receipt(&self, receipt: &SyncOutboxReceipt) -> SyncResult<usize> {
let [batch_id] = receipt.batch_ids() else {
return Err(SyncError::OutboxOperationUnsupported(
"multi-message receipt",
));
};
self.acknowledge_front(batch_id)?;
Ok(1)
}
fn is_empty(&self) -> SyncResult<bool> {
Ok(self.len()? == 0)
}
}
#[derive(Debug, Default)]
pub struct InMemorySyncOutbox {
messages: Mutex<VecDeque<PendingMessage>>,
}
#[derive(Debug)]
struct PendingMessage {
message: SyncMessage,
encoded_bytes: usize,
attempts: u32,
next_ready_at_ms: u64,
}
impl InMemorySyncOutbox {
pub fn new() -> Self {
Self::default()
}
}
impl SyncOutbox for InMemorySyncOutbox {
fn try_enqueue(&self, message: SyncMessage, max_len: usize) -> SyncResult<bool> {
let encoded_bytes = encoded_message_bytes(&message)?;
let mut messages = self.messages.lock();
if messages.len() >= max_len {
return Ok(false);
}
messages.push_back(PendingMessage {
message,
encoded_bytes,
attempts: 0,
next_ready_at_ms: 0,
});
Ok(true)
}
fn front(&self) -> SyncResult<Option<SyncMessage>> {
Ok(self
.messages
.lock()
.front()
.map(|pending| pending.message.clone()))
}
fn acknowledge_front(&self, batch_id: &str) -> SyncResult<()> {
let mut messages = self.messages.lock();
if messages
.front()
.map(|pending| pending.message.batch_id.as_str())
!= Some(batch_id)
{
return Err(SyncError::InvalidSyncMessage(
"outbox acknowledgement mismatch",
));
}
messages.pop_front();
Ok(())
}
fn messages(&self) -> SyncResult<Vec<SyncMessage>> {
Ok(self
.messages
.lock()
.iter()
.map(|pending| pending.message.clone())
.collect())
}
fn len(&self) -> SyncResult<usize> {
Ok(self.messages.lock().len())
}
fn peek(&self, limit: usize, max_bytes: usize) -> SyncResult<Vec<SyncMessage>> {
validate_page_limits(limit, max_bytes)?;
Ok(page(self.messages.lock().iter(), limit, max_bytes, None))
}
fn stats(&self) -> SyncResult<SyncOutboxStats> {
let messages = self.messages.lock();
let pending_bytes = messages.iter().try_fold(0usize, |total, pending| {
total
.checked_add(pending.encoded_bytes)
.ok_or(SyncError::InvalidSyncMessage("outbox byte overflow"))
})?;
let attempted_messages = messages
.iter()
.filter(|pending| pending.attempts > 0)
.count();
let total_attempts = messages.iter().try_fold(0u64, |total, pending| {
total
.checked_add(u64::from(pending.attempts))
.ok_or(SyncError::InvalidSyncMessage("outbox attempt overflow"))
})?;
Ok(SyncOutboxStats {
pending_messages: messages.len(),
pending_bytes: Some(pending_bytes),
attempted_messages: Some(attempted_messages),
total_attempts: Some(total_attempts),
next_ready_at_ms: messages.front().map(|pending| pending.next_ready_at_ms),
})
}
fn mark_attempt(&self, batch_id: &str, next_ready_at_ms: u64) -> SyncResult<u32> {
validate_outbox_batch_id(batch_id)?;
let mut messages = self.messages.lock();
let pending = messages
.front_mut()
.filter(|pending| pending.message.batch_id == batch_id)
.ok_or(SyncError::InvalidSyncMessage("outbox attempt mismatch"))?;
pending.attempts = pending
.attempts
.checked_add(1)
.ok_or(SyncError::InvalidSyncMessage("outbox attempt overflow"))?;
pending.next_ready_at_ms = next_ready_at_ms;
Ok(pending.attempts)
}
fn next_ready(
&self,
now_ms: u64,
limit: usize,
max_bytes: usize,
) -> SyncResult<Vec<SyncMessage>> {
validate_page_limits(limit, max_bytes)?;
Ok(page(
self.messages.lock().iter(),
limit,
max_bytes,
Some(now_ms),
))
}
fn acknowledge_receipt(&self, receipt: &SyncOutboxReceipt) -> SyncResult<usize> {
let mut messages = self.messages.lock();
validate_receipt_prefix(&messages, receipt)?;
for _ in receipt.batch_ids() {
messages.pop_front();
}
Ok(receipt.batch_ids().len())
}
}
fn page<'a>(
messages: impl Iterator<Item = &'a PendingMessage>,
limit: usize,
max_bytes: usize,
ready_at_ms: Option<u64>,
) -> Vec<SyncMessage> {
let mut page = Vec::new();
let mut bytes = 0usize;
for pending in messages.take(limit) {
if ready_at_ms.is_some_and(|now| pending.next_ready_at_ms > now)
|| bytes
.checked_add(pending.encoded_bytes)
.is_none_or(|total| total > max_bytes)
{
break;
}
bytes += pending.encoded_bytes;
page.push(pending.message.clone());
}
page
}
fn validate_receipt_prefix(
messages: &VecDeque<PendingMessage>,
receipt: &SyncOutboxReceipt,
) -> SyncResult<()> {
if messages.len() < receipt.batch_ids().len()
|| messages
.iter()
.zip(receipt.batch_ids())
.any(|(pending, batch_id)| pending.message.batch_id != *batch_id)
{
return Err(SyncError::InvalidSyncMessage(
"outbox acknowledgement mismatch",
));
}
Ok(())
}
pub(crate) fn encoded_message_bytes(message: &SyncMessage) -> SyncResult<usize> {
serde_json::to_vec(message)
.map(|encoded| encoded.len())
.map_err(|_| SyncError::InvalidSyncMessage("outbox serialization failed"))
}
pub(crate) fn validate_page_limits(limit: usize, max_bytes: usize) -> SyncResult<()> {
if limit > MAX_OUTBOX_PAGE_MESSAGES || max_bytes > MAX_OUTBOX_PAGE_BYTES {
return Err(SyncError::InvalidSyncMessage("invalid outbox page limits"));
}
Ok(())
}
pub(crate) fn validate_outbox_batch_id(batch_id: &str) -> SyncResult<()> {
if batch_id.is_empty() || batch_id.len() > 1_024 || batch_id.chars().any(char::is_control) {
return Err(SyncError::InvalidSyncMessage("invalid outbox batch id"));
}
Ok(())
}