WeaveDocs
Gnosis

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.

Note

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

  1. Builds a Triple { subject, predicate, object }.
  2. Encodes the triple with serde_json::to_vec.
  3. Takes the Strand write lock and appends. The Strand assigns a sequence number seq.
  4. 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)
  5. Returns Ok(()).

Costs

OperationComplexity
add_tripleOne 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

VariantCauseRecovery
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 encodeStrings 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.