1use 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 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 #[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 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 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 pub fn staged_pending_len(&self) -> usize {
180 self.pending_intents.iter().map(|e| e.value().len()).sum()
181 }
182
183 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 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 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 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}