Add Triple
Append a fact to Gnosis and learn the durability and indexing guarantees.
What this page covers
The add_triple operation: how a fact lands on disk, how the three indices are updated, and what the operation costs.
Adding a triple is a Strand append plus three in-memory DashMap updates. The on-disk source of truth is the Strand; if the indices are lost (process crash before the next snapshot), they are rebuilt by replaying the Strand on next open_gnosis.
Construction
use std::sync::Arc;
use tokio::sync::RwLock;
use strand::{Strand, StrandConfig};
use gnosis::Gnosis;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let cfg = StrandConfig::new().with_storage(dir.path());
let strand = Arc::new(RwLock::new(Strand::new(cfg).await?));
let gnosis = Gnosis::new(strand);
gnosis.add_triple("alice", "trusts", "bob").await?;
gnosis.add_triple("alice", "trusts", "carol").await?;
gnosis.add_triple("bob", "has_role", "admin").await?;
gnosis.add_triple("carol", "has_role", "viewer").await?;
println!("4 facts persisted to the Strand and indexed by S/P/O");
Ok(())
}What add_triple does
- Builds a
Triple { subject, predicate, object }. - Encodes the triple with
serde_json::to_vec. - Takes the Strand write lock and appends. The Strand assigns a sequence number
seq. - Updates the three in-memory indices, each
DashMap<String, Vec<u64>>:subject_index.entry(subject).or_default().push(seq)predicate_index.entry(predicate).or_default().push(seq)object_index.entry(object).or_default().push(seq)
- Returns
Ok(()).
Costs
| Operation | Complexity |
|---|---|
add_triple | One Strand append + three DashMap inserts |
| Storage per triple | ~ len(s) + len(p) + len(o) + JSON overhead bytes in the Strand, plus index entries |
Index entries are deduplicated by position — adding the same subject twice does not duplicate the subject map key, only its Vec<u64>.
Durability
The Strand append is the linearization point. After add_triple returns Ok(()), the triple is durable on disk. The indices are rebuildable from the Strand on restart.
Errors
| Variant | Cause | Recovery |
|---|---|---|
GnosisError::Strand(String) | Strand append failed (disk full, quorum lost) | Retry — same (s, p, o) produces a new sequence number; idempotency must be enforced at the caller |
GnosisError::Serialization(serde_json::Error) | Triple failed to encode | Strings are always serializable; this should not happen in practice |
Practical patterns
Idempotent add (deduplicate on (s, p, o))
use gnosis::{Gnosis, QueryPattern};
async fn add_unique(g: &Gnosis, s: &str, p: &str, o: &str) -> Result<bool, Box<dyn std::error::Error>> {
let existing = g.query(&QueryPattern {
subject: Some(s.to_string()),
predicate: Some(p.to_string()),
object: Some(o.to_string()),
}).await?;
if !existing.is_empty() {
return Ok(false);
}
g.add_triple(s, p, o).await?;
Ok(true)
}The check-then-add is racy under concurrent writers. For strict uniqueness, gate the add behind a Lens-stored boolean keyed by blake3(s|p|o).
Bulk ingest
use gnosis::Gnosis;
async fn ingest_facts(g: &Gnosis, facts: &[(String, String, String)]) -> Result<usize, Box<dyn std::error::Error>> {
let mut n = 0;
for (s, p, o) in facts {
g.add_triple(s, p, o).await?;
n += 1;
}
Ok(n)
}Each call takes the Strand write lock once. For very large batches, batching at the Strand layer (a future API) reduces lock churn. Today, expect ~10 K-30 K facts/second on a local SSD depending on string length.
What is not persisted
The three in-memory indices are not written to disk. On restart, they are reconstructed by replaying the Strand. See Internals for the rebuild path.