Skip to main content

oxiland/
model.rs

1use std::fs::File;
2use std::io::{BufReader, BufWriter};
3use std::path::Path;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, RwLock};
6use std::thread::ThreadId;
7
8use oxigraph::io::{RdfFormat, RdfParser, RdfSerializer};
9use oxigraph::model::{
10    GraphName, GraphNameRef, NamedOrBlankNodeRef, Quad, QuadRef, TermRef, Triple, TripleRef,
11};
12use oxigraph::store::{QuadIter, Store, Transaction as OxigraphTransaction};
13
14use crate::io::{BomStrippingReader, map_rdf_parse_error};
15use crate::storage::{
16    self, DurableStore, DurableStoreOps, OpenOptions, StorageBackend, StorageCapabilities,
17};
18use crate::{Error, Result};
19
20/// A partial triple pattern, equivalent to Redland's statement matching API.
21#[derive(Clone, Copy, Debug, Default)]
22pub struct StatementPattern<'a> {
23    /// Optional subject constraint.
24    pub subject: Option<NamedOrBlankNodeRef<'a>>,
25    /// Optional predicate constraint.
26    pub predicate: Option<oxigraph::model::NamedNodeRef<'a>>,
27    /// Optional object constraint.
28    pub object: Option<TermRef<'a>>,
29    /// Optional graph/context constraint. `None` searches all contexts.
30    pub graph_name: Option<GraphNameRef<'a>>,
31}
32
33/// Streaming matches produced by [`Model::find`].
34///
35/// The iterator yields owned [`Quad`] values from a store snapshot and does not
36/// borrow the [`Model`]. Errors from the storage backend surface as
37/// [`Error::Storage`].
38#[must_use]
39pub struct StatementMatches {
40    inner: QuadIter<'static>,
41}
42
43impl Iterator for StatementMatches {
44    type Item = Result<Quad>;
45
46    fn next(&mut self) -> Option<Self::Item> {
47        self.inner
48            .next()
49            .map(|item| item.map_err(|error| Error::Storage(error.to_string())))
50    }
51}
52
53/// Mutator available inside [`Model::transaction`].
54pub struct ModelTransaction<'a> {
55    inner: OxigraphTransaction<'a>,
56}
57
58impl ModelTransaction<'_> {
59    /// Adds a statement to the default graph within the transaction.
60    pub fn add(&mut self, statement: impl Into<Triple>) -> Result<bool> {
61        self.add_to_graph(statement, GraphName::DefaultGraph)
62    }
63
64    /// Adds a statement to a named graph within the transaction.
65    pub fn add_to_graph(
66        &mut self,
67        statement: impl Into<Triple>,
68        graph_name: impl Into<GraphName>,
69    ) -> Result<bool> {
70        let triple = statement.into();
71        let quad = Quad::new(triple.subject, triple.predicate, triple.object, graph_name);
72        self.insert_quad(quad)
73    }
74
75    /// Inserts a quad within the transaction.
76    pub fn insert_quad(&mut self, quad: Quad) -> Result<bool> {
77        let inserted = !self
78            .inner
79            .contains(quad.as_ref())
80            .map_err(|error| Error::Storage(error.to_string()))?;
81        self.inner.insert(quad.as_ref());
82        Ok(inserted)
83    }
84
85    /// Removes a statement from the default graph within the transaction.
86    pub fn remove(&mut self, statement: impl Into<Triple>) -> Result<bool> {
87        self.remove_from_graph(statement, GraphName::DefaultGraph)
88    }
89
90    /// Removes a statement from a named graph within the transaction.
91    pub fn remove_from_graph(
92        &mut self,
93        statement: impl Into<Triple>,
94        graph_name: impl Into<GraphName>,
95    ) -> Result<bool> {
96        let triple = statement.into();
97        let quad = Quad::new(triple.subject, triple.predicate, triple.object, graph_name);
98        self.remove_quad(&quad)
99    }
100
101    /// Removes a quad within the transaction.
102    pub fn remove_quad(&mut self, quad: &Quad) -> Result<bool> {
103        let removed = self
104            .inner
105            .contains(quad.as_ref())
106            .map_err(|error| Error::Storage(error.to_string()))?;
107        if removed {
108            self.inner.remove(quad.as_ref());
109        }
110        Ok(removed)
111    }
112
113    /// Clears the entire dataset within the transaction.
114    pub fn clear(&mut self) -> Result<()> {
115        self.inner
116            .clear()
117            .map_err(|error| Error::Storage(error.to_string()))
118    }
119
120    /// Clears one graph within the transaction.
121    pub fn clear_graph(&mut self, graph_name: impl Into<GraphName>) -> Result<()> {
122        let graph_name = graph_name.into();
123        self.inner
124            .clear_graph(graph_name.as_ref())
125            .map_err(|error| Error::Storage(error.to_string()))
126    }
127}
128
129/// An RDF graph model backed by an Oxigraph store.
130///
131/// In-memory models use Oxigraph alone. Persistent models opened with
132/// [`Model::open`] / [`Model::open_with`] keep an Oxigraph working set and a
133/// Fjall durable copy under Oxiland format v1 (ADR-006).
134///
135/// Cloning a [`Model`] clones the store handle and shares the same dataset; it
136/// does not deep-copy statements. `Model` is `Send` and `Sync`.
137///
138/// Readers (`find` / query execution) take a shared lock; writers take an
139/// exclusive lock so Fjall reload cannot expose an empty working set.
140///
141/// # Examples
142///
143/// ```
144/// use oxiland::terms::{self, Literal, Triple};
145/// use oxiland::{Model, StatementPattern};
146///
147/// # fn main() -> oxiland::Result<()> {
148/// let model = Model::new()?;
149/// let statement = Triple::new(
150///     terms::named_node("https://example.com/alice")?,
151///     terms::named_node("https://example.com/name")?,
152///     Literal::new_simple_literal("Alice"),
153/// );
154///
155/// assert!(model.add(statement.clone())?);
156/// assert!(model.contains(statement.as_ref())?);
157///
158/// let matches = model
159///     .find(StatementPattern {
160///         subject: Some(statement.subject.as_ref()),
161///         ..StatementPattern::default()
162///     })
163///     .collect::<Result<Vec<_>, _>>()?;
164/// assert_eq!(matches.len(), 1);
165/// # Ok(())
166/// # }
167/// ```
168#[derive(Clone)]
169pub struct Model {
170    store: Store,
171    disk: Option<DurableStore>,
172    lock: Arc<RwLock<()>>,
173    read_only: bool,
174    in_transaction: Arc<AtomicBool>,
175    txn_owner: Arc<Mutex<Option<ThreadId>>>,
176}
177
178struct InTransactionGuard<'a> {
179    model: &'a Model,
180}
181
182impl Drop for InTransactionGuard<'_> {
183    fn drop(&mut self) {
184        self.model.in_transaction.store(false, Ordering::Release);
185        *self
186            .model
187            .txn_owner
188            .lock()
189            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
190    }
191}
192
193impl Model {
194    /// Creates an empty in-memory model.
195    pub fn new() -> Result<Self> {
196        Store::new()
197            .map(|store| Self {
198                store,
199                disk: None,
200                lock: Arc::new(RwLock::new(())),
201                read_only: false,
202                in_transaction: Arc::new(AtomicBool::new(false)),
203                txn_owner: Arc::new(Mutex::new(None)),
204            })
205            .map_err(|error| Error::Storage(error.to_string()))
206    }
207
208    /// Opens or creates a persistent format-v1 model at `path`.
209    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
210        Self::open_with(OpenOptions::fjall(path))
211    }
212
213    /// Opens a persistent model with typed options (ADR-006 / ADR-022).
214    pub fn open_with(options: OpenOptions) -> Result<Self> {
215        if options.backend() == StorageBackend::Memory {
216            return Self::new();
217        }
218
219        let path = options.path();
220        if options.is_read_only() {
221            if !path.exists() {
222                return Err(Error::OpenStore {
223                    path: path.to_owned(),
224                    message: "read-only open requires an existing store path".into(),
225                });
226            }
227            if !DurableStore::looks_like_store(options.backend(), path) {
228                return Err(Error::OpenStore {
229                    path: path.to_owned(),
230                    message: "read-only open cannot initialize a new Oxiland store".into(),
231                });
232            }
233        }
234
235        let disk = match options.backend() {
236            StorageBackend::Memory => unreachable!("memory handled above"),
237            StorageBackend::Fjall => DurableStore::open_fjall(path, options.can_create())?,
238        };
239        let allow_init = !options.is_read_only() && options.can_create();
240        disk.ensure_format_v1(path, allow_init)?;
241        let store = Store::new().map_err(|error| Error::OpenStore {
242            path: path.to_owned(),
243            message: error.to_string(),
244        })?;
245        disk.load_into(&store)?;
246        Ok(Self {
247            store,
248            disk: Some(disk),
249            lock: Arc::new(RwLock::new(())),
250            read_only: options.is_read_only(),
251            in_transaction: Arc::new(AtomicBool::new(false)),
252            txn_owner: Arc::new(Mutex::new(None)),
253        })
254    }
255
256    /// Migrates a pre-0.4 experimental Fjall directory to format v1, then opens it.
257    pub fn migrate_legacy_store(path: impl AsRef<Path>) -> Result<Self> {
258        let path = path.as_ref();
259        {
260            let disk = DurableStore::open_fjall(path, false)?;
261            disk.migrate_legacy_to_v1()?;
262        }
263        Self::open_with(OpenOptions::fjall(path))
264    }
265
266    /// Returns capability bits for this model.
267    #[must_use]
268    pub fn capabilities(&self) -> StorageCapabilities {
269        match &self.disk {
270            None => StorageCapabilities::memory(),
271            Some(disk) => disk.capabilities(self.read_only),
272        }
273    }
274
275    /// Returns the storage backend for this model.
276    #[must_use]
277    pub fn backend(&self) -> StorageBackend {
278        match &self.disk {
279            None => StorageBackend::Memory,
280            Some(disk) => disk.backend_id(),
281        }
282    }
283
284    /// Returns the underlying Oxigraph store.
285    ///
286    /// This is an escape hatch for advanced Oxigraph use. Mutations through the
287    /// returned handle bypass Oxiland's lock and Fjall durability sync.
288    /// Prefer [`Model::insert_quad`], [`Model::transaction`], and
289    /// [`crate::Update`] so memory and disk stay aligned.
290    #[must_use]
291    pub fn store(&self) -> &Store {
292        &self.store
293    }
294
295    pub(crate) fn with_read_lock<R>(&self, f: impl FnOnce() -> R) -> R {
296        if self.same_thread_in_transaction() {
297            // Avoid deadlocking on the non-reentrant RwLock held by transaction().
298            // Reads see the committed working set, not uncommitted txn mutations.
299            return f();
300        }
301        let _guard = self
302            .lock
303            .read()
304            .unwrap_or_else(std::sync::PoisonError::into_inner);
305        f()
306    }
307
308    /// Reports whether a named storage backend is available in this build.
309    pub fn storage_backend_available(name: &str) -> Result<bool> {
310        let backend = StorageBackend::from_name(name)?;
311        Ok(storage::compiled_backends().contains(&backend))
312    }
313
314    fn same_thread_in_transaction(&self) -> bool {
315        if !self.in_transaction.load(Ordering::Acquire) {
316            return false;
317        }
318        let owner = self
319            .txn_owner
320            .lock()
321            .unwrap_or_else(std::sync::PoisonError::into_inner);
322        *owner == Some(std::thread::current().id())
323    }
324
325    fn ensure_writable(&self) -> Result<()> {
326        if self.read_only {
327            return Err(Error::Unsupported(
328                "model was opened read-only; mutating APIs are unavailable".into(),
329            ));
330        }
331        if self.in_transaction.load(Ordering::Acquire) {
332            return Err(Error::Unsupported(
333                "auto-commit mutation is unavailable while a Model::transaction is open; use the transaction handle"
334                    .into(),
335            ));
336        }
337        Ok(())
338    }
339
340    /// Runs `f` inside an Oxigraph transaction; Fjall models sync on commit.
341    ///
342    /// Same-thread `Model` reads (`len` / `find` / `Query::execute`) during the
343    /// callback see the last committed working set and do not deadlock. Use
344    /// [`ModelTransaction`] methods for in-transaction mutations. Nested
345    /// `transaction` / auto-commit writes return [`Error::Unsupported`].
346    pub fn transaction<R>(
347        &self,
348        f: impl FnOnce(&mut ModelTransaction<'_>) -> Result<R>,
349    ) -> Result<R> {
350        if self.read_only {
351            return Err(Error::Unsupported(
352                "model was opened read-only; mutating APIs are unavailable".into(),
353            ));
354        }
355        if self.same_thread_in_transaction() {
356            return Err(Error::Unsupported(
357                "nested Model::transaction is unsupported".into(),
358            ));
359        }
360        let _guard = self
361            .lock
362            .write()
363            .unwrap_or_else(std::sync::PoisonError::into_inner);
364        if self
365            .in_transaction
366            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
367            .is_err()
368        {
369            return Err(Error::Unsupported(
370                "nested Model::transaction is unsupported".into(),
371            ));
372        }
373        *self
374            .txn_owner
375            .lock()
376            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(std::thread::current().id());
377        let _txn_flag = InTransactionGuard { model: self };
378        let oxi = self
379            .store
380            .start_transaction()
381            .map_err(|error| Error::Storage(error.to_string()))?;
382        let mut tx = ModelTransaction { inner: oxi };
383        let value = f(&mut tx)?;
384        let ModelTransaction { inner } = tx;
385        inner
386            .commit()
387            .map_err(|error| Error::Storage(error.to_string()))?;
388        if let Some(disk) = &self.disk {
389            if let Err(error) = disk.replace_all_from_store(&self.store) {
390                if let Err(reload_error) = self.reload_store_from_disk_unlocked(disk) {
391                    return Err(Error::Storage(format!(
392                        "durable sync failed after transaction ({error}); rollback from disk also failed ({reload_error})"
393                    )));
394                }
395                return Err(Error::Storage(format!(
396                    "durable sync failed after transaction; in-memory store rolled back to disk: {error}"
397                )));
398            }
399        }
400        Ok(value)
401    }
402
403    /// Forces a durable sync (Fjall `SyncAll`). No-op success for memory models.
404    pub fn sync(&self) -> Result<()> {
405        if self.same_thread_in_transaction() {
406            return Err(Error::Unsupported(
407                "sync is unavailable while a Model::transaction is open on this thread".into(),
408            ));
409        }
410        let _guard = self
411            .lock
412            .write()
413            .unwrap_or_else(std::sync::PoisonError::into_inner);
414        match &self.disk {
415            Some(disk) => disk.sync(),
416            None => Ok(()),
417        }
418    }
419
420    /// Clears all statements (and named graphs) from the model.
421    pub fn clear(&self) -> Result<()> {
422        self.ensure_writable()?;
423        let _guard = self
424            .lock
425            .write()
426            .unwrap_or_else(std::sync::PoisonError::into_inner);
427        self.store
428            .clear()
429            .map_err(|error| Error::Storage(error.to_string()))?;
430        if let Some(disk) = &self.disk {
431            if let Err(error) = disk.clear_quads() {
432                if let Err(reload_error) = self.reload_store_from_disk_unlocked(disk) {
433                    return Err(Error::Storage(format!(
434                        "durable clear failed ({error}); rollback from disk also failed ({reload_error})"
435                    )));
436                }
437                return Err(error);
438            }
439        }
440        Ok(())
441    }
442
443    /// Clears a single graph/context.
444    pub fn clear_graph(&self, graph_name: impl Into<GraphName>) -> Result<()> {
445        self.ensure_writable()?;
446        let graph_name = graph_name.into();
447        let _guard = self
448            .lock
449            .write()
450            .unwrap_or_else(std::sync::PoisonError::into_inner);
451        self.store
452            .clear_graph(graph_name.as_ref())
453            .map_err(|error| Error::Storage(error.to_string()))?;
454        if let Some(disk) = &self.disk {
455            if let Err(error) = disk.replace_all_from_store(&self.store) {
456                if let Err(reload_error) = self.reload_store_from_disk_unlocked(disk) {
457                    return Err(Error::Storage(format!(
458                        "durable clear_graph failed ({error}); rollback from disk also failed ({reload_error})"
459                    )));
460                }
461                return Err(error);
462            }
463        }
464        Ok(())
465    }
466
467    /// Inserts many quads inside a single transaction (then durable sync).
468    ///
469    /// Returns the number of quads in the input iterator (including duplicates
470    /// that were already present), not the count of newly inserted quads.
471    pub fn bulk_insert_quads(&self, quads: impl IntoIterator<Item = Quad>) -> Result<usize> {
472        let quads: Vec<_> = quads.into_iter().collect();
473        let total = quads.len();
474        self.transaction(|tx| {
475            for quad in quads {
476                tx.insert_quad(quad)?;
477            }
478            Ok(total)
479        })
480    }
481
482    /// Exports the model as N-Quads to a filesystem path (archival helper).
483    pub fn export_nquads_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
484        let path = path.as_ref();
485        let file = File::create(path).map_err(|error| {
486            Error::Io(std::io::Error::new(
487                error.kind(),
488                format!("{}: {}", path.display(), error),
489            ))
490        })?;
491        let mut serializer =
492            RdfSerializer::from_format(RdfFormat::NQuads).for_writer(BufWriter::new(file));
493        for item in self.find(StatementPattern::default()) {
494            let quad = item?;
495            serializer
496                .serialize_quad(QuadRef::from(&quad))
497                .map_err(Error::Io)?;
498        }
499        let writer = serializer.finish().map_err(Error::Io)?;
500        writer
501            .into_inner()
502            .map_err(|error| Error::Io(error.into_error()))?;
503        Ok(())
504    }
505
506    /// Imports N-Quads from a path inside a transaction (atomic on success).
507    ///
508    /// Quads are **merged** into the existing model (RDF union); this does not
509    /// clear the store first. A leading UTF-8 BOM is skipped when present.
510    pub fn import_nquads_from_path(&self, path: impl AsRef<Path>) -> Result<usize> {
511        let path = path.as_ref();
512        let file = File::open(path).map_err(|error| {
513            Error::Io(std::io::Error::new(
514                error.kind(),
515                format!("{}: {}", path.display(), error),
516            ))
517        })?;
518        let reader = BomStrippingReader::new(BufReader::new(file));
519        let quads = RdfParser::from_format(RdfFormat::NQuads)
520            .rename_blank_nodes()
521            .for_reader(reader)
522            .collect::<std::result::Result<Vec<_>, _>>()
523            .map_err(map_rdf_parse_error)?;
524        let total = quads.len();
525        self.bulk_insert_quads(quads)?;
526        Ok(total)
527    }
528
529    /// Adds a statement to the default graph.
530    pub fn add(&self, statement: impl Into<Triple>) -> Result<bool> {
531        self.add_to_graph(statement, GraphName::DefaultGraph)
532    }
533
534    /// Adds a statement to a named graph/context.
535    pub fn add_to_graph(
536        &self,
537        statement: impl Into<Triple>,
538        graph_name: impl Into<GraphName>,
539    ) -> Result<bool> {
540        let triple = statement.into();
541        let quad = Quad::new(triple.subject, triple.predicate, triple.object, graph_name);
542        self.insert_quad(quad)
543    }
544
545    /// Inserts a fully formed quad into the model.
546    pub fn insert_quad(&self, quad: Quad) -> Result<bool> {
547        self.ensure_writable()?;
548        let _guard = self
549            .lock
550            .write()
551            .unwrap_or_else(std::sync::PoisonError::into_inner);
552        let inserted = !self
553            .store
554            .contains(quad.as_ref())
555            .map_err(|error| Error::Storage(error.to_string()))?;
556        if !inserted {
557            return Ok(false);
558        }
559        self.store
560            .insert(&quad)
561            .map_err(|error| Error::Storage(error.to_string()))?;
562        if let Some(disk) = &self.disk {
563            let canonical = storage::stored_matching_quad(&self.store, &quad)?;
564            if let Err(error) = disk.insert_quad(&canonical) {
565                if let Err(reload_error) = self.reload_store_from_disk_unlocked(disk) {
566                    let _ = self.store.remove(canonical.as_ref());
567                    return Err(Error::Storage(format!(
568                        "durable insert failed ({error}); rollback from disk also failed ({reload_error})"
569                    )));
570                }
571                return Err(error);
572            }
573        }
574        Ok(true)
575    }
576
577    /// Removes a fully formed quad from the model.
578    pub fn remove_quad(&self, quad: &Quad) -> Result<bool> {
579        self.ensure_writable()?;
580        let _guard = self
581            .lock
582            .write()
583            .unwrap_or_else(std::sync::PoisonError::into_inner);
584        let removed = self
585            .store
586            .contains(quad.as_ref())
587            .map_err(|error| Error::Storage(error.to_string()))?;
588        if !removed {
589            return Ok(false);
590        }
591        let canonical = storage::stored_matching_quad(&self.store, quad)?;
592        self.store
593            .remove(quad.as_ref())
594            .map_err(|error| Error::Storage(error.to_string()))?;
595        if let Some(disk) = &self.disk {
596            if let Err(error) = disk.remove_rdf_equal(&canonical) {
597                if let Err(reload_error) = self.reload_store_from_disk_unlocked(disk) {
598                    let _ = self.store.insert(&canonical);
599                    return Err(Error::Storage(format!(
600                        "durable remove failed ({error}); rollback from disk also failed ({reload_error})"
601                    )));
602                }
603                return Err(error);
604            }
605        }
606        Ok(true)
607    }
608
609    /// Removes a statement from the default graph.
610    pub fn remove(&self, statement: impl Into<Triple>) -> Result<bool> {
611        self.remove_from_graph(statement, GraphName::DefaultGraph)
612    }
613
614    /// Removes a statement from a named graph/context.
615    pub fn remove_from_graph(
616        &self,
617        statement: impl Into<Triple>,
618        graph_name: impl Into<GraphName>,
619    ) -> Result<bool> {
620        let triple = statement.into();
621        let quad = Quad::new(triple.subject, triple.predicate, triple.object, graph_name);
622        self.remove_quad(&quad)
623    }
624
625    /// Tests whether the default graph contains a statement.
626    pub fn contains(&self, statement: TripleRef<'_>) -> Result<bool> {
627        self.contains_in_graph(statement, GraphNameRef::DefaultGraph)
628    }
629
630    /// Tests whether a named graph/context contains a statement.
631    pub fn contains_in_graph(
632        &self,
633        statement: TripleRef<'_>,
634        graph_name: GraphNameRef<'_>,
635    ) -> Result<bool> {
636        self.with_read_lock(|| {
637            self.store
638                .contains(oxigraph::model::QuadRef::new(
639                    statement.subject,
640                    statement.predicate,
641                    statement.object,
642                    graph_name,
643                ))
644                .map_err(|error| Error::Storage(error.to_string()))
645        })
646    }
647
648    /// Returns the number of statements across all contexts.
649    pub fn len(&self) -> Result<usize> {
650        self.with_read_lock(|| {
651            self.store
652                .len()
653                .map_err(|error| Error::Storage(error.to_string()))
654        })
655    }
656
657    /// Returns whether the model contains no statements.
658    pub fn is_empty(&self) -> Result<bool> {
659        self.with_read_lock(|| {
660            self.store
661                .is_empty()
662                .map_err(|error| Error::Storage(error.to_string()))
663        })
664    }
665
666    /// Streams quads matching a partial statement/context pattern.
667    pub fn find(&self, pattern: StatementPattern<'_>) -> StatementMatches {
668        self.with_read_lock(|| StatementMatches {
669            inner: self.store.quads_for_pattern(
670                pattern.subject,
671                pattern.predicate,
672                pattern.object,
673                pattern.graph_name,
674            ),
675        })
676    }
677
678    /// Runs a store-mutating SPARQL Update under the write lock, then resyncs
679    /// Fjall.
680    pub(crate) fn run_sparql_update(
681        &self,
682        update: impl FnOnce(&Store) -> Result<()>,
683    ) -> Result<()> {
684        self.ensure_writable()?;
685        let _guard = self
686            .lock
687            .write()
688            .unwrap_or_else(std::sync::PoisonError::into_inner);
689        update(&self.store)?;
690        let Some(disk) = &self.disk else {
691            return Ok(());
692        };
693        if let Err(error) = disk.replace_all_from_store(&self.store) {
694            if let Err(reload_error) = self.reload_store_from_disk_unlocked(disk) {
695                return Err(Error::Storage(format!(
696                    "durable sync failed after SPARQL Update ({error}); rollback from disk also failed ({reload_error})"
697                )));
698            }
699            return Err(Error::Storage(format!(
700                "durable sync failed after SPARQL Update; in-memory store rolled back to disk: {error}"
701            )));
702        }
703        Ok(())
704    }
705
706    fn reload_store_from_disk_unlocked(&self, disk: &DurableStore) -> Result<()> {
707        self.store
708            .clear()
709            .map_err(|error| Error::Storage(error.to_string()))?;
710        disk.load_into(&self.store)
711    }
712}