Skip to main content

vertep_store/
journal.rs

1//! The journal store contract: per-actor, compare-and-set appends of RDF entries.
2
3use std::collections::HashSet;
4
5use oxrdf::dataset::CanonicalizationAlgorithm;
6use oxrdf::vocab::xsd;
7use oxrdf::{
8    Dataset, Graph, GraphName, GraphNameRef, Literal, NamedNode, NamedOrBlankNode, Quad, Term,
9    Triple,
10};
11
12use crate::error::{AppendError, StoreError};
13use crate::vocab;
14
15/// A journal position: the number of an entry in one actor's journal.
16///
17/// Entries are numbered from 1 without gaps; position 0 is the empty journal. An append
18/// "at expected position n" succeeds only while the journal's last entry is n, and writes its
19/// entries at n + 1, n + 2, ...
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct Position(u64);
22
23impl Position {
24    /// The empty journal.
25    pub const ZERO: Position = Position(0);
26
27    /// The position `n`.
28    pub const fn new(n: u64) -> Self {
29        Position(n)
30    }
31
32    /// The number.
33    pub const fn get(self) -> u64 {
34        self.0
35    }
36
37    /// The next position.
38    pub const fn next(self) -> Self {
39        Position(self.0 + 1)
40    }
41
42    /// This position advanced by `n` entries.
43    pub const fn advance(self, n: u64) -> Self {
44        Position(self.0 + n)
45    }
46}
47
48impl From<u64> for Position {
49    fn from(n: u64) -> Self {
50        Position(n)
51    }
52}
53
54/// One journal entry: an RDF dataset with exactly one named graph, named by the entry's IRI.
55///
56/// The entry IRI is chosen by the caller (the runtime derives it from the actor address and the
57/// position, see `runtime-ontology#Stable Runtime IRIs`) and is unique in the store. On append a
58/// store adds three stamps to the graph ([`Entry::stamp`]): `rdf:type vt:JournalEntry`,
59/// `vt:actor` and `vt:position`, so every entry read back is self-describing in `vt:` terms.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct Entry {
62    name: NamedNode,
63    triples: Graph,
64}
65
66impl Entry {
67    /// An entry named `name` holding `triples`.
68    pub fn new(name: NamedNode, triples: impl IntoIterator<Item = Triple>) -> Self {
69        Entry {
70            name,
71            triples: triples.into_iter().collect(),
72        }
73    }
74
75    /// The entry encoded by a dataset, which must hold exactly one named graph, named by an IRI,
76    /// and nothing in the default graph.
77    ///
78    /// # Errors
79    ///
80    /// [`StoreError::Invalid`] for any other dataset shape.
81    pub fn from_dataset(dataset: &Dataset) -> Result<Self, StoreError> {
82        let mut name: Option<NamedNode> = None;
83        let mut triples = Graph::new();
84        for q in dataset {
85            let g = match q.graph_name {
86                GraphNameRef::NamedNode(n) => n.into_owned(),
87                GraphNameRef::BlankNode(_) => {
88                    return Err(StoreError::invalid(
89                        "an entry graph must be named by an IRI",
90                    ));
91                }
92                GraphNameRef::DefaultGraph => {
93                    return Err(StoreError::invalid(
94                        "an entry has no statements in the default graph",
95                    ));
96                }
97            };
98            match &name {
99                None => name = Some(g),
100                Some(n) if *n != g => {
101                    return Err(StoreError::invalid(
102                        "an entry is one named graph, found several",
103                    ));
104                }
105                Some(_) => {}
106            }
107            triples.insert(&Triple::new(
108                q.subject.into_owned(),
109                q.predicate.into_owned(),
110                q.object.into_owned(),
111            ));
112        }
113        let name = name.ok_or_else(|| StoreError::invalid("an entry has at least one quad"))?;
114        Ok(Entry { name, triples })
115    }
116
117    /// The entry IRI, which is also the name of its graph.
118    pub fn name(&self) -> &NamedNode {
119        &self.name
120    }
121
122    /// The statements of the entry graph.
123    pub fn triples(&self) -> &Graph {
124        &self.triples
125    }
126
127    /// The quads of the entry: every statement in the graph named by the entry IRI.
128    pub fn quads(&self) -> Vec<Quad> {
129        let g = GraphName::NamedNode(self.name.clone());
130        self.triples
131            .iter()
132            .map(|t| t.into_owned().in_graph(g.clone()))
133            .collect()
134    }
135
136    /// The entry as an RDF dataset with one named graph.
137    pub fn to_dataset(&self) -> Dataset {
138        self.quads().into_iter().collect()
139    }
140
141    /// The position stamped on the entry (`<entry> vt:position n`), if any.
142    pub fn position(&self) -> Option<Position> {
143        let p = vocab::node(vocab::POSITION);
144        self.triples
145            .objects_for_subject_predicate(&self.name, &p)
146            .find_map(|o| match o {
147                oxrdf::TermRef::Literal(l) if l.datatype() == xsd::INTEGER => {
148                    l.value().parse::<u64>().ok().map(Position)
149                }
150                _ => None,
151            })
152    }
153
154    /// The entry with the store's stamps added: `rdf:type vt:JournalEntry`, `vt:actor <actor>`
155    /// and `vt:position n`.
156    ///
157    /// # Errors
158    ///
159    /// [`StoreError::Invalid`] when the entry already states another actor or position.
160    pub fn stamp(&self, actor: &NamedNode, position: Position) -> Result<Entry, StoreError> {
161        let subject = NamedOrBlankNode::from(self.name.clone());
162        let actor_p = vocab::node(vocab::ACTOR);
163        let pos_p = vocab::node(vocab::POSITION);
164        let pos = Literal::new_typed_literal(position.get().to_string(), xsd::INTEGER);
165        for o in self
166            .triples
167            .objects_for_subject_predicate(&self.name, &actor_p)
168        {
169            if o != actor.as_ref().into() {
170                return Err(StoreError::invalid(format!(
171                    "entry {} states actor {o}, appended to {actor}",
172                    self.name
173                )));
174            }
175        }
176        for o in self
177            .triples
178            .objects_for_subject_predicate(&self.name, &pos_p)
179        {
180            if o != pos.as_ref().into() {
181                return Err(StoreError::invalid(format!(
182                    "entry {} states position {o}, appended at {}",
183                    self.name,
184                    position.get()
185                )));
186            }
187        }
188        let mut triples = self.triples.clone();
189        triples.insert(&Triple::new(
190            subject.clone(),
191            vocab::node(vocab::RDF_TYPE),
192            vocab::node(vocab::JOURNAL_ENTRY),
193        ));
194        triples.insert(&Triple::new(subject.clone(), actor_p, actor.clone()));
195        triples.insert(&Triple::new(subject, pos_p, pos));
196        Ok(Entry {
197            name: self.name.clone(),
198            triples,
199        })
200    }
201
202    /// True when both entries have the same IRI and isomorphic graphs (equal up to blank node
203    /// labels, which a store may rename).
204    pub fn is_isomorphic(&self, other: &Entry) -> bool {
205        if self.name != other.name || self.triples.len() != other.triples.len() {
206            return false;
207        }
208        let mut a = self.triples.clone();
209        let mut b = other.triples.clone();
210        a.canonicalize(CanonicalizationAlgorithm::default());
211        b.canonicalize(CanonicalizationAlgorithm::default());
212        a == b
213    }
214
215    /// True when the graph contains a blank node anywhere.
216    pub fn has_blank_nodes(&self) -> bool {
217        self.triples.iter().any(|t| {
218            matches!(t.subject, oxrdf::NamedOrBlankNodeRef::BlankNode(_))
219                || matches!(t.object, oxrdf::TermRef::BlankNode(_))
220        })
221    }
222}
223
224/// An entry read back from a journal, with its position.
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub struct JournalRecord {
227    /// The entry's position in the actor's journal.
228    pub position: Position,
229    /// The entry, with the store's stamps.
230    pub entry: Entry,
231}
232
233/// A fencing token: a scope (for example a shard) and a monotonically increasing epoch.
234///
235/// A store that declares fencing rejects an append whose epoch is lower than the highest epoch
236/// it has accepted for the scope.
237#[derive(Clone, Debug, PartialEq, Eq, Hash)]
238pub struct FencingToken {
239    /// The scope the epoch counts in, such as a shard IRI.
240    pub scope: NamedNode,
241    /// The epoch.
242    pub epoch: u64,
243}
244
245impl FencingToken {
246    /// A token for `scope` at `epoch`.
247    pub fn new(scope: NamedNode, epoch: u64) -> Self {
248        FencingToken { scope, epoch }
249    }
250}
251
252/// The changes an append makes to the actor's current-state graph (the named graph named by
253/// the actor address): statements of exited states to retract, and statements to assert.
254///
255/// Only stores that declare [`JournalCapabilities::state_in_append`] accept a delta; they
256/// commit it in the same transaction as the entries. Retractions apply before assertions.
257/// Statements may not contain blank nodes, so that a later delta can name them again.
258#[derive(Clone, Debug, Default, PartialEq, Eq)]
259pub struct StateDelta {
260    /// Statements to retract from the current-state graph.
261    pub retract: Vec<Triple>,
262    /// Statements to assert in the current-state graph.
263    pub assert: Vec<Triple>,
264}
265
266impl StateDelta {
267    /// True when the delta changes nothing.
268    pub fn is_empty(&self) -> bool {
269        self.retract.is_empty() && self.assert.is_empty()
270    }
271}
272
273/// Optional metadata of an append.
274#[derive(Clone, Debug, Default, PartialEq, Eq)]
275pub struct AppendMeta {
276    /// The fencing token; `None` is an unfenced append, checked only by compare-and-set.
277    pub fencing: Option<FencingToken>,
278    /// The state changes to commit with the entries.
279    pub state: Option<StateDelta>,
280}
281
282impl AppendMeta {
283    /// No metadata: an unfenced append without state changes.
284    pub fn none() -> Self {
285        AppendMeta::default()
286    }
287
288    /// This metadata with a fencing token.
289    pub fn with_fencing(mut self, token: FencingToken) -> Self {
290        self.fencing = Some(token);
291        self
292    }
293
294    /// This metadata with a state delta.
295    pub fn with_state(mut self, delta: StateDelta) -> Self {
296        self.state = Some(delta);
297        self
298    }
299}
300
301/// What a journal store supports beyond compare-and-set, read before relying on it.
302#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
303pub struct JournalCapabilities {
304    /// The store enforces fencing tokens. A store without fencing rejects an append that
305    /// carries a token with [`AppendError::Unsupported`] instead of ignoring it.
306    pub fencing: bool,
307    /// The store commits a [`StateDelta`] in the same transaction as the entries.
308    pub state_in_append: bool,
309}
310
311/// A journal store: per actor address, a linearizable, all-or-nothing append at an expected
312/// position, and reads from a position.
313// @tg: implements:: [[openspec:storage#Journal Store Contract]]
314// @tg: implements:: [[openspec:storage#Append Fencing Token]]
315pub trait JournalStore: Send + Sync {
316    /// What the store supports beyond compare-and-set.
317    fn capabilities(&self) -> JournalCapabilities;
318
319    /// True when the store enforces fencing tokens.
320    fn supports_fencing(&self) -> bool {
321        self.capabilities().fencing
322    }
323
324    /// Appends `entries` to `actor`'s journal if, and only if, its last entry is at `expected`.
325    ///
326    /// On success the entries are at `expected + 1 ..= expected + entries.len()` and the new
327    /// head is returned. On any error nothing is written: no entry, no state change and no
328    /// fencing epoch. Appends for one actor are linearizable.
329    ///
330    /// # Errors
331    ///
332    /// [`AppendError::Conflict`] when the journal is elsewhere, [`AppendError::Fenced`] for a
333    /// stale fencing epoch, [`AppendError::Unsupported`] for metadata the store does not
334    /// support, [`AppendError::Invalid`] for malformed entries, [`AppendError::Storage`] when
335    /// the store fails.
336    fn append(
337        &self,
338        actor: &NamedNode,
339        expected: Position,
340        entries: &[Entry],
341        meta: &AppendMeta,
342    ) -> Result<Position, AppendError>;
343
344    /// The entries of `actor`'s journal from position `from` (inclusive; 0 reads from the
345    /// start) to the head, in order.
346    ///
347    /// # Errors
348    ///
349    /// [`StoreError`] when the store fails.
350    fn read(&self, actor: &NamedNode, from: Position) -> Result<Vec<JournalRecord>, StoreError>;
351
352    /// The position of `actor`'s last entry, 0 for an empty journal.
353    ///
354    /// # Errors
355    ///
356    /// [`StoreError`] when the store fails.
357    fn head(&self, actor: &NamedNode) -> Result<Position, StoreError>;
358
359    /// Every actor with at least one entry.
360    ///
361    /// # Errors
362    ///
363    /// [`StoreError`] when the store fails.
364    fn actors(&self) -> Result<Vec<NamedNode>, StoreError>;
365}
366
367impl<J: JournalStore + ?Sized> JournalStore for std::sync::Arc<J> {
368    fn capabilities(&self) -> JournalCapabilities {
369        (**self).capabilities()
370    }
371    fn append(
372        &self,
373        actor: &NamedNode,
374        expected: Position,
375        entries: &[Entry],
376        meta: &AppendMeta,
377    ) -> Result<Position, AppendError> {
378        (**self).append(actor, expected, entries, meta)
379    }
380    fn read(&self, actor: &NamedNode, from: Position) -> Result<Vec<JournalRecord>, StoreError> {
381        (**self).read(actor, from)
382    }
383    fn head(&self, actor: &NamedNode) -> Result<Position, StoreError> {
384        (**self).head(actor)
385    }
386    fn actors(&self) -> Result<Vec<NamedNode>, StoreError> {
387        (**self).actors()
388    }
389}
390
391/// The checks every backend runs before an append touches storage, and the stamped entries to
392/// write at `expected + 1 ..`.
393///
394/// It rejects an empty append, two entries with one IRI, an entry named like the actor,
395/// metadata the store does not support and state deltas with blank nodes.
396///
397/// # Errors
398///
399/// [`AppendError::Invalid`] or [`AppendError::Unsupported`].
400pub fn prepare_append(
401    actor: &NamedNode,
402    expected: Position,
403    entries: &[Entry],
404    meta: &AppendMeta,
405    caps: JournalCapabilities,
406) -> Result<Vec<Entry>, AppendError> {
407    if entries.is_empty() {
408        return Err(AppendError::Invalid(
409            "an append has at least one entry".into(),
410        ));
411    }
412    if meta.fencing.is_some() && !caps.fencing {
413        return Err(AppendError::Unsupported(
414            "this journal store does not enforce fencing tokens".into(),
415        ));
416    }
417    if let Some(delta) = &meta.state {
418        if !caps.state_in_append {
419            return Err(AppendError::Unsupported(
420                "this journal store does not commit state changes with entries".into(),
421            ));
422        }
423        let blank = delta.retract.iter().chain(&delta.assert).any(|t| {
424            matches!(t.subject, NamedOrBlankNode::BlankNode(_))
425                || matches!(t.object, Term::BlankNode(_))
426        });
427        if blank {
428            return Err(AppendError::Invalid(
429                "current-state statements cannot contain blank nodes".into(),
430            ));
431        }
432    }
433    let mut seen = HashSet::new();
434    let mut out = Vec::with_capacity(entries.len());
435    for (i, e) in entries.iter().enumerate() {
436        if e.name() == actor {
437            return Err(AppendError::Invalid(format!(
438                "entry {} is named like its actor",
439                e.name()
440            )));
441        }
442        if !seen.insert(e.name().clone()) {
443            return Err(AppendError::Invalid(format!(
444                "entry {} appears twice in one append",
445                e.name()
446            )));
447        }
448        out.push(e.stamp(actor, expected.advance(i as u64 + 1))?);
449    }
450    Ok(out)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    fn iri(s: &str) -> NamedNode {
458        NamedNode::new(format!("urn:test:{s}")).unwrap()
459    }
460
461    #[test]
462    fn dataset_round_trip_and_shape_checks() {
463        let e = Entry::new(iri("e1"), [Triple::new(iri("a"), iri("p"), iri("b"))]);
464        let ds = e.to_dataset();
465        assert_eq!(Entry::from_dataset(&ds).unwrap(), e);
466
467        let mut two = ds.clone();
468        two.insert(&Quad::new(iri("a"), iri("p"), iri("c"), iri("e2")));
469        assert!(Entry::from_dataset(&two).is_err());
470        let mut default = ds;
471        default.insert(&Quad::new(
472            iri("a"),
473            iri("p"),
474            iri("c"),
475            GraphName::DefaultGraph,
476        ));
477        assert!(Entry::from_dataset(&default).is_err());
478        assert!(Entry::from_dataset(&Dataset::new()).is_err());
479    }
480
481    #[test]
482    fn stamp_adds_type_actor_and_position_and_rejects_contradictions() {
483        let e = Entry::new(iri("e1"), [Triple::new(iri("a"), iri("p"), iri("b"))]);
484        let s = e.stamp(&iri("actor"), Position::new(7)).unwrap();
485        assert_eq!(s.triples().len(), 4);
486        assert_eq!(s.position(), Some(Position::new(7)));
487        // stamping twice is idempotent
488        assert_eq!(s.stamp(&iri("actor"), Position::new(7)).unwrap(), s);
489        assert!(s.stamp(&iri("actor"), Position::new(8)).is_err());
490        assert!(s.stamp(&iri("other"), Position::new(7)).is_err());
491    }
492
493    #[test]
494    fn prepare_append_rejects_bad_requests() {
495        let caps = JournalCapabilities::default();
496        let a = iri("actor");
497        let e = Entry::new(iri("e1"), [Triple::new(iri("a"), iri("p"), iri("b"))]);
498        assert!(matches!(
499            prepare_append(&a, Position::ZERO, &[], &AppendMeta::none(), caps),
500            Err(AppendError::Invalid(_))
501        ));
502        assert!(matches!(
503            prepare_append(
504                &a,
505                Position::ZERO,
506                &[e.clone(), e.clone()],
507                &AppendMeta::none(),
508                caps
509            ),
510            Err(AppendError::Invalid(_))
511        ));
512        let fenced = AppendMeta::none().with_fencing(FencingToken::new(iri("shard"), 1));
513        assert!(matches!(
514            prepare_append(&a, Position::ZERO, std::slice::from_ref(&e), &fenced, caps),
515            Err(AppendError::Unsupported(_))
516        ));
517        let stamped = prepare_append(&a, Position::new(3), &[e], &AppendMeta::none(), caps)
518            .unwrap()
519            .remove(0);
520        assert_eq!(stamped.position(), Some(Position::new(4)));
521    }
522}