Skip to main content

linkrs_sync/
manager.rs

1//! Sync Manager
2//!
3//! Unified synchronization manager using SyncCoordinator.
4//!
5//! The implementation is split by responsibility across submodules:
6//! `types` (shared types), `lifecycle` (construction and runtime),
7//! `intents` (transactional staging), `ingest` (mutation entry points),
8//! `delivery` (outbox claim-apply-ack), `outbox_admin` (snapshots, dead
9//! letters, retention) and `accessors` (read-only views and facades).
10//! This module keeps the shared state, the sync-execution primitive and
11//! the unit tests.
12
13mod 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    /// When an `Auth` error is seen, delivery is paused until this timestamp
65    /// (millis since epoch). Atomic for lock-free reads in the hot path.
66    auth_paused_until_ms: Arc<std::sync::atomic::AtomicU64>,
67    stats_manager: Option<Arc<StatsManager>>,
68    handle: JoinHandleGuard,
69    /// Per-index rebuild admission locks. A rebuild holds its key across
70    /// generation registration, backfill, catch-up, and publish, so two
71    /// concurrent rebuilds of the same index serialize: the loser gets
72    /// `SyncError::RebuildBusy` instead of interleaving generations.
73    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}