1mod accessors;
14mod delivery;
15mod ingest;
16mod intents;
17mod lifecycle;
18mod outbox_admin;
19mod types;
20
21#[cfg(feature = "vector")]
22pub(crate) use self::intents::format_vector_point_id;
23#[cfg(test)]
24pub(crate) use self::intents::payload_to_intent;
25#[cfg(any(feature = "fulltext", feature = "vector"))]
26pub(crate) use self::intents::stable_hash;
27pub use self::types::{
28 EdgeProps, EdgeRef, IndexCreateRequest, OutboxBackpressureConfig, OutboxConsumerConfig,
29 SyncError,
30};
31
32#[cfg(feature = "fulltext")]
33use crate::coordinator::SyncCoordinator;
34use crate::sqlite_outbox::SqliteOutbox;
35#[cfg(feature = "vector")]
36use crate::vector_sync::VectorSyncCoordinator;
37use dashmap::DashMap;
38use linkrs_core::types::TransactionId;
39use linkrs_metrics::StatsManager;
40#[cfg(feature = "vector")]
41pub use simvec::{CollectionConfig, SearchResult};
42use std::sync::Arc;
43use tokio::sync::Mutex;
44
45type JoinHandleGuard = Mutex<Option<tokio::task::JoinHandle<()>>>;
46
47pub struct SyncManager {
48 #[cfg(feature = "fulltext")]
49 sync_coordinator: Option<Arc<SyncCoordinator>>,
50 #[cfg(feature = "vector")]
51 vector_coordinator: Option<Arc<VectorSyncCoordinator>>,
52 pending_intents: DashMap<TransactionId, Vec<linkrs_core::wal::OutboxIntent>>,
53 running: Arc<std::sync::atomic::AtomicBool>,
54 dead_letter_queue: Option<Arc<crate::DeadLetterQueue>>,
55 sqlite_outbox: Option<Arc<SqliteOutbox>>,
56 #[cfg(feature = "vector")]
57 vector_receiver: Option<Arc<crate::VectorReceiver>>,
58 #[cfg(feature = "vector")]
59 auto_embed_text: Arc<std::sync::atomic::AtomicBool>,
60 outbox_consumer: Arc<OutboxConsumerConfig>,
61 #[cfg(feature = "vector")]
62 backend_policy: Option<Arc<crate::backend::BackendDeliveryPolicy>>,
63 backpressure: Arc<OutboxBackpressureConfig>,
64 auth_paused_until_ms: Arc<std::sync::atomic::AtomicU64>,
67 stats_manager: Option<Arc<StatsManager>>,
68 handle: JoinHandleGuard,
69 rebuild_locks: DashMap<String, Arc<tokio::sync::Mutex<()>>>,
74}
75
76impl Clone for SyncManager {
77 fn clone(&self) -> Self {
78 Self {
79 #[cfg(feature = "fulltext")]
80 sync_coordinator: self.sync_coordinator.clone(),
81 #[cfg(feature = "vector")]
82 vector_coordinator: self.vector_coordinator.clone(),
83 pending_intents: self.pending_intents.clone(),
84 running: self.running.clone(),
85 dead_letter_queue: self.dead_letter_queue.clone(),
86 sqlite_outbox: self.sqlite_outbox.clone(),
87 #[cfg(feature = "vector")]
88 vector_receiver: self.vector_receiver.clone(),
89 #[cfg(feature = "vector")]
90 auto_embed_text: self.auto_embed_text.clone(),
91 outbox_consumer: self.outbox_consumer.clone(),
92 #[cfg(feature = "vector")]
93 backend_policy: self.backend_policy.clone(),
94 backpressure: self.backpressure.clone(),
95 auth_paused_until_ms: self.auth_paused_until_ms.clone(),
96 stats_manager: self.stats_manager.clone(),
97 handle: Mutex::new(None),
98 rebuild_locks: self.rebuild_locks.clone(),
99 }
100 }
101}
102
103impl std::fmt::Debug for SyncManager {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 let mut d = f.debug_struct("SyncManager");
106 #[cfg(feature = "fulltext")]
107 d.field("sync_coordinator", &self.sync_coordinator);
108 #[cfg(feature = "vector")]
109 d.field("vector_coordinator", &self.vector_coordinator);
110 d.field("running", &self.running);
111 d.finish_non_exhaustive()
112 }
113}
114
115impl SyncManager {
116 fn execute_sync<F, Fut, T>(&self, f: F) -> Result<T, SyncError>
117 where
118 F: FnOnce() -> Fut + Send,
119 Fut: std::future::Future<Output = Result<T, SyncError>> + Send,
120 T: Send,
121 {
122 crate::runtime::block_on_ambient(f())
123 .map_err(|error| SyncError::Internal(error.to_string()))?
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::checkpoint_manifest::CheckpointManifestManager;
131 use crate::outbox::OutboxPayload;
132 use crate::sqlite_outbox::{OutboxSnapshot, SqliteOutbox};
133 use crate::types::ChangeType;
134 use crate::CheckpointManifest;
135 use linkrs_core::types::{CommitLsn, TransactionId};
136 use linkrs_core::Value;
137 use std::path::Path;
138 use tempfile::TempDir;
139
140 async fn create_test_snapshots(root: &Path) -> Vec<OutboxSnapshot> {
141 let source = root.join("source.sqlite");
142 let outbox = SqliteOutbox::open(&source)
143 .await
144 .expect("source outbox should open");
145 let snapshot_dir = root.join("outbox_snapshots");
146 let mut snapshots = Vec::new();
147 for lsn in [100, 200] {
148 outbox
149 .materialize_commit(CommitLsn::new(lsn), &[], &[])
150 .await
151 .expect("source outbox should materialize");
152 snapshots.push(
153 outbox
154 .create_snapshot(snapshot_dir.join(format!("outbox_snapshot_{lsn}.sqlite")))
155 .await
156 .expect("outbox snapshot should be created"),
157 );
158 }
159 snapshots
160 }
161
162 fn publish_test_manifest(root: &Path, snapshot: &OutboxSnapshot) {
163 let storage_path = root.join("checkpoint/checkpoint_1");
164 std::fs::create_dir_all(&storage_path).expect("storage checkpoint should exist");
165 std::fs::write(storage_path.join("checkpoint.meta"), b"checkpoint")
166 .expect("storage checkpoint metadata should exist");
167 let manifest = CheckpointManifest::new(
168 1,
169 snapshot.materialized_lsn,
170 CheckpointManifest::storage_snapshot_from_directory(&storage_path, 1, 0, 0)
171 .expect("storage snapshot reference should be created"),
172 Some(CheckpointManifest::outbox_snapshot_from(snapshot)),
173 Vec::new(),
174 )
175 .expect("checkpoint manifest should be created");
176 CheckpointManifestManager::new(root.join("checkpoint/manifests"))
177 .publish(&manifest)
178 .expect("checkpoint manifest should publish");
179 }
180
181 #[test]
182 fn pending_intents_are_available_before_commit() {
183 let manager = SyncManager::new_without_fulltext();
184 let txn_id = TransactionId::new(77);
185 manager
186 .on_vertex_change_with_txn(
187 txn_id,
188 1,
189 "Node",
190 &Value::string("v1"),
191 &[],
192 ChangeType::Insert,
193 )
194 .expect("event should stage");
195 let intents = manager
196 .pending_transaction_intents(txn_id)
197 .expect("intents should be available");
198 assert!(intents.is_empty());
199 assert_eq!(manager.outbox_stats().pending, 0);
200 }
201
202 #[test]
203 fn pending_intents_are_cleared_on_rollback() {
204 let manager = SyncManager::new_without_fulltext();
205 let txn_id = TransactionId::new(88);
206 manager
207 .on_vertex_change_with_txn(
208 txn_id,
209 1,
210 "Node",
211 &Value::string("v1"),
212 &[],
213 ChangeType::Insert,
214 )
215 .expect("event should stage");
216 assert!(manager
217 .pending_transaction_intents(txn_id)
218 .expect("intents should be available")
219 .is_empty());
220 manager.clear_transaction_intents(txn_id);
221 assert_eq!(manager.outbox_stats().pending, 0);
222 }
223
224 #[test]
225 fn edge_delete_intent_preserves_parallel_edge_ranking() {
226 let payload = OutboxPayload::EdgeDelete {
227 space_id: 1,
228 src: Value::string("src"),
229 dst: Value::string("dst"),
230 edge_type: "KNOWS".to_string(),
231 ranking: 7,
232 };
233 let intent = payload_to_intent(TransactionId::new(101), 0, "fulltext", &payload)
234 .expect("edge delete intent should be serializable");
235
236 match intent.mutation.entity_ref {
237 linkrs_core::wal::EntityRef::Edge { ranking, .. } => assert_eq!(ranking, 7),
238 other => panic!("expected edge entity reference, got {other:?}"),
239 }
240 }
241
242 #[cfg(feature = "fulltext")]
243 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
244 async fn fulltext_changes_use_only_the_fulltext_target() {
245 let directory = TempDir::new().expect("temporary index directory should be created");
246 let config = linkrs_fulltext::FulltextConfig {
247 index_path: directory.path().to_path_buf(),
248 ..Default::default()
249 };
250 let fulltext_manager = Arc::new(
251 linkrs_fulltext::FulltextIndexManager::new(config)
252 .expect("fulltext manager should be created"),
253 );
254 fulltext_manager
255 .create_index(1, "Node", "text", None)
256 .await
257 .expect("fulltext index should be created");
258 let coordinator = Arc::new(crate::SyncCoordinator::new(
259 fulltext_manager,
260 crate::BatchConfig::default(),
261 ));
262 let manager = SyncManager::new(coordinator);
263 let outbox_path = directory.path().join("outbox/outbox.sqlite");
264 let mut manager = manager;
265 manager
266 .configure_outbox(outbox_path)
267 .expect("test outbox should be configured");
268 let txn_id = TransactionId::new(99);
269
270 manager
271 .on_vertex_change_with_txn(
272 txn_id,
273 1,
274 "Node",
275 &Value::string("v1"),
276 &[("text".into(), Value::string("hello"))],
277 ChangeType::Insert,
278 )
279 .expect("change should stage");
280
281 let intents = manager
282 .pending_transaction_intents(txn_id)
283 .expect("intents should be available");
284 assert_eq!(intents.len(), 1);
285 assert_eq!(intents[0].mutation.target.as_str(), "fulltext");
286 }
287
288 #[cfg(feature = "fulltext")]
289 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
290 async fn fulltext_outbox_claim_apply_and_restart_receipt_are_end_to_end() {
291 let directory = TempDir::new().expect("temporary index directory should be created");
292 let config = linkrs_fulltext::FulltextConfig {
293 index_path: directory.path().join("indexes"),
294 ..Default::default()
295 };
296 let fulltext_manager = Arc::new(
297 linkrs_fulltext::FulltextIndexManager::new(config)
298 .expect("fulltext manager should be created"),
299 );
300 fulltext_manager
301 .create_index(1, "Node", "text", None)
302 .await
303 .expect("receiver index should be created");
304 let coordinator = Arc::new(crate::SyncCoordinator::new(
305 fulltext_manager.clone(),
306 crate::BatchConfig::default(),
307 ));
308 let mut manager = SyncManager::new(coordinator);
309 manager
310 .configure_outbox(directory.path().join("outbox/outbox.sqlite"))
311 .expect("outbox should be configured");
312 let transaction_id = TransactionId::new(1001);
313 manager
314 .on_vertex_change_with_txn(
315 transaction_id,
316 1,
317 "Node",
318 &Value::string("node-1"),
319 &[("text".into(), Value::string("durable graph event"))],
320 ChangeType::Insert,
321 )
322 .expect("change should stage");
323 let intents = manager
324 .pending_transaction_intents(transaction_id)
325 .expect("staged intents should load");
326 manager
327 .materialize_committed_transaction(transaction_id, CommitLsn::new(10), &intents)
328 .expect("commit should materialize");
329 manager.clear_transaction_intents(transaction_id);
330 assert_eq!(manager.retry_outbox_sync().expect("claim should apply"), 1);
331 let results = fulltext_manager
332 .search(1, "Node", "text", "durable", 10)
333 .await
334 .expect("search should see the applied event");
335 assert_eq!(results.len(), 1);
336
337 let recovered = crate::receiver::FulltextReceiver::new(
338 fulltext_manager
339 .get_engine(1, "Node", "text")
340 .expect("receiver engine should exist"),
341 );
342 let duplicate = recovered
343 .check_late_arrival(CommitLsn::new(10), "1001:fulltext:1:text")
344 .await;
345 assert!(
346 !duplicate.accepted,
347 "field-specific receipt should reject a duplicate"
348 );
349 }
350
351 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
352 async fn configure_outbox_prefers_manifest_snapshot_over_newer_directory_snapshot() {
353 let directory = TempDir::new().expect("temporary directory should be created");
354 let snapshots = create_test_snapshots(directory.path()).await;
355 publish_test_manifest(directory.path(), &snapshots[0]);
356
357 let mut manager = SyncManager::new_without_fulltext();
358 manager
359 .configure_outbox(directory.path().join("outbox/outbox.sqlite"))
360 .expect("outbox should recover from the manifest snapshot");
361
362 assert_eq!(
363 manager
364 .outbox_materialized_lsn()
365 .expect("outbox frontier should load"),
366 Some(CommitLsn::new(100))
367 );
368 }
369
370 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
371 async fn configure_outbox_verifies_and_restores_corrupt_live_database() {
372 let directory = TempDir::new().expect("temporary directory should be created");
373 let snapshots = create_test_snapshots(directory.path()).await;
374 publish_test_manifest(directory.path(), &snapshots[0]);
375 let live_path = directory.path().join("outbox/outbox.sqlite");
376 std::fs::create_dir_all(live_path.parent().expect("live parent should exist"))
377 .expect("live parent should be created");
378 std::fs::write(&live_path, b"corrupt sqlite").expect("live database should be corrupt");
379
380 let mut manager = SyncManager::new_without_fulltext();
381 manager
382 .configure_outbox(&live_path)
383 .expect("corrupt outbox should be restored");
384
385 assert_eq!(
386 manager
387 .outbox_materialized_lsn()
388 .expect("outbox frontier should load"),
389 Some(CommitLsn::new(100))
390 );
391 }
392
393 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
394 async fn configure_outbox_falls_back_when_manifest_snapshot_checksum_is_invalid() {
395 let directory = TempDir::new().expect("temporary directory should be created");
396 let snapshots = create_test_snapshots(directory.path()).await;
397 std::fs::write(&snapshots[0].path, b"corrupt snapshot")
398 .expect("manifest snapshot should be corrupt");
399 publish_test_manifest(directory.path(), &snapshots[0]);
400
401 let mut manager = SyncManager::new_without_fulltext();
402 manager
403 .configure_outbox(directory.path().join("outbox/outbox.sqlite"))
404 .expect("outbox should fall back to a valid snapshot");
405
406 assert_eq!(
407 manager
408 .outbox_materialized_lsn()
409 .expect("outbox frontier should load"),
410 Some(CommitLsn::new(200))
411 );
412 }
413}