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