Skip to main content

appcore_sync_sqlite/
outbox.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: outbox.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/26 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/26 00:00:00 by dnettoRaw
8//      ###########      S: 2.0.0
9// =============================================================================
10
11//! Defines bounded outbox contracts and behavior for this crate.
12
13use crate::outbox_blob::{
14    encoded_message_bytes, insert_message_blob, message_blob_matches, read_message_blob,
15};
16use crate::{SqliteSyncError, SqliteSyncStore};
17use appcore_sync::{
18    SyncError, SyncMessage, SyncOutbox, SyncOutboxReceipt, SyncOutboxStats, SyncResult,
19    MAX_OUTBOX_PAGE_BYTES, MAX_OUTBOX_PAGE_MESSAGES,
20};
21use rusqlite::{params, OptionalExtension, TransactionBehavior};
22
23const MAX_BATCH_ID_BYTES: usize = 1_024;
24
25/// SQLite-backed bounded synchronization outbox.
26#[derive(Debug, Clone)]
27pub struct SqliteSyncOutbox {
28    store: SqliteSyncStore,
29}
30
31impl SqliteSyncOutbox {
32    pub(crate) fn new(store: SqliteSyncStore) -> Self {
33        Self { store }
34    }
35
36    fn read_messages(&self, limit: usize) -> SyncResult<Vec<SyncMessage>> {
37        self.store
38            .with_connection(|connection| {
39                let limit = i64::try_from(limit)
40                    .map_err(|_| SqliteSyncError::CapacityExceeded("outbox"))?;
41                let mut statement = connection
42                    .prepare(
43                        "SELECT position, length(encoded) FROM appcore_sync_outbox
44                         ORDER BY position LIMIT ?1",
45                    )
46                    .map_err(SqliteSyncError::database)?;
47                let rows = statement
48                    .query_map([limit], |row| {
49                        Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
50                    })
51                    .map_err(SqliteSyncError::database)?;
52                let mut messages = Vec::new();
53                let mut bytes = 0usize;
54                for row in rows {
55                    let (position, encoded_bytes) = row.map_err(SqliteSyncError::database)?;
56                    let encoded_bytes = checked_usize(encoded_bytes, "outbox byte")?;
57                    if encoded_bytes > self.store.config().max_outbox_record_bytes {
58                        return Err(SqliteSyncError::CapacityExceeded("outbox record"));
59                    }
60                    bytes = bytes
61                        .checked_add(encoded_bytes)
62                        .ok_or(SqliteSyncError::CapacityExceeded("outbox byte"))?;
63                    if bytes as u64 > self.store.config().max_database_bytes {
64                        return Err(SqliteSyncError::CapacityExceeded("outbox byte"));
65                    }
66                    messages.push(read_message_blob(connection, position, encoded_bytes)?);
67                }
68                Ok(messages)
69            })
70            .map_err(SqliteSyncError::sync)
71    }
72
73    fn read_page(
74        &self,
75        limit: usize,
76        max_bytes: usize,
77        ready_at_ms: Option<u64>,
78    ) -> SyncResult<Vec<SyncMessage>> {
79        validate_page_limits(limit, max_bytes)?;
80        self.store
81            .with_connection(|connection| {
82                let limit = limit.min(self.store.config().max_outbox_entries);
83                let sql_limit = i64::try_from(limit)
84                    .map_err(|_| SqliteSyncError::CapacityExceeded("outbox page"))?;
85                let mut statement = connection
86                    .prepare(
87                        "SELECT position, length(encoded), next_ready_at_ms
88                         FROM appcore_sync_outbox ORDER BY position LIMIT ?1",
89                    )
90                    .map_err(SqliteSyncError::database)?;
91                let rows = statement
92                    .query_map([sql_limit], |row| {
93                        Ok((
94                            row.get::<_, i64>(0)?,
95                            row.get::<_, i64>(1)?,
96                            row.get::<_, i64>(2)?,
97                        ))
98                    })
99                    .map_err(SqliteSyncError::database)?;
100                let mut selected = 0usize;
101                let mut selected_bytes = 0usize;
102                let mut last_position = None;
103                for row in rows {
104                    let (position, encoded_bytes, next_ready) =
105                        row.map_err(SqliteSyncError::database)?;
106                    let encoded_bytes = usize::try_from(encoded_bytes)
107                        .map_err(|_| SqliteSyncError::CorruptRecord("outbox byte"))?;
108                    if ready_at_ms.is_some_and(|now| {
109                        u64::try_from(next_ready).map_or(true, |ready| ready > now)
110                    }) || selected_bytes
111                        .checked_add(encoded_bytes)
112                        .is_none_or(|bytes| bytes > max_bytes)
113                    {
114                        break;
115                    }
116                    selected_bytes += encoded_bytes;
117                    selected += 1;
118                    last_position = Some(position);
119                }
120                drop(statement);
121                let Some(last_position) = last_position else {
122                    return Ok(Vec::new());
123                };
124                read_selected_messages(
125                    connection,
126                    last_position,
127                    selected,
128                    selected_bytes,
129                    self.store.config().max_outbox_record_bytes,
130                )
131            })
132            .map_err(SqliteSyncError::sync)
133    }
134}
135
136impl SyncOutbox for SqliteSyncOutbox {
137    fn try_enqueue(&self, message: SyncMessage, max_len: usize) -> SyncResult<bool> {
138        validate_batch_id(&message.batch_id)?;
139        let encoded_bytes =
140            encoded_message_bytes(&message, self.store.config().max_outbox_record_bytes)?;
141        let limit = max_len.min(self.store.config().max_outbox_entries);
142        self.store
143            .with_connection(|connection| {
144                let transaction = connection
145                    .transaction_with_behavior(TransactionBehavior::Immediate)
146                    .map_err(SqliteSyncError::database)?;
147                let inserted =
148                    enqueue_in_transaction(&transaction, &message, encoded_bytes, limit)?;
149                transaction.commit().map_err(SqliteSyncError::database)?;
150                Ok(inserted)
151            })
152            .map_err(|error| match error {
153                SqliteSyncError::CorruptRecord("outbox conflict") => {
154                    SyncError::InvalidSyncMessage("outbox batch conflict")
155                }
156                other => other.sync(),
157            })
158    }
159
160    fn front(&self) -> SyncResult<Option<SyncMessage>> {
161        Ok(self.read_messages(1)?.into_iter().next())
162    }
163
164    fn acknowledge_front(&self, batch_id: &str) -> SyncResult<()> {
165        let receipt = SyncOutboxReceipt::new(vec![batch_id.to_string()])?;
166        self.acknowledge_receipt(&receipt).map(|_| ())
167    }
168
169    fn messages(&self) -> SyncResult<Vec<SyncMessage>> {
170        let length = self.len()?;
171        if length > self.store.config().max_outbox_entries {
172            return Err(SqliteSyncError::CapacityExceeded("outbox").sync());
173        }
174        self.read_messages(length)
175    }
176
177    fn len(&self) -> SyncResult<usize> {
178        self.store
179            .with_connection(|connection| count_entries(connection))
180            .map_err(SqliteSyncError::sync)
181    }
182
183    fn peek(&self, limit: usize, max_bytes: usize) -> SyncResult<Vec<SyncMessage>> {
184        self.read_page(limit, max_bytes, None)
185    }
186
187    fn stats(&self) -> SyncResult<SyncOutboxStats> {
188        self.store
189            .with_connection(|connection| {
190                let values: (i64, i64, i64, i64, Option<i64>) = connection
191                    .query_row(
192                        "SELECT COUNT(*), COALESCE(SUM(length(encoded)), 0),
193                                COALESCE(SUM(CASE WHEN attempts > 0 THEN 1 ELSE 0 END), 0),
194                                COALESCE(SUM(attempts), 0),
195                                (SELECT next_ready_at_ms FROM appcore_sync_outbox
196                                 ORDER BY position LIMIT 1)
197                         FROM appcore_sync_outbox",
198                        [],
199                        |row| {
200                            Ok((
201                                row.get(0)?,
202                                row.get(1)?,
203                                row.get(2)?,
204                                row.get(3)?,
205                                row.get(4)?,
206                            ))
207                        },
208                    )
209                    .map_err(SqliteSyncError::database)?;
210                Ok(SyncOutboxStats {
211                    pending_messages: checked_usize(values.0, "outbox count")?,
212                    pending_bytes: Some(checked_usize(values.1, "outbox byte")?),
213                    attempted_messages: Some(checked_usize(values.2, "outbox attempt")?),
214                    total_attempts: Some(
215                        u64::try_from(values.3)
216                            .map_err(|_| SqliteSyncError::CorruptRecord("outbox attempt"))?,
217                    ),
218                    next_ready_at_ms: values
219                        .4
220                        .map(|value| {
221                            u64::try_from(value)
222                                .map_err(|_| SqliteSyncError::CorruptRecord("outbox readiness"))
223                        })
224                        .transpose()?,
225                })
226            })
227            .map_err(SqliteSyncError::sync)
228    }
229
230    fn mark_attempt(&self, batch_id: &str, next_ready_at_ms: u64) -> SyncResult<u32> {
231        validate_batch_id(batch_id)?;
232        let next_ready_at_ms = i64::try_from(next_ready_at_ms)
233            .map_err(|_| SyncError::InvalidSyncMessage("outbox readiness overflow"))?;
234        self.store
235            .with_connection(|connection| {
236                let transaction = connection
237                    .transaction_with_behavior(TransactionBehavior::Immediate)
238                    .map_err(SqliteSyncError::database)?;
239                let front: Option<(i64, String, i64)> = transaction
240                    .query_row(
241                        "SELECT position, batch_id, attempts FROM appcore_sync_outbox
242                         ORDER BY position LIMIT 1",
243                        [],
244                        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
245                    )
246                    .optional()
247                    .map_err(SqliteSyncError::database)?;
248                let Some((position, current_id, attempts)) = front else {
249                    return Err(SqliteSyncError::CorruptRecord("outbox attempt"));
250                };
251                if current_id != batch_id {
252                    return Err(SqliteSyncError::CorruptRecord("outbox attempt"));
253                }
254                let attempts = attempts
255                    .checked_add(1)
256                    .and_then(|value| u32::try_from(value).ok())
257                    .ok_or(SqliteSyncError::CapacityExceeded("outbox attempt"))?;
258                transaction
259                    .execute(
260                        "UPDATE appcore_sync_outbox
261                         SET attempts = ?1, next_ready_at_ms = ?2 WHERE position = ?3",
262                        params![attempts, next_ready_at_ms, position],
263                    )
264                    .map_err(SqliteSyncError::database)?;
265                transaction.commit().map_err(SqliteSyncError::database)?;
266                Ok(attempts)
267            })
268            .map_err(|error| match error {
269                SqliteSyncError::CorruptRecord("outbox attempt") => {
270                    SyncError::InvalidSyncMessage("outbox attempt mismatch")
271                }
272                other => other.sync(),
273            })
274    }
275
276    fn next_ready(
277        &self,
278        now_ms: u64,
279        limit: usize,
280        max_bytes: usize,
281    ) -> SyncResult<Vec<SyncMessage>> {
282        self.read_page(limit, max_bytes, Some(now_ms))
283    }
284
285    fn acknowledge_receipt(&self, receipt: &SyncOutboxReceipt) -> SyncResult<usize> {
286        self.store
287            .with_connection(|connection| {
288                let transaction = connection
289                    .transaction_with_behavior(TransactionBehavior::Immediate)
290                    .map_err(SqliteSyncError::database)?;
291                let removed = acknowledge_in_transaction(&transaction, receipt)?;
292                transaction.commit().map_err(SqliteSyncError::database)?;
293                Ok(removed)
294            })
295            .map_err(|error| match error {
296                SqliteSyncError::CorruptRecord("outbox acknowledgement") => {
297                    SyncError::InvalidSyncMessage("outbox acknowledgement mismatch")
298                }
299                other => other.sync(),
300            })
301    }
302}
303
304fn read_selected_messages(
305    connection: &rusqlite::Connection,
306    last_position: i64,
307    selected: usize,
308    selected_bytes: usize,
309    max_record_bytes: usize,
310) -> Result<Vec<SyncMessage>, SqliteSyncError> {
311    let limit =
312        i64::try_from(selected).map_err(|_| SqliteSyncError::CapacityExceeded("outbox page"))?;
313    let mut statement = connection
314        .prepare(
315            "SELECT position, length(encoded) FROM appcore_sync_outbox
316             WHERE position <= ?1 ORDER BY position LIMIT ?2",
317        )
318        .map_err(SqliteSyncError::database)?;
319    let rows = statement
320        .query_map(params![last_position, limit], |row| {
321            Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
322        })
323        .map_err(SqliteSyncError::database)?;
324    let mut messages = Vec::with_capacity(selected);
325    let mut actual_bytes = 0usize;
326    for row in rows {
327        let (position, encoded_bytes) = row.map_err(SqliteSyncError::database)?;
328        let encoded_bytes = checked_usize(encoded_bytes, "outbox byte")?;
329        if encoded_bytes > max_record_bytes {
330            return Err(SqliteSyncError::CapacityExceeded("outbox record"));
331        }
332        actual_bytes = actual_bytes
333            .checked_add(encoded_bytes)
334            .ok_or(SqliteSyncError::CapacityExceeded("outbox byte"))?;
335        messages.push(read_message_blob(connection, position, encoded_bytes)?);
336    }
337    if messages.len() != selected || actual_bytes != selected_bytes {
338        return Err(SqliteSyncError::CorruptRecord("outbox page"));
339    }
340    Ok(messages)
341}
342
343fn validate_page_limits(limit: usize, max_bytes: usize) -> SyncResult<()> {
344    if limit > MAX_OUTBOX_PAGE_MESSAGES || max_bytes > MAX_OUTBOX_PAGE_BYTES {
345        return Err(SyncError::InvalidSyncMessage("invalid outbox page limits"));
346    }
347    Ok(())
348}
349
350fn checked_usize(value: i64, reason: &'static str) -> Result<usize, SqliteSyncError> {
351    usize::try_from(value).map_err(|_| SqliteSyncError::CorruptRecord(reason))
352}
353
354fn count_entries(connection: &rusqlite::Connection) -> Result<usize, SqliteSyncError> {
355    let count: i64 = connection
356        .query_row("SELECT COUNT(*) FROM appcore_sync_outbox", [], |row| {
357            row.get(0)
358        })
359        .map_err(SqliteSyncError::database)?;
360    usize::try_from(count).map_err(|_| SqliteSyncError::CorruptRecord("outbox count"))
361}
362
363pub(crate) fn validate_batch_id(batch_id: &str) -> SyncResult<()> {
364    if batch_id.is_empty()
365        || batch_id.len() > MAX_BATCH_ID_BYTES
366        || batch_id.chars().any(char::is_control)
367    {
368        return Err(SyncError::InvalidSyncMessage("invalid outbox batch id"));
369    }
370    Ok(())
371}
372
373pub(crate) fn enqueue_in_transaction(
374    transaction: &rusqlite::Transaction<'_>,
375    message: &SyncMessage,
376    encoded_bytes: usize,
377    limit: usize,
378) -> crate::SqliteSyncResult<bool> {
379    let existing: Option<(i64, i64)> = transaction
380        .query_row(
381            "SELECT position, length(encoded) FROM appcore_sync_outbox
382                         WHERE batch_id = ?1",
383            [&message.batch_id],
384            |row| Ok((row.get(0)?, row.get(1)?)),
385        )
386        .optional()
387        .map_err(SqliteSyncError::database)?;
388    if let Some((position, existing_bytes)) = existing {
389        let existing_bytes = usize::try_from(existing_bytes)
390            .map_err(|_| SqliteSyncError::CorruptRecord("outbox byte"))?;
391        return if existing_bytes == encoded_bytes
392            && message_blob_matches(transaction, position, message, encoded_bytes)?
393        {
394            Ok(false)
395        } else {
396            Err(SqliteSyncError::CorruptRecord("outbox conflict"))
397        };
398    }
399    let count = count_entries(transaction)?;
400    if count >= limit {
401        return Ok(false);
402    }
403    insert_message_blob(transaction, message, encoded_bytes)?;
404    Ok(true)
405}
406
407pub(crate) fn acknowledge_in_transaction(
408    transaction: &rusqlite::Transaction<'_>,
409    receipt: &SyncOutboxReceipt,
410) -> crate::SqliteSyncResult<usize> {
411    let limit = i64::try_from(receipt.batch_ids().len())
412        .map_err(|_| SqliteSyncError::CapacityExceeded("outbox receipt"))?;
413    let mut statement = transaction
414        .prepare(
415            "SELECT position, batch_id FROM appcore_sync_outbox
416                         ORDER BY position LIMIT ?1",
417        )
418        .map_err(SqliteSyncError::database)?;
419    let rows = statement
420        .query_map([limit], |row| {
421            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
422        })
423        .map_err(SqliteSyncError::database)?;
424    let selected = rows
425        .collect::<Result<Vec<_>, _>>()
426        .map_err(SqliteSyncError::database)?;
427    if selected.len() != receipt.batch_ids().len()
428        || selected
429            .iter()
430            .zip(receipt.batch_ids())
431            .any(|((_, current), expected)| current != expected)
432    {
433        return Err(SqliteSyncError::CorruptRecord("outbox acknowledgement"));
434    }
435    let last_position = selected
436        .last()
437        .map(|(position, _)| *position)
438        .ok_or(SqliteSyncError::CorruptRecord("outbox acknowledgement"))?;
439    drop(statement);
440    let removed = transaction
441        .execute(
442            "DELETE FROM appcore_sync_outbox WHERE position <= ?1",
443            [last_position],
444        )
445        .map_err(SqliteSyncError::database)?;
446    if removed != receipt.batch_ids().len() {
447        return Err(SqliteSyncError::CorruptRecord("outbox acknowledgement"));
448    }
449    Ok(removed)
450}