Skip to main content

linkrs_sync/manager/
outbox_admin.rs

1//! Durable outbox administration: configuration, snapshots, dead letters,
2//! degraded ranges and retention.
3use super::*;
4use crate::checkpoint_manifest::CheckpointManifestManager;
5use crate::sqlite_outbox::OutboxSnapshot;
6use linkrs_core::types::CommitLsn;
7use std::path::Path;
8fn latest_manifest_outbox_snapshot(work_dir: &Path) -> Result<Option<OutboxSnapshot>, String> {
9    let manifest_manager =
10        CheckpointManifestManager::new(work_dir.join("checkpoint").join("manifests"));
11    let Some(manifest) = manifest_manager.load_latest()? else {
12        return Ok(None);
13    };
14    Ok(manifest.outbox_snapshot.map(|snapshot| OutboxSnapshot {
15        path: snapshot.path,
16        size_bytes: snapshot.size_bytes,
17        checksum: snapshot.checksum,
18        materialized_lsn: snapshot.materialized_lsn,
19    }))
20}
21fn restore_outbox_from_candidates(
22    live_path: &Path,
23    snapshot_dir: &Path,
24    preferred_snapshot: Option<&OutboxSnapshot>,
25) -> Result<Option<CommitLsn>, String> {
26    let mut preferred_error = None;
27    if let Some(snapshot) = preferred_snapshot {
28        match crate::restore_snapshot_sync(snapshot, live_path) {
29            Ok(()) => return Ok(Some(snapshot.materialized_lsn)),
30            Err(error) => {
31                log::warn!(
32                    "Failed to restore manifest-referenced outbox snapshot {}: {}",
33                    snapshot.path.display(),
34                    error
35                );
36                preferred_error = Some(error);
37            }
38        }
39    }
40
41    if snapshot_dir.is_dir() {
42        return crate::restore_latest_snapshot(live_path, snapshot_dir).map(Some);
43    }
44
45    preferred_error.map_or(Ok(None), Err)
46}
47#[cfg_attr(
48    not(any(feature = "fulltext", feature = "vector")),
49    allow(unused_variables)
50)]
51impl super::SyncManager {
52    pub fn configure_outbox(&mut self, path: impl AsRef<std::path::Path>) -> Result<(), SyncError> {
53        let path = path.as_ref();
54        let sqlite_path = if path
55            .extension()
56            .is_some_and(|extension| extension == "sqlite")
57        {
58            path.to_path_buf()
59        } else {
60            path.with_extension("sqlite")
61        };
62
63        let database_parent = sqlite_path.parent().unwrap_or(Path::new("."));
64        let snapshot_dir = database_parent
65            .parent()
66            .unwrap_or(database_parent)
67            .join("outbox_snapshots");
68        let work_dir = database_parent.parent().unwrap_or(database_parent);
69        let preferred_snapshot =
70            latest_manifest_outbox_snapshot(work_dir).map_err(SyncError::PersistenceError)?;
71
72        // Validate the live database before opening it. A file can exist while
73        // still being an incomplete or corrupt SQLite projection after a crash.
74        // Restore the snapshot referenced by the latest valid combined
75        // checkpoint first, then fall back to the directory-wide snapshot scan.
76        let live_is_healthy =
77            self.execute_sync(|| async { Ok(crate::verify_live_database(&sqlite_path).await) })?;
78        if !live_is_healthy {
79            match restore_outbox_from_candidates(
80                &sqlite_path,
81                &snapshot_dir,
82                preferred_snapshot.as_ref(),
83            ) {
84                Ok(Some(lsn)) => {
85                    log::info!("Recovered outbox from snapshot at LSN {}", lsn.get());
86                }
87                Ok(None) => {}
88                Err(error) => {
89                    log::warn!(
90                        "Outbox recovery attempted but failed: {}. Starting fresh.",
91                        error
92                    );
93                }
94            }
95        }
96
97        let open_outbox = || {
98            self.execute_sync(|| async {
99                SqliteOutbox::open(&sqlite_path)
100                    .await
101                    .map_err(SyncError::PersistenceError)
102            })
103        };
104        let outbox = match open_outbox() {
105            Ok(outbox) => outbox,
106            Err(error) if snapshot_dir.is_dir() => {
107                log::warn!(
108                    "Failed to open outbox {}: {}. Restoring the latest snapshot.",
109                    sqlite_path.display(),
110                    error
111                );
112                restore_outbox_from_candidates(
113                    &sqlite_path,
114                    &snapshot_dir,
115                    preferred_snapshot.as_ref(),
116                )
117                .map_err(SyncError::PersistenceError)?
118                .ok_or_else(|| {
119                    SyncError::PersistenceError(format!(
120                        "No valid outbox snapshot found in {}",
121                        snapshot_dir.display()
122                    ))
123                })?;
124                open_outbox()?
125            }
126            Err(error) => return Err(error),
127        };
128        let outbox_arc = Arc::new(outbox);
129        self.sqlite_outbox = Some(outbox_arc.clone());
130        #[cfg(feature = "vector")]
131        {
132            self.vector_receiver = Some(Arc::new(crate::VectorReceiver::open(
133                work_dir.join("vector_receiver"),
134            )));
135            if let Some(coord) = self.vector_coordinator.as_ref() {
136                coord.set_outbox(outbox_arc.clone());
137            }
138        }
139        Ok(())
140    }
141
142    pub fn configure_outbox_consumer(&mut self, config: OutboxConsumerConfig) {
143        self.outbox_consumer = Arc::new(OutboxConsumerConfig {
144            consumer_id: if config.consumer_id.is_empty() {
145                OutboxConsumerConfig::default().consumer_id
146            } else {
147                config.consumer_id
148            },
149            batch_size: config.batch_size.max(1),
150            lease_duration_ms: config.lease_duration_ms.max(1_000),
151            max_retries: config.max_retries.max(1),
152            max_concurrency: config.max_concurrency.max(1),
153        });
154    }
155
156    /// Inject a backend-aware delivery policy (Local vs Qdrant) from startup.
157    ///
158    /// The policy replaces the raw `OutboxConsumerConfig` with backend-tuned
159    /// values and records the chosen policy for observability.
160    #[cfg(feature = "vector")]
161    pub fn configure_backend_policy(&mut self, policy: crate::backend::BackendDeliveryPolicy) {
162        self.backend_policy = Some(Arc::new(policy.clone()));
163        self.configure_outbox_consumer(policy.to_consumer_config());
164        // Align the per-target concurrency with the policy.
165        let mut consumer = (*self.outbox_consumer).clone();
166        consumer.max_concurrency = policy.max_concurrency.max(1);
167        self.outbox_consumer = Arc::new(consumer);
168    }
169
170    /// Override the backpressure limits (defaults: 10k per txn, 100k total).
171    pub fn configure_backpressure(&mut self, config: OutboxBackpressureConfig) {
172        self.backpressure = Arc::new(OutboxBackpressureConfig {
173            max_pending_per_txn: config.max_pending_per_txn.max(1),
174            max_pending_total: config.max_pending_total.max(1),
175        });
176    }
177
178    /// Current in-memory staged intent count across all transactions.
179    pub fn staged_pending_len(&self) -> usize {
180        self.pending_intents.iter().map(|e| e.value().len()).sum()
181    }
182
183    /// Expose the current durable + staged pending depth for storage-layer
184    /// flow control. Returns `0` when no outbox is configured.
185    pub fn outbox_pending(&self) -> usize {
186        let staged = self.staged_pending_len();
187        if let Some(outbox) = self.sqlite_outbox.clone() {
188            let durable = crate::runtime::block_on_ambient(async move {
189                outbox.stats().await.ok().map(|s| s.pending).unwrap_or(0)
190            })
191            .unwrap_or(0);
192            staged + durable
193        } else {
194            staged
195        }
196    }
197
198    pub fn outbox_stats(&self) -> crate::OutboxStats {
199        let Some(outbox) = self.sqlite_outbox.clone() else {
200            return crate::OutboxStats {
201                pending: 0,
202                ..Default::default()
203            };
204        };
205        match crate::runtime::block_on_ambient(async move { outbox.stats().await }) {
206            Ok(Ok(stats)) => stats,
207            _ => crate::OutboxStats {
208                pending: 0,
209                ..Default::default()
210            },
211        }
212    }
213
214    /// Return durable outbox delivery and index-generation diagnostics.
215    pub fn sync_diagnostics(&self) -> Result<crate::SyncDiagnostics, SyncError> {
216        let Some(outbox) = &self.sqlite_outbox else {
217            return Err(SyncError::PersistenceError(
218                "SQLite outbox is not configured".to_string(),
219            ));
220        };
221        #[allow(unused_mut)]
222        let mut diagnostics = self.execute_sync(|| async {
223            outbox
224                .diagnostics()
225                .await
226                .map_err(SyncError::PersistenceError)
227        })?;
228        #[cfg(feature = "vector")]
229        if let Some(coord) = self.vector_coordinator.as_ref() {
230            diagnostics.vector_disabled_skips = coord.disabled_skip_count();
231        }
232        Ok(diagnostics)
233    }
234
235    /// Create a crash-safe immutable snapshot of the SQLite projection.
236    pub fn create_outbox_snapshot(
237        &self,
238        destination: impl AsRef<Path>,
239    ) -> Result<crate::OutboxSnapshot, SyncError> {
240        let Some(outbox) = &self.sqlite_outbox else {
241            return Err(SyncError::PersistenceError(
242                "SQLite outbox is not configured".to_string(),
243            ));
244        };
245        let destination = destination.as_ref().to_path_buf();
246        self.execute_sync(|| async {
247            outbox
248                .create_snapshot(destination)
249                .await
250                .map_err(SyncError::PersistenceError)
251        })
252    }
253
254    pub fn wait_for_minimum_lsn(
255        &self,
256        target: &linkrs_core::types::TargetId,
257        index_id: u64,
258        generation: u64,
259        minimum_lsn: linkrs_core::types::CommitLsn,
260        timeout_ms: u64,
261    ) -> Result<bool, SyncError> {
262        let Some(outbox) = &self.sqlite_outbox else {
263            return Err(SyncError::PersistenceError(
264                "SQLite outbox is not configured".to_string(),
265            ));
266        };
267        self.execute_sync(|| async {
268            outbox
269                .wait_for_minimum_lsn(target, index_id, generation, minimum_lsn, timeout_ms)
270                .await
271                .map_err(SyncError::PersistenceError)
272        })
273    }
274
275    pub fn create_checkpoint_outbox_snapshot(&self) -> Result<crate::OutboxSnapshot, SyncError> {
276        let Some(outbox) = &self.sqlite_outbox else {
277            return Err(SyncError::PersistenceError(
278                "SQLite outbox is not configured".to_string(),
279            ));
280        };
281        let materialized_lsn = self.execute_sync(|| async {
282            outbox
283                .materialized_lsn()
284                .await
285                .map_err(SyncError::PersistenceError)
286        })?;
287        let database_parent = outbox.path().parent().ok_or_else(|| {
288            SyncError::PersistenceError("SQLite outbox path has no parent".to_string())
289        })?;
290        let work_dir = database_parent.parent().unwrap_or(database_parent);
291        let destination = work_dir
292            .join("outbox_snapshots")
293            .join(format!("outbox_snapshot_{}.sqlite", materialized_lsn.get()));
294        self.create_outbox_snapshot(destination)
295    }
296
297    pub fn verify_outbox_snapshot(snapshot: &crate::OutboxSnapshot) -> Result<(), SyncError> {
298        crate::SqliteOutbox::verify_snapshot(snapshot).map_err(SyncError::PersistenceError)
299    }
300
301    pub fn requeue_dead_letter(&self, event_id: i64) -> Result<bool, SyncError> {
302        let Some(outbox) = &self.sqlite_outbox else {
303            return Err(SyncError::PersistenceError(
304                "SQLite outbox is not configured".to_string(),
305            ));
306        };
307        self.execute_sync(|| async {
308            outbox
309                .requeue_dead_letter(event_id)
310                .await
311                .map_err(SyncError::PersistenceError)
312        })
313    }
314
315    pub fn requeue_dead_letters_batch(
316        &self,
317        target: Option<&linkrs_core::types::TargetId>,
318        index_id: Option<u64>,
319        generation: Option<u64>,
320        limit: usize,
321    ) -> Result<usize, SyncError> {
322        let Some(outbox) = &self.sqlite_outbox else {
323            return Err(SyncError::PersistenceError(
324                "SQLite outbox is not configured".to_string(),
325            ));
326        };
327        let outbox = outbox.clone();
328        let target = target.cloned();
329        self.execute_sync(move || {
330            let outbox = outbox.clone();
331            let target = target.clone();
332            async move {
333                outbox
334                    .requeue_dead_letters_batch(target.as_ref(), index_id, generation, limit)
335                    .await
336                    .map_err(SyncError::PersistenceError)
337            }
338        })
339    }
340
341    pub fn list_dead_letters(
342        &self,
343        target: Option<&linkrs_core::types::TargetId>,
344        index_id: Option<u64>,
345        generation: Option<u64>,
346        limit: usize,
347        offset: usize,
348    ) -> Result<Vec<crate::DeadLetterRow>, SyncError> {
349        let Some(outbox) = &self.sqlite_outbox else {
350            return Err(SyncError::PersistenceError(
351                "SQLite outbox is not configured".to_string(),
352            ));
353        };
354        let outbox = outbox.clone();
355        let target = target.cloned();
356        self.execute_sync(move || {
357            let outbox = outbox.clone();
358            let target = target.clone();
359            async move {
360                outbox
361                    .list_dead_letters(target.as_ref(), index_id, generation, limit, offset)
362                    .await
363                    .map_err(SyncError::PersistenceError)
364            }
365        })
366    }
367
368    pub fn list_degraded_ranges(
369        &self,
370        target: Option<&linkrs_core::types::TargetId>,
371        index_id: Option<u64>,
372        generation: Option<u64>,
373    ) -> Result<Vec<crate::DegradedRangeRow>, SyncError> {
374        let Some(outbox) = &self.sqlite_outbox else {
375            return Err(SyncError::PersistenceError(
376                "SQLite outbox is not configured".to_string(),
377            ));
378        };
379        let outbox = outbox.clone();
380        let target = target.cloned();
381        self.execute_sync(move || {
382            let outbox = outbox.clone();
383            let target = target.clone();
384            async move {
385                outbox
386                    .list_degraded_ranges(target.as_ref(), index_id, generation)
387                    .await
388                    .map_err(SyncError::PersistenceError)
389            }
390        })
391    }
392
393    pub fn clear_degraded_range(
394        &self,
395        target: &linkrs_core::types::TargetId,
396        index_id: u64,
397        generation: u64,
398        start_lsn: linkrs_core::types::CommitLsn,
399        end_lsn: linkrs_core::types::CommitLsn,
400    ) -> Result<bool, SyncError> {
401        let Some(outbox) = &self.sqlite_outbox else {
402            return Err(SyncError::PersistenceError(
403                "SQLite outbox is not configured".to_string(),
404            ));
405        };
406        let outbox = outbox.clone();
407        let target = target.clone();
408        self.execute_sync(move || {
409            let outbox = outbox.clone();
410            let target = target.clone();
411            async move {
412                outbox
413                    .clear_degraded_range(&target, index_id, generation, start_lsn, end_lsn)
414                    .await
415                    .map_err(SyncError::PersistenceError)
416            }
417        })
418    }
419
420    pub fn retention_lsn(&self) -> Result<linkrs_core::types::CommitLsn, SyncError> {
421        let Some(outbox) = &self.sqlite_outbox else {
422            return Err(SyncError::PersistenceError(
423                "SQLite outbox is not configured".to_string(),
424            ));
425        };
426        self.execute_sync(|| async {
427            outbox
428                .retention_lsn()
429                .await
430                .map_err(SyncError::PersistenceError)
431        })
432    }
433
434    pub fn update_retention_lsn(
435        &self,
436        retention_lsn: linkrs_core::types::CommitLsn,
437    ) -> Result<(), SyncError> {
438        let Some(outbox) = &self.sqlite_outbox else {
439            return Err(SyncError::PersistenceError(
440                "SQLite outbox is not configured".to_string(),
441            ));
442        };
443        let outbox = outbox.clone();
444        self.execute_sync(move || {
445            let outbox = outbox.clone();
446            async move {
447                outbox
448                    .update_retention_lsn(retention_lsn)
449                    .await
450                    .map_err(SyncError::PersistenceError)
451            }
452        })
453    }
454
455    pub fn prune_applied_events(
456        &self,
457        retention_lsn: linkrs_core::types::CommitLsn,
458    ) -> Result<u64, SyncError> {
459        let Some(outbox) = &self.sqlite_outbox else {
460            return Ok(0);
461        };
462        let outbox = outbox.clone();
463        self.execute_sync(move || {
464            let outbox = outbox.clone();
465            async move {
466                outbox
467                    .prune_applied_events(retention_lsn)
468                    .await
469                    .map_err(SyncError::PersistenceError)
470            }
471        })
472    }
473
474    pub fn archive_dead_letters(
475        &self,
476        retention_lsn: linkrs_core::types::CommitLsn,
477    ) -> Result<u64, SyncError> {
478        let Some(outbox) = &self.sqlite_outbox else {
479            return Ok(0);
480        };
481        let outbox = outbox.clone();
482        self.execute_sync(move || {
483            let outbox = outbox.clone();
484            async move {
485                outbox
486                    .archive_dead_letters(retention_lsn)
487                    .await
488                    .map_err(SyncError::PersistenceError)
489            }
490        })
491    }
492
493    pub fn prune_degraded_ranges(&self, max_age_ms: u64) -> Result<u64, SyncError> {
494        let Some(outbox) = &self.sqlite_outbox else {
495            return Ok(0);
496        };
497        let outbox = outbox.clone();
498        self.execute_sync(move || {
499            let outbox = outbox.clone();
500            async move {
501                outbox
502                    .prune_degraded_ranges(max_age_ms)
503                    .await
504                    .map_err(SyncError::PersistenceError)
505            }
506        })
507    }
508
509    /// Run one retention cycle: prune applied, archive dead letters, update retention watermark.
510    pub fn run_retention_once(
511        &self,
512        grace_lsn_distance: u64,
513        max_age_ms: u64,
514    ) -> Result<(u64, u64, u64), SyncError> {
515        let Some(outbox) = &self.sqlite_outbox else {
516            return Ok((0, 0, 0));
517        };
518        let outbox = outbox.clone();
519        self.execute_sync(move || {
520            let outbox = outbox.clone();
521            async move {
522                let safe = outbox
523                    .compute_safe_retention_lsn(grace_lsn_distance)
524                    .await
525                    .map_err(SyncError::PersistenceError)?;
526                let pruned = outbox
527                    .prune_applied_events(safe)
528                    .await
529                    .map_err(SyncError::PersistenceError)?;
530                let archived = outbox
531                    .archive_dead_letters(safe)
532                    .await
533                    .map_err(SyncError::PersistenceError)?;
534                outbox
535                    .update_retention_lsn(safe)
536                    .await
537                    .map_err(SyncError::PersistenceError)?;
538                let _ = outbox
539                    .prune_degraded_ranges(max_age_ms)
540                    .await
541                    .map_err(SyncError::PersistenceError)?;
542                Ok((pruned, archived, safe.get()))
543            }
544        })
545    }
546}