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(&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}