Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

.nornir/graphdb-guide.md

Using knut-graphdb

Class: guide. How to read a graph endpoint, and how to write a backend. Source: crates/knut-graphdb/.

Read a FalkorDB

use knut_graphdb::{FalkorBackend, GraphDb};

let db = FalkorBackend::new("127.0.0.1:6379");   // or "redis://…" / "rediss://…"

for name in db.graph_names().await? {            // every graph on the endpoint
    let schema = db.open(&name).await?;          // labels, rel types, keys, counts
    let scene  = db.read(&name, 300).await?;     // nodes + edges, capped
    println!("{name}: {} labels, {} nodes", schema.labels.len(), scene.nodes.len());
}

FalkorBackend::new takes an endpoint and nothing else. A bare host:port is normalised to redis://host:port; a string that already carries a scheme is passed through untouched, so a rediss:// endpoint is never downgraded to plaintext.

Two knobs, both optional:

let db = FalkorBackend::new("rediss://falkor.internal:6379")
    .with_identity(tls)                          // mutual-TLS client identity
    .with_timeout(Duration::from_secs(5));       // per-op bound

The identity is a credential, not a lifecycle knob — it changes how the backend dials, never whether the endpoint exists. Its bytes are never logged: Debug prints mtls: true and nothing else.

The default bound is KORP_GRAPH_READ_TIMEOUT_MS or 30 s, and it can never be zero — a parse to 0 falls back to the default, so the bound always fires.

Read an embedded Cozo (no container)

use knut_graphdb::{CozoBackend, GraphDb};

let db = CozoBackend::shared()?;         // process-global, demo-seeded once
assert_eq!(db.graph_names().await?, vec!["korp".to_string()]);
let scene = db.read("korp", 0).await?;   // 0 ⇒ DEFAULT_SCENE_LIMIT

Cozo speaks Datalog, not Cypher. It does not implement Cypher at all, so there is nothing to stub and nothing to panic from. Ask it for a query bar and you get a value, not a crash:

if !db.cypher_support().is_available() {
    let refusal = knut_graphdb::no_cypher_here(&db);   // GraphError::NoQueryLanguage
}

Run Cypher (only where it exists)

use knut_graphdb::{Cypher, FalkorBackend};

let db = FalkorBackend::new("127.0.0.1:6379");
let sub = db.cypher("social", "MATCH (a)-[r]->(b) RETURN …", 300).await?;

limit is passed to ensure_limit, so a query that bounds itself keeps its own cap and an unbounded one gets LIMIT <limit>. limit = 0 means "the caller already bounded this" and the Cypher reaches the engine verbatim. An empty query falls back to the Person-anchored scene_cypher.

Telling the four answers apart

match db.graph_names().await {
    Ok(names) if names.is_empty() => …,          // REACHABLE and empty
    Ok(names)                     => …,
    Err(GraphError::Connect(_))   => …,          // unreachable
    Err(GraphError::Timeout { .. }) => …,        // past the bound
    Err(e)                        => …,
}

db.open("typo") is GraphError::NotFound, never an empty GraphSchema — and the refused open leaves no stray graph on the endpoint, because open lists before it probes.

Write a backend

Implement five methods:

#[async_trait]
impl GraphDb for MyStore {
    fn kind(&self) -> &'static str { "mystore" }
    fn endpoint(&self) -> &str { &self.url }
    async fn graph_names(&self) -> Result<Vec<String>, GraphError> { … }
    async fn open(&self, graph: &str) -> Result<GraphSchema, GraphError> { … }
    async fn read(&self, graph: &str, limit: usize) -> Result<SubGraph, GraphError> { … }
}

That is a complete backend. Three rules:

  1. Never return Ok(vec![]) for an unreachable endpoint. Empty and unreachable are different answers and callers branch on the difference.
  2. open establishes existence from graph_names BEFORE it probes, and a name that is not there is GraphError::NotFound. If your engine creates a key on first touch (FalkorDB does), the other order silently makes every name valid.
  3. Do not implement Cypher unless you speak it. Leaving it out is the honest answer and costs you nothing — cypher_support() already says None.

Add neighbours / shortest_path / upsert if you can serve them; the defaults refuse with GraphError::Unsupported naming your backend and the op.

Extras that are not on the trait

FalkorBackend::delete_graph(graph) (GRAPH.DELETE) and FalkorBackend::config_get_int(key) (GRAPH.CONFIG GET) are inherent methods: they are FalkorDB server operations, not something every graph backend has. They live here so a consumer never opens its own redis connection to send one — which is exactly how korp ended up with three.

Running the live gates

podman run -d --name falkordb -p 6379:6379 falkordb/falkordb:latest
KNUT_FALKOR_ENDPOINT=127.0.0.1:6379 \
  cargo test -p knut-graphdb --test live_falkordb -- --ignored --nocapture

Every graph they create is namespaced by pid and deleted afterwards, so they run beside another lane's data on a shared endpoint.