Skip to main content

linkrs_storage/engine/
sync_wrapper.rs

1//! Storage Layer Synchronous Wrapper
2//!
3//! Decorator pattern implementation that wraps any StorageClient to automatically
4//! synchronize storage operations with external index systems (fulltext, vector).
5
6use crate::StorageClient;
7use linkrs_core::StorageError;
8use std::fmt::Debug;
9use std::sync::Arc;
10
11/// Decorator that wraps a StorageClient to provide automatic index synchronization.
12#[derive(Clone, Debug)]
13pub struct SyncWrapper<S: StorageClient + Debug> {
14    inner: S,
15    sync_manager: Option<Arc<linkrs_sync::SyncManager>>,
16    enabled: bool,
17    auto_commit_owner: bool,
18}
19
20/// One internal index definition snapshotted before a destructive schema op,
21/// staged afterwards as a replay-visible `DropIndex` intent.
22struct IndexDropSnapshotEntry {
23    space_id: u64,
24    index_name: String,
25    schema_name: String,
26    index_type: String,
27    fields: Vec<String>,
28}
29
30struct IndexDropSnapshot {
31    space_id: Option<u64>,
32    drops: Vec<IndexDropSnapshotEntry>,
33}
34
35impl<S: StorageClient> SyncWrapper<S> {
36    /// Create a new wrapper without synchronization.
37    pub fn new(storage: S) -> Self {
38        Self {
39            inner: storage,
40            sync_manager: None,
41            enabled: false,
42            auto_commit_owner: false,
43        }
44    }
45
46    /// Create a new wrapper with a SyncManager for index synchronization.
47    pub fn with_sync_manager(storage: S, sync_manager: Arc<linkrs_sync::SyncManager>) -> Self {
48        let frontier_manager = sync_manager.clone();
49        storage.set_outbox_materialized_lsn_provider(Arc::new(move || {
50            frontier_manager
51                .outbox_materialized_lsn()
52                .map_err(|error| StorageError::db_error(error.to_string()))
53        }));
54        Self {
55            inner: storage,
56            sync_manager: Some(sync_manager),
57            enabled: true,
58            auto_commit_owner: false,
59        }
60    }
61
62    /// Check if synchronization is enabled.
63    pub fn is_enabled(&self) -> bool {
64        self.enabled
65    }
66
67    /// Get reference to the sync manager.
68    pub fn get_sync_manager(&self) -> Option<Arc<linkrs_sync::SyncManager>> {
69        self.sync_manager.clone()
70    }
71
72    /// Get reference to the inner storage client.
73    pub fn inner(&self) -> &S {
74        &self.inner
75    }
76
77    /// Get mutable reference to the inner storage client.
78    pub fn inner_mut(&mut self) -> &mut S {
79        &mut self.inner
80    }
81}
82
83impl<S: StorageClient> SyncWrapper<S> {
84    /// Get the current transaction ID from storage context.
85    fn get_current_txn_id(&self) -> Option<linkrs_core::types::TransactionId> {
86        self.inner
87            .operation_context()
88            .and_then(|ctx| ctx.transaction_id)
89    }
90    fn commit_transaction_fact(
91        &self,
92        transaction_id: linkrs_core::types::TransactionId,
93    ) -> Result<linkrs_core::types::CommitLsn, StorageError> {
94        self.commit_transaction_fact_with_durability(
95            transaction_id,
96            linkrs_core::types::DurabilityLevel::Sync,
97        )
98    }
99
100    fn commit_transaction_fact_with_durability(
101        &self,
102        transaction_id: linkrs_core::types::TransactionId,
103        durability: linkrs_core::types::DurabilityLevel,
104    ) -> Result<linkrs_core::types::CommitLsn, StorageError> {
105        let intents = match self.sync_manager.as_ref() {
106            Some(manager) => manager
107                .pending_transaction_intents(transaction_id)
108                .map_err(|error| {
109                    StorageError::db_error(format!(
110                        "Failed to build transaction index intents: {}",
111                        error
112                    ))
113                })?,
114            None => Vec::new(),
115        };
116        let commit_lsn = match self.inner.commit_staged_writes_with_durability(
117            transaction_id,
118            &intents,
119            durability,
120        ) {
121            Ok(commit_lsn) => commit_lsn,
122            Err(error) => {
123                // A failed durability fence must not leave redo or target
124                // intents attached to an auto-commit transaction that the
125                // caller is allowed to retry.
126                let _ = self.inner.abort_staged_writes(transaction_id);
127                if let Some(manager) = self.sync_manager.as_ref() {
128                    let _ = manager.rollback_transaction_sync(transaction_id);
129                }
130                return Err(error);
131            }
132        };
133        Ok(commit_lsn)
134    }
135
136    fn finalize_commit_fact(
137        &self,
138        transaction_id: linkrs_core::types::TransactionId,
139        commit_lsn: linkrs_core::types::CommitLsn,
140    ) -> Result<(), StorageError> {
141        if let Some(manager) = self.sync_manager.as_ref() {
142            let intents = manager
143                .pending_transaction_intents(transaction_id)
144                .map_err(|error| {
145                    StorageError::db_error(format!(
146                        "Failed to load committed transaction intents: {}",
147                        error
148                    ))
149                })?;
150            if let Err(error) =
151                manager.materialize_committed_transaction(transaction_id, commit_lsn, &intents)
152            {
153                log::error!(
154                    "Committed transaction {} at {} but outbox materialization is pending recovery: {}",
155                    transaction_id,
156                    commit_lsn,
157                    error
158                );
159            }
160            if let Err(error) = manager.rollback_transaction_sync(transaction_id) {
161                log::warn!(
162                    "Committed transaction {} at {} but staging cleanup failed: {}",
163                    transaction_id,
164                    commit_lsn,
165                    error
166                );
167            }
168            manager.clear_transaction_intents(transaction_id);
169            if let Err(error) = manager.retry_outbox_sync() {
170                log::debug!(
171                    "Committed transaction {} at {}; target delivery will retry: {}",
172                    transaction_id,
173                    commit_lsn,
174                    error
175                );
176            }
177        }
178        Ok(())
179    }
180
181    fn commit_auto_transaction(
182        &self,
183    ) -> Result<Option<linkrs_core::types::CommitLsn>, StorageError> {
184        let Some(context) = self.inner.operation_context() else {
185            return Ok(None);
186        };
187        if (!self.auto_commit_owner && !context.auto_commit) || context.read_only {
188            return Ok(None);
189        }
190        let transaction_id = context.transaction_id.ok_or_else(|| {
191            StorageError::db_error("Auto-commit write has no transaction ID".to_string())
192        })?;
193        let commit_lsn = self.commit_transaction_fact(transaction_id)?;
194        // Mirror the explicit-transaction sink flow: after the WAL durability
195        // fence, materialize the staged intents into the durable outbox and
196        // release them. Skipping this step would pin the outbox frontier at
197        // its pre-commit value forever, which in turn pins the checkpoint
198        // safe-WAL boundary at 0 (WAL never truncated) and leaks the staged
199        // intents in `pending_intents`.
200        self.finalize_commit_fact(transaction_id, commit_lsn)?;
201        Ok(Some(commit_lsn))
202    }
203
204    fn abort_transaction_fact(
205        &self,
206        transaction_id: linkrs_core::types::TransactionId,
207    ) -> Result<(), StorageError> {
208        self.inner.abort_staged_writes(transaction_id)?;
209        if let Some(manager) = self.sync_manager.as_ref() {
210            manager
211                .rollback_transaction_sync(transaction_id)
212                .map_err(|error| {
213                    StorageError::db_error(format!(
214                        "Failed to discard transaction index intents: {}",
215                        error
216                    ))
217                })?;
218        }
219        Ok(())
220    }
221
222    fn stage_index_create(
223        &self,
224        index: &linkrs_core::types::Index,
225        index_type: &str,
226    ) -> Result<(), StorageError> {
227        if !self.enabled {
228            return Ok(());
229        }
230        let Some(sync_manager) = self.get_sync_manager() else {
231            return Ok(());
232        };
233        let transaction_id = self.get_current_txn_id().ok_or_else(|| {
234            StorageError::db_error(
235                "Synchronized schema changes require an operation transaction context".to_string(),
236            )
237        })?;
238        let fields = index
239            .fields
240            .iter()
241            .map(|field| (field.name.clone(), field.value_type.clone()))
242            .collect::<Vec<_>>();
243        sync_manager
244            .on_index_create(
245                transaction_id,
246                linkrs_sync::manager::IndexCreateRequest {
247                    space_id: index.space_id,
248                    index_name: index.name.clone(),
249                    schema_name: index.schema_name.clone(),
250                    index_type: index_type.to_string(),
251                    fields,
252                    properties: index.properties.clone(),
253                },
254            )
255            .map_err(|error| {
256                StorageError::db_error(format!("Failed to stage index creation intent: {error}"))
257            })
258    }
259
260    fn validate_schema_sync_context(&self) -> Result<(), StorageError> {
261        if self.enabled && self.get_sync_manager().is_some() && self.get_current_txn_id().is_none()
262        {
263            return Err(StorageError::db_error(
264                "Synchronized schema changes require an operation transaction context".to_string(),
265            ));
266        }
267        Ok(())
268    }
269
270    fn stage_index_drop(
271        &self,
272        space_id: u64,
273        index_name: &str,
274        schema_name: &str,
275        index_type: &str,
276        fields: &[String],
277    ) -> Result<(), StorageError> {
278        if !self.enabled {
279            return Ok(());
280        }
281        let Some(sync_manager) = self.get_sync_manager() else {
282            return Ok(());
283        };
284        let transaction_id = self.get_current_txn_id().ok_or_else(|| {
285            StorageError::db_error(
286                "Synchronized schema changes require an operation transaction context".to_string(),
287            )
288        })?;
289        sync_manager
290            .on_index_drop(
291                transaction_id,
292                space_id,
293                index_name,
294                schema_name,
295                index_type,
296                fields,
297            )
298            .map_err(|error| {
299                StorageError::db_error(format!("Failed to stage index drop intent: {error}"))
300            })
301    }
302
303    /// One internal index definition snapshotted before a destructive op.
304    fn stage_snapshot_drops(&self, snapshot: &IndexDropSnapshot) -> Result<(), StorageError> {
305        for drop in &snapshot.drops {
306            self.stage_index_drop(
307                drop.space_id,
308                &drop.index_name,
309                &drop.schema_name,
310                &drop.index_type,
311                &drop.fields,
312            )?;
313        }
314        Ok(())
315    }
316
317    fn stage_space_drop(&self, space_id: u64) -> Result<(), StorageError> {
318        if !self.enabled {
319            return Ok(());
320        }
321        let Some(sync_manager) = self.get_sync_manager() else {
322            return Ok(());
323        };
324        let transaction_id = self.get_current_txn_id().ok_or_else(|| {
325            StorageError::db_error(
326                "Synchronized schema changes require an operation transaction context".to_string(),
327            )
328        })?;
329        sync_manager
330            .on_space_drop(transaction_id, space_id)
331            .map_err(|error| {
332                StorageError::db_error(format!("Failed to stage space drop intent: {error}"))
333            })
334    }
335
336    fn stage_tag_drop(&self, space_id: u64, tag_name: &str) -> Result<(), StorageError> {
337        if !self.enabled {
338            return Ok(());
339        }
340        let Some(sync_manager) = self.get_sync_manager() else {
341            return Ok(());
342        };
343        let transaction_id = self.get_current_txn_id().ok_or_else(|| {
344            StorageError::db_error(
345                "Synchronized schema changes require an operation transaction context".to_string(),
346            )
347        })?;
348        sync_manager
349            .on_tag_drop(transaction_id, space_id, tag_name)
350            .map_err(|error| {
351                StorageError::db_error(format!("Failed to stage tag drop intent: {error}"))
352            })
353    }
354
355    /// Shared `drop_space` / `clear_space` path: snapshot index definitions
356    /// first (the inner op removes them), then stage one replay-visible
357    /// `DropIndex` per index plus a generic `DropSpace` safety net covering
358    /// directly created external indexes.
359    fn drop_or_clear_space(&mut self, space: &str, is_clear: bool) -> Result<bool, StorageError> {
360        let snapshot = self.snapshot_tag_indexes(space, None);
361        let space_id = snapshot.space_id;
362        self.validate_schema_sync_context()?;
363        let result = if is_clear {
364            self.inner.clear_space(space)?
365        } else {
366            self.inner.drop_space(space)?
367        };
368        if result {
369            if let Err(error) = self.stage_snapshot_drops(&snapshot) {
370                if let Some(transaction_id) = self.get_current_txn_id() {
371                    let _ = self.abort_transaction_fact(transaction_id);
372                }
373                return Err(error);
374            }
375            if let Some(sid) = space_id {
376                if let Err(error) = self.stage_space_drop(sid) {
377                    if let Some(transaction_id) = self.get_current_txn_id() {
378                        let _ = self.abort_transaction_fact(transaction_id);
379                    }
380                    return Err(error);
381                }
382            }
383        }
384        self.commit_auto_transaction()?;
385        Ok(result)
386    }
387
388    /// Snapshot tag+edge index definitions of one space, optionally filtered
389    /// to a single schema (tag/edge type). Best effort: unreadable scopes
390    /// yield an empty snapshot rather than failing the schema op.
391    fn snapshot_tag_indexes(&self, space: &str, schema_filter: Option<&str>) -> IndexDropSnapshot {
392        let space_id = self.inner.get_space_id(space).ok();
393        let mut drops = Vec::new();
394        if let Some(sid) = space_id {
395            let mut push_index = |index: &linkrs_core::types::Index, index_type: &str| {
396                if schema_filter.is_some_and(|f| f != index.schema_name) {
397                    return;
398                }
399                drops.push(IndexDropSnapshotEntry {
400                    space_id: sid,
401                    index_name: index.name.clone(),
402                    schema_name: index.schema_name.clone(),
403                    index_type: index_type.to_string(),
404                    fields: index
405                        .fields
406                        .iter()
407                        .map(|field| field.name.clone())
408                        .collect(),
409                });
410            };
411            if let Ok(indexes) = self.inner.list_tag_indexes(space) {
412                for index in &indexes {
413                    push_index(index, "tag");
414                }
415            }
416            if let Ok(indexes) = self.inner.list_edge_indexes(space) {
417                for index in &indexes {
418                    push_index(index, "edge");
419                }
420            }
421        }
422        IndexDropSnapshot { space_id, drops }
423    }
424}
425
426pub mod admin;
427pub mod operation;
428pub mod persistence;
429pub mod reader;
430pub mod schema;
431pub mod transaction;
432pub mod undo;
433mod write;
434mod write_edge;
435mod write_vertex;
436
437#[cfg(test)]
438mod tests;