Skip to main content

force_sync/capture/
postgres.rs

1//! `PostgreSQL` outbox capture for force-sync.
2
3use futures::FutureExt;
4use serde_json::Value;
5use tokio_postgres::GenericClient;
6
7use crate::{
8    error::ForceSyncError,
9    identity::SyncKey,
10    model::{ChangeEnvelope, ChangeOperation, SourceCursor, SourceSystem},
11    store::pg::{AppendResult, DeadLetter, PgStore},
12};
13
14struct OutboxRow {
15    outbox_id: i64,
16    tenant: String,
17    object_name: String,
18    external_id: String,
19    source_cursor: String,
20    op: String,
21    tombstone: bool,
22    payload_text: String,
23    created_at: chrono::DateTime<chrono::Utc>,
24}
25
26fn outbox_operation(op: &str, tombstone: bool) -> Result<ChangeOperation, ForceSyncError> {
27    match (op, tombstone) {
28        ("upsert", false) => Ok(ChangeOperation::Upsert),
29        ("delete", true) => Ok(ChangeOperation::Delete),
30        ("upsert", true) | ("delete", false) => Err(ForceSyncError::InvalidOutboxOperation {
31            op: format!("{op}:{tombstone}"),
32        }),
33        (other_op, _) => Err(ForceSyncError::InvalidOutboxOperation {
34            op: other_op.to_string(),
35        }),
36    }
37}
38
39fn outbox_source_cursor(raw: &str) -> Result<SourceCursor, ForceSyncError> {
40    if raw.starts_with("postgres-lsn:")
41        || raw.starts_with("salesforce-replay-id:")
42        || raw.starts_with("snapshot:")
43    {
44        return Err(ForceSyncError::InvalidOutboxCursor {
45            cursor: raw.to_string(),
46        });
47    }
48
49    Ok(SourceCursor::PostgresLsn(raw.to_string()))
50}
51
52fn outbox_envelope(row: &OutboxRow) -> Result<ChangeEnvelope, ForceSyncError> {
53    let payload: Value = serde_json::from_str(&row.payload_text)?;
54    let sync_key = SyncKey::new(
55        row.tenant.clone(),
56        row.object_name.clone(),
57        row.external_id.clone(),
58    )?;
59    let operation = outbox_operation(&row.op, row.tombstone)?;
60
61    Ok(ChangeEnvelope::new(
62        sync_key,
63        SourceSystem::Postgres,
64        operation,
65        row.created_at,
66        payload,
67    )
68    .with_cursor(outbox_source_cursor(&row.source_cursor)?))
69}
70
71const fn row_content_error(error: &ForceSyncError) -> bool {
72    matches!(
73        error,
74        ForceSyncError::Json(_)
75            | ForceSyncError::InvalidOutboxOperation { .. }
76            | ForceSyncError::InvalidOutboxCursor { .. }
77            | ForceSyncError::EmptySyncKeyPart { .. }
78    )
79}
80
81async fn quarantine_row<C>(
82    client: &C,
83    row: &OutboxRow,
84    error: &ForceSyncError,
85) -> Result<(), ForceSyncError>
86where
87    C: GenericClient + Sync + ?Sized,
88{
89    let payload = serde_json::from_str::<Value>(&row.payload_text).ok();
90    let dead_letter = DeadLetter {
91        task_id: None,
92        tenant: Some(row.tenant.clone()),
93        object_name: Some(row.object_name.clone()),
94        external_id: Some(row.external_id.clone()),
95        error_message: error.to_string(),
96        payload,
97    };
98
99    crate::store::pg::dead_letter::insert_dead_letter_in_tx(client, &dead_letter).await?;
100    client
101        .execute(
102            "update force_sync_outbox
103             set processed_at = now()
104             where outbox_id = $1
105               and processed_at is null",
106            &[&row.outbox_id],
107        )
108        .await?;
109    Ok(())
110}
111
112async fn capture_batch_in_tx<C>(
113    client: &C,
114    limit: i64,
115    priority: i32,
116) -> Result<usize, ForceSyncError>
117where
118    C: GenericClient + Sync + ?Sized,
119{
120    if limit <= 0 {
121        return Ok(0usize);
122    }
123
124    let rows = client
125        .query(
126            "select outbox_id, tenant, object_name, external_id, source_cursor, op, tombstone, payload::text as payload_text, created_at
127             from force_sync_outbox
128             where processed_at is null
129             order by created_at asc, outbox_id asc
130             for update skip locked
131             limit $1",
132            &[&limit],
133        )
134        .await?;
135
136    let mut processed = 0usize;
137
138    for row in rows {
139        let outbox_row = OutboxRow {
140            outbox_id: row.get("outbox_id"),
141            tenant: row.get("tenant"),
142            object_name: row.get("object_name"),
143            external_id: row.get("external_id"),
144            source_cursor: row.get("source_cursor"),
145            op: row.get("op"),
146            tombstone: row.get("tombstone"),
147            payload_text: row.get("payload_text"),
148            created_at: row.get("created_at"),
149        };
150
151        match outbox_envelope(&outbox_row) {
152            Ok(envelope) => match PgStore::append_journal_if_new_in_tx(client, &envelope).await? {
153                AppendResult::Inserted { journal_id } => {
154                    PgStore::enqueue_apply_task_in_tx(client, journal_id, priority).await?;
155                    client
156                        .execute(
157                            "update force_sync_outbox
158                             set processed_at = now()
159                             where outbox_id = $1
160                               and processed_at is null",
161                            &[&outbox_row.outbox_id],
162                        )
163                        .await?;
164                    processed = processed.saturating_add(1);
165                }
166                AppendResult::Duplicate => {
167                    client
168                        .execute(
169                            "update force_sync_outbox
170                             set processed_at = now()
171                             where outbox_id = $1
172                               and processed_at is null",
173                            &[&outbox_row.outbox_id],
174                        )
175                        .await?;
176                    processed = processed.saturating_add(1);
177                }
178            },
179            Err(error) if row_content_error(&error) => {
180                let _ = quarantine_row(client, &outbox_row, &error).await;
181                processed = processed.saturating_add(1);
182            }
183            Err(error) => return Err(error),
184        }
185    }
186
187    Ok(processed)
188}
189
190/// Captures a batch of unprocessed `PostgreSQL` outbox rows into the sync journal.
191///
192/// # Errors
193///
194/// Returns a database error if the transaction cannot be opened or a row
195/// cannot be converted into a sync envelope.
196pub async fn capture_batch(
197    store: &PgStore,
198    limit: i64,
199    priority: i32,
200) -> Result<usize, ForceSyncError> {
201    if limit <= 0 {
202        return Ok(0);
203    }
204    store
205        .with_transaction(|tx| {
206            async move { capture_batch_in_tx(tx, limit, priority).await }.boxed()
207        })
208        .await
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::model::{ChangeOperation, SourceCursor};
215
216    // ── outbox_operation ──────────────────────────────────────────────
217
218    #[test]
219    fn outbox_operation_upsert_not_tombstone() {
220        let Ok(op) = outbox_operation("upsert", false) else {
221            panic!("expected Ok for upsert/false");
222        };
223        assert_eq!(op, ChangeOperation::Upsert);
224    }
225
226    #[test]
227    fn outbox_operation_delete_tombstone() {
228        let Ok(op) = outbox_operation("delete", true) else {
229            panic!("expected Ok for delete/true");
230        };
231        assert_eq!(op, ChangeOperation::Delete);
232    }
233
234    #[test]
235    fn outbox_operation_upsert_tombstone_is_invalid() {
236        assert!(matches!(
237            outbox_operation("upsert", true),
238            Err(ForceSyncError::InvalidOutboxOperation { .. })
239        ));
240    }
241
242    #[test]
243    fn outbox_operation_delete_not_tombstone_is_invalid() {
244        assert!(matches!(
245            outbox_operation("delete", false),
246            Err(ForceSyncError::InvalidOutboxOperation { .. })
247        ));
248    }
249
250    #[test]
251    fn outbox_operation_unknown_op_is_invalid() {
252        assert!(matches!(
253            outbox_operation("insert", false),
254            Err(ForceSyncError::InvalidOutboxOperation { .. })
255        ));
256    }
257
258    // ── outbox_source_cursor ──────────────────────────────────────────
259
260    #[test]
261    fn outbox_source_cursor_plain_lsn_succeeds() {
262        let Ok(cursor) = outbox_source_cursor("0/16B3740") else {
263            panic!("expected Ok for plain LSN");
264        };
265        assert!(matches!(cursor, SourceCursor::PostgresLsn(ref lsn) if lsn == "0/16B3740"));
266    }
267
268    #[test]
269    fn outbox_source_cursor_rejects_postgres_lsn_prefix() {
270        assert!(matches!(
271            outbox_source_cursor("postgres-lsn:0/16B3740"),
272            Err(ForceSyncError::InvalidOutboxCursor { .. })
273        ));
274    }
275
276    #[test]
277    fn outbox_source_cursor_rejects_salesforce_replay_id_prefix() {
278        assert!(matches!(
279            outbox_source_cursor("salesforce-replay-id:42"),
280            Err(ForceSyncError::InvalidOutboxCursor { .. })
281        ));
282    }
283
284    #[test]
285    fn outbox_source_cursor_rejects_snapshot_prefix() {
286        assert!(matches!(
287            outbox_source_cursor("snapshot:abc"),
288            Err(ForceSyncError::InvalidOutboxCursor { .. })
289        ));
290    }
291
292    // ── row_content_error ─────────────────────────────────────────────
293
294    #[test]
295    fn row_content_error_recognizes_json_error() {
296        let Err(json_err) = serde_json::from_str::<Value>("{{invalid}}") else {
297            panic!("expected invalid JSON to fail");
298        };
299        let err: ForceSyncError = json_err.into();
300        assert!(row_content_error(&err));
301    }
302
303    #[test]
304    fn row_content_error_recognizes_invalid_outbox_operation() {
305        let err = ForceSyncError::InvalidOutboxOperation {
306            op: "bad".to_string(),
307        };
308        assert!(row_content_error(&err));
309    }
310
311    #[test]
312    fn row_content_error_recognizes_invalid_outbox_cursor() {
313        let err = ForceSyncError::InvalidOutboxCursor {
314            cursor: "bad".to_string(),
315        };
316        assert!(row_content_error(&err));
317    }
318
319    #[test]
320    fn row_content_error_recognizes_empty_sync_key_part() {
321        let err = ForceSyncError::EmptySyncKeyPart { part: "tenant" };
322        assert!(row_content_error(&err));
323    }
324
325    #[test]
326    fn row_content_error_rejects_missing_source_cursor() {
327        let err = ForceSyncError::MissingSourceCursor;
328        assert!(!row_content_error(&err));
329    }
330}