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:
- Never return
Ok(vec![])for an unreachable endpoint. Empty and unreachable are different answers and callers branch on the difference. openestablishes existence fromgraph_namesBEFORE it probes, and a name that is not there isGraphError::NotFound. If your engine creates a key on first touch (FalkorDB does), the other order silently makes every name valid.- Do not implement
Cypherunless you speak it. Leaving it out is the honest answer and costs you nothing —cypher_support()already saysNone.
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.