1use 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#[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}