nordisk/knut
.nornir/README-full.md
# knut — complete overview
> The **real** README: the full pitch, quickstart, design, examples and the
> measured test/bench matrix. The repo-root [`README.md`](../README.md) is the
> slim face (title · mashup · pointers); this chapter is the heavy detail, and it
> is also folded into the manual [`docs/manual.pdf`](../docs/manual.pdf) by `nornir docs book`.
<p align="center"><img src="assets/generated_knut_iceberg.png" alt="knut — a knytpunkt over graphs" width="560"></p>
# knut
> A pure-Rust **columnar ↔ graph fabric**: read/write Apache **GraphAr** files, bulk-load
> them into **FalkorDB**, and serve **Cypher over Arrow Flight SQL** — plus **Bifröst**,
> which streams **Iceberg** tables into a live graph by CDC (Spark-Connect *or* Spark-free).
> One Arrow pipe from the lakehouse to a queryable graph.
**Why knut** — it carries graph data the whole way from a columnar lake to a live, queryable
graph in one Arrow pipe (no JVM required) — the rows marked **NA** are categories the rivals
have no concept for:
| capability | **knut** | GraphAr (official) | Neo4j / GDS | Spark + GraphFrames |
|---|:--:|:--:|:--:|:--:|
| Pure-**Rust** GraphAr — 5 formats (Parquet/CSV/ORC/JSON/Arrow), local **or** object store | **✅** | ◐ (C++/Java/Py) | ✗ | ◐ |
| **Cypher over Arrow Flight SQL** server — ADBC / Power BI / DataFusion clients, Arrow out | **✅** | **NA** | ◐ | ✗ |
| **Iceberg → graph CDC** fabric — snapshot-incremental + changelog (`_change_type` → MERGE / DETACH DELETE) | **✅** | **NA** | ✗ | ◐ |
| **Dual-engine CDC** — Spark 4.2's native `CHANGES` clause *and* Iceberg's `create_changelog_view`, chosen by **probing** the live server | **✅** | **NA** | ✗ | ◐ |
| CDC **fallback is never silent** — `fell_back` + the engine's own reason in the log **and** in `state_json` | **✅** | **NA** | ✗ | ✗ |
| **Renderable** change rows — `(op, before, after, snapshot, ts)` Arrow, streamed batch-by-batch to a UI pane | **✅** | **NA** | ◐ | ✗ |
| **Projection + predicate pushed into the CDC read** (not just full-table reads) | **✅** | **NA** | ✗ | ◐ |
| **Spark-free** Iceberg ingestion (skade `IcebergSource`, no JVM) | **✅** | **NA** | ✗ | ✗ |
| Bulk columnar `RecordBatch` → **live graph DB** (FalkorDB UNWIND/CREATE) | **✅** | ◐ | ✅ | ✅ |
| Mature graph-algorithm library + production graph engine | ◐ | ✗ | **✅** | **✅** |
*Full numbers — generated by `nornir bench run knut`. Rows marked **NA**: the rival has no such category.*
> **New here? → [knut for dummies](.nornir/knut-for-dummies.md)** — a hands-on,
> copy-paste tour that starts the containers and runs every feature.
>
> **Design docs.** This README is the *face*. The deep design lives in the
> nornir doc set under [`.nornir/`](.nornir/): the [map](.nornir/design.md) and
> one *territory* doc per subsystem —
> [graphar](.nornir/graphar.md) ·
> [graphar-falkordb](.nornir/graphar-falkordb.md) ·
> [graphar-flight](.nornir/graphar-flight.md) ·
> [knut-bifrost](.nornir/bifrost.md) ·
> [knut-pipelines](.nornir/pipelines.md). Locked decisions and open work are in
> [`.nornir/plan.md`](.nornir/plan.md); rendered docs (PDF + HTML) in [`docs/`](../docs/).
## What it does
- **`graphar`** — read & write Apache GraphAr (gar/v1) property graphs as chunked columnar
files: 5 formats (Parquet/CSV/ORC/JSON/Arrow IPC), local filesystem or any object store
(S3/GCS/Azure/SeaweedFS/RustFS).
- **`graphar-falkordb`** — bulk-load Arrow `RecordBatch`es into a FalkorDB graph via batched
`UNWIND … CREATE` Cypher, endpoints matched by the shared `_gar_id` linking key.
- **`graphar-flight`** — an Arrow Flight SQL gRPC server that takes **Cypher** in and streams
**Arrow** out; auth (bearer / Basic-handshake / mTLS); graph → lakehouse export (GraphAr or
Iceberg). Any ADBC / Power BI / DataFusion client works.
- **`knut-bifrost` (Bifröst 🌈)** — watch Iceberg tables (Spark Connect *or* Spark-free via
skade) and continuously materialise them into FalkorDB with changelog CDC and an idempotent
`MERGE` sink.
- **`knut` (Python) + `knut-pipelines`** — a pure-Python Flight SQL client (Cypher → pandas →
NetworkX) and a live viewer for Spark Declarative Pipelines / Connect jobs (`knutpipe`).
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ GraphAr files (disk) │
│ vertex/Person/id/chunk0.parquet │
│ edge/Person_knows_Person/ordered_by_source/adj_list/... │
└───────────────┬─────────────────────────────────────────────────┘
│ graphar (Rust)
│ VertexReader / EdgeReader / VerticesBuilder / EdgesBuilder
▼
┌─────────────────────────────────────────┐
│ Apache Arrow RecordBatch (in-memory) │
└───────────────┬─────────────────────────┘
│ graphar-falkordb (Rust)
│ FalkorDbLoader — UNWIND … CREATE (Cypher)
▼
┌─────────────────────────────────────────┐
│ FalkorDB (Redis / Valkey module) │
│ nodes with _gar_id property │
└───────────────┬─────────────────────────┘
│ graphar-flight (Rust)
│ Arrow Flight SQL gRPC — GRAPH.QUERY → RecordBatch stream
▼
┌─────────────────────────────────────────┐
│ Arrow Flight SQL wire protocol │
└───────────────┬─────────────────────────┘
│ knut (Python)
│ KnutClient — DoGet / DoPut
▼
┌─────────────────────────────────────────┐
│ pandas DataFrame → NetworkX Graph │
│ nx.from_pandas_edgelist │
│ nx.set_node_attributes │
└─────────────────────────────────────────┘
```
## Components
### Rust crates
| Crate | Description |
|---|---|
| [`graphar`](#graphar) | GraphAr gar/v1 format — read & write chunked property graphs (5 formats, local or object store) |
| [`graphar-falkordb`](#graphar-falkordb) | Bulk-load Arrow `RecordBatch`es into FalkorDB |
| [`graphar-flight`](#graphar-flight) | Arrow Flight SQL server — Cypher in, Arrow out; auth (bearer / Basic-handshake / mTLS); graph → lakehouse export (GraphAr file or Iceberg table) |
| [`knut-bifrost`](bifrost-design.md) | Bifröst 🌈 — watch Iceberg (Spark Connect *or* Spark-free via skade) or file drops, transform (incl. changelog CDC), sync to FalkorDB; Airflow AIP-82; file-or-Valkey state |
| [`knut-pipelines`](crates/knut-pipelines/docs/pipelines-viewer.md) | Viewer for Spark Declarative Pipelines + Spark Connect jobs — live DAG (egui-snarl) + job progress; `knutpipe` CLI/GUI |
| `xtask` | nornir bench suite (`cargo bench -p xtask`) |
### Python client
| Package | Description |
|---|---|
| [`knut`](#python-client-knut) | Flight SQL client — Cypher queries → pandas → NetworkX |
---
## Prerequisites
| Dependency | Version | Notes |
|---|---|---|
| Rust | stable ≥ 1.85 | Rust 2024 edition |
| Python | ≥ 3.11 | for the `knut` client |
| FalkorDB | latest | Redis/Valkey graph module |
**Start FalkorDB with Docker:**
```bash
podman run -p 6379:6379 localhost/knut-falkordb-valkey9:latest
```
---
## Quick start
### 1. Write a graph to disk (GraphAr format)
```rust
use graphar::{
VertexInfo, PropertyGroup, Property, DataType, FileType,
VerticesBuilder, PropertyValue, Vertex,
};
let vi = VertexInfo::new("Person", 262_144)
.with_property_group(PropertyGroup::new(
vec![
Property::new("id", DataType::Int64).primary(),
Property::new("name", DataType::String),
],
FileType::Parquet,
));
let mut builder = VerticesBuilder::new(vi, "./graph");
builder.add_vertex(Vertex::new()
.add_property("id", PropertyValue::Int64(0))
.add_property("name", PropertyValue::String("Alice".into())));
builder.dump()?;
```
### 2. Load into FalkorDB
```rust
use graphar_falkordb::FalkorDbLoader;
let mut loader = FalkorDbLoader::connect("redis://127.0.0.1:6379", "mygraph").await?;
loader.create_index("Person", "_gar_id").await?;
loader.insert_vertices("Person", &batches).await?;
loader.insert_edges("Person", "KNOWS", "Person", &edge_batches).await?;
```
### 3. Start the Flight SQL server
```rust
use std::sync::Arc;
use graphar_flight::{SchemaRegistry, run_server};
// Empty registry works — types are inferred automatically from FalkorDB's response.
// Optionally register queries for explicit typed schemas.
let registry = Arc::new(SchemaRegistry::new());
registry.register_vertex_query(
"MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name",
&["name"],
);
registry.register_edge_query(
"MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a._gar_id AS src, b._gar_id AS dst",
&[],
);
run_server("redis://127.0.0.1:6379", "mygraph", registry, "0.0.0.0:50051".parse()?).await?;
```
### 4. Query from Python → NetworkX
```python
from knut import KnutClient
G = KnutClient().to_networkx(
vertex_query="MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name",
edge_query="MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a._gar_id AS src, b._gar_id AS dst",
)
print(G.nodes[0]) # {'name': 'Alice'}
```
---
## The `_gar_id` linking key
All components share a single identifier: `_gar_id`, the 0-based row index of each vertex across all batches. It is the glue between GraphAr's internal vertex ID, FalkorDB node properties, and NetworkX node IDs.
```
GraphAr row index 0 ──► FalkorDB node { _gar_id: 0, name: "Alice" }
│
edge { src: 0, dst: 1 } ──► MATCH (a { _gar_id: 0 }), (b { _gar_id: 1 })
│
NetworkX node 0 with attrs { name: "Alice" }
```
- `graphar-falkordb` sets `_gar_id` on every vertex during `insert_vertices`.
- `insert_edges` matches endpoints by `_gar_id`, so no user-defined primary key is required.
- `knut` uses `_gar_id` as the NetworkX node identifier and join key for node attributes.
---
## `graphar`
Implements the [GraphAr gar/v1 specification](https://graphar.apache.org/docs/specification/format) for reading and writing property graphs as chunked columnar files.
### Concepts
- **Vertex chunks** — a vertex type is split into fixed-size chunks (default 2¹⁸ = 262,144 rows), each chunk stored as a separate Parquet or CSV file.
- **Property groups** — properties are stored in separate column groups so only the columns you need are read from disk.
- **Edge chunks** — edges are partitioned by vertex chunk alignment (CSR / CSC / COO), with offset tables for ordered adjacency lists enabling O(degree) edge lookups.
- **Metadata** — `GraphInfo`, `VertexInfo`, and `EdgeInfo` YAML files describe the full schema and file layout. They are the entry point for both readers and writers.
### File layout
```
<prefix>/
<graph>.graph.yml
<label>.vertex.yml
vertex/<label>/<prop_group>/chunk0.parquet
<src>_<edge>_<dst>.edge.yml
edge/<src>_<edge>_<dst>/ordered_by_source/adj_list/part0/chunk0.parquet
edge/<src>_<edge>_<dst>/ordered_by_source/offset/chunk0.parquet
edge/<src>_<edge>_<dst>/ordered_by_source/<prop_group>/part0/chunk0.parquet
```
`part{n}` is the vertex-chunk partition index; `chunk{n}` is the edge-chunk index within that partition.
### Writing a graph
```rust
use graphar::{
AdjListInfo, AdjListType, DataType, Edge, EdgeInfo,
EdgesBuilder, FileType, Property, PropertyGroup, PropertyValue,
Vertex, VertexInfo, VerticesBuilder,
};
// Define vertex schema
let vertex_info = VertexInfo::new("Person", 100)
.with_property_group(PropertyGroup::new(
vec![
Property::new("id", DataType::Int64).primary(),
Property::new("name", DataType::String),
],
FileType::Parquet,
));
// Write vertices
let mut vb = VerticesBuilder::new(vertex_info.clone(), "./data");
vb.add_vertex(Vertex::new()
.add_property("id", PropertyValue::Int64(0))
.add_property("name", PropertyValue::String("Alice".into())));
vb.dump()?;
// Define edge schema (CSR — ordered by source vertex)
let edge_info = EdgeInfo::new("Person", "knows", "Person", 1024, 100, 100, false)
.with_adj_list(AdjListInfo {
ordered: true,
aligned_by: "src".into(),
file_type: FileType::Parquet,
prefix: None,
});
// Write edges
let mut eb = EdgesBuilder::new(edge_info, "./data", AdjListType::OrderedBySource, 2);
eb.add_edge(Edge::new(0, 1));
eb.dump()?;
```
### Reading a graph
```rust
use graphar::{GraphInfo, VertexReader};
let graph_info = GraphInfo::load("./data/my_graph.graph.yml")?;
let vertex_info = graph_info.get_vertex_info("Person", "./data")?;
let reader = VertexReader::new(vertex_info.clone(), "./data", 1000);
let group = &vertex_info.property_groups[0];
let batches = reader.read_property_group(group)?; // Vec<RecordBatch>
for batch in &batches {
println!("{} rows", batch.num_rows());
}
```
### Supported file formats
| Format | Read | Write |
|---|---|---|
| Parquet | ✓ | ✓ |
| CSV | ✓ | ✓ |
| ORC | ✓ | ✓ |
| JSON (lines) | ✓ | ✓ |
| Arrow IPC | ✓ | ✓ |
All five formats work both on the local filesystem (`graphar::io`) and
against any object store (`graphar::storage::ChunkStore`): S3, GCS, Azure,
plus S3-compatible endpoints — SeaweedFS, RustFS, MinIO — via
`ChunkStore::s3_compatible(endpoint, bucket, key, secret, prefix)`.
### Supported data types
`Bool`, `Int32`, `Int64`, `Float`, `Double`, `String`, `Date`, `Timestamp`, `Time`, `List`
---
## `graphar-falkordb`
Bulk-loads Apache Arrow `RecordBatch`es into a [FalkorDB](https://www.falkordb.com/) graph (Redis/Valkey) using batched `UNWIND … CREATE` Cypher queries.
### How it works
Each `GRAPH.QUERY` call sends up to 512 rows (configurable) as an inline `UNWIND` list:
```cypher
UNWIND [{`_gar_id`: 0, `name`: 'Alice'}, {`_gar_id`: 1, `name`: 'Bob'}] AS row
CREATE (n:`Person`) SET n = row
```
For edges, the server MATCHes the endpoints by `_gar_id` and sets edge properties:
```cypher
UNWIND [{`_src`: 0, `_dst`: 1, `since`: '2020'}] AS row
MATCH (a:`Person` {`_gar_id`: row._src}), (b:`Person` {`_gar_id`: row._dst})
CREATE (a)-[r:`KNOWS`]->(b) SET r = row
REMOVE r._src, r._dst
```
### Usage
```rust
use graphar_falkordb::FalkorDbLoader;
let mut loader = FalkorDbLoader::connect("redis://127.0.0.1:6379", "mygraph").await?;
// Index _gar_id before inserting edges — makes MATCH fast
loader.create_index("Person", "_gar_id").await?;
// Insert vertices — each RecordBatch column becomes a node property
loader.insert_vertices("Person", &vertex_batches).await?;
// Insert edges — batch must have Int64 `src` and `dst` columns
loader.insert_edges("Person", "KNOWS", "Person", &edge_batches).await?;
```
### Merging GraphAr property groups before insert
GraphAr stores properties split across multiple group files. Merge them horizontally before inserting into FalkorDB:
```
VertexReader → [PropertyGroup RecordBatch, ...]
↓
merge_property_groups(groups) — or reader.read_merged()
↓
FalkorDbLoader::insert_vertices(label, &[merged_batch])
```
### Batch size
Default is 512 rows per `GRAPH.QUERY` call. Tune with `.with_batch_size(n)?`.
### Arrow → Cypher type mapping
| Arrow type | Cypher literal |
|---|---|
| `Int32` / `Int64` | integer, e.g. `42` |
| `Float32` / `Float64` | float, e.g. `3.14` |
| `Boolean` | `true` / `false` |
| `Utf8` | quoted string, e.g. `'Alice'` |
| `null` | `null` |
---
## `graphar-flight`
An [Apache Arrow Flight SQL](https://arrow.apache.org/docs/format/FlightSql.html) gRPC server that accepts **Cypher** queries instead of SQL, executes them against FalkorDB via `GRAPH.QUERY`, and streams results as Arrow `RecordBatch`es.
Any Flight SQL-compatible client works: [ADBC](https://arrow.apache.org/docs/format/ADBC.html), DataFusion, DBeaver (Flight plugin), `flightsql-dbapi` (Python), the `knut` Python client included in this repo, etc.
### Protocol flow
```
Client Server (graphar-flight)
│ │
│── GetFlightInfo(CommandStatementQuery) ─►│ look up schema in registry
│◄─ FlightInfo(schema, ticket) ───────────│
│ │
│── DoGet(ticket) ───────────────────────►│ GRAPH.QUERY → RecordBatch
│◄─ FlightData stream ────────────────────│
│ │
│── DoPut(CommandStatementUpdate, │
│ Arrow stream) ────────────────►│ UNWIND … CREATE in FalkorDB
│◄─ affected row count ───────────────────│
```
### Schema registry (optional)
The server has two modes:
| Mode | How | When to use |
|---|---|---|
| **Auto** (default) | Column names and types inferred from the FalkorDB response. `Int` → `Int64`, `Double` → `Float64`, `Boolean` → `Boolean`, strings → `Utf8`. | Works for any query with zero setup. |
| **Registered** | Schema pre-declared via `register_vertex_query`, `register_edge_query`, or `register`. | When you want guaranteed types regardless of data, or need to catch schema drift early. |
In auto mode the server peeks at the first non-null value in each column to determine its Arrow type, so integer IDs like `_gar_id`, `src`, and `dst` arrive as `Int64` by default — correct NetworkX node IDs with no configuration needed.
#### Registering typed schemas (optional, but faster)
Two convenience helpers cover the common patterns. Both set `_gar_id`, `src`,
and `dst` to `Int64` (native integers, not strings), and append any extra
column names as `Utf8`.
```rust
use std::sync::Arc;
use graphar_flight::{SchemaRegistry, run_server};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// An empty registry is valid — unregistered queries use auto-string mode.
let registry = Arc::new(SchemaRegistry::new());
// Vertex query: _gar_id → Int64, extra columns → Utf8.
// The RETURN clause must alias the internal ID as _gar_id and list
// extra columns in the same order as the slice.
registry.register_vertex_query(
"MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name, n.age",
&["name", "age"],
);
// Edge query: src + dst → Int64, extra columns → Utf8.
// Alias source _gar_id as src, destination _gar_id as dst.
registry.register_edge_query(
"MATCH (a:Person)-[r:KNOWS]->(b:Person) \
RETURN a._gar_id AS src, b._gar_id AS dst, r.since",
&["since"],
);
// Edges with no properties — just topology.
registry.register_edge_query(
"MATCH (a:Person)-[:FOLLOWS]->(b:Person) \
RETURN a._gar_id AS src, b._gar_id AS dst",
&[],
);
// Full control: use register() directly for custom types (e.g. age as Int64).
// registry.register("...", Arc::new(Schema::new(vec![...])));
run_server(
"redis://127.0.0.1:6379",
"mygraph",
registry,
"0.0.0.0:50051".parse()?,
).await?;
Ok(())
}
```
### Bulk insert (DoPut)
The `CommandStatementUpdate` query string is a directive, not a Cypher statement:
| Directive | Action |
|---|---|
| `VERTICES:<label>` | Insert vertices from the Arrow stream |
| `EDGES:<src>:<type>:<dst>` | Insert edges (stream must have `src` + `dst` Int64 columns) |
### Redis → Arrow type mapping
Both registered and auto-string modes use the same coercions.
In auto-string mode the type is **inferred from the first non-null value** in each column.
| Redis RESP value | Arrow column type |
|---|---|
| `Int` | `Int64` |
| `Double` | `Float64` |
| `Boolean` | `Boolean` |
| `BulkString` / `SimpleString` | `Utf8` |
| `Nil` | `null` (type inferred from next non-null row; falls back to `Utf8` if all null) |
Integer properties like `_gar_id`, `src`, and `dst` arrive as `Int64` in both
modes — no registration needed for correct NetworkX node IDs.
---
## Python client (`knut`)
Located in `python/`. A pure-Python Arrow Flight SQL client that queries the `graphar-flight` server and builds [NetworkX](https://networkx.org/) graphs directly from the Arrow results.
No intermediate format conversion is needed: Arrow → pandas → NetworkX uses `nx.from_pandas_edgelist` and `nx.set_node_attributes` directly.
### Install
```bash
cd python
pip install -e ".[dev]" # installs pyarrow, pandas, networkx
```
### Query → NetworkX
No schema pre-registration is required. The server infers Arrow types from
FalkorDB's response — integers arrive as `Int64`, floats as `Float64`, strings
as `Utf8`. NetworkX node IDs are integers by default.
```python
from knut import KnutClient
client = KnutClient(host="localhost", port=50051)
# One call: fetch vertices + edges, return a NetworkX DiGraph
G = client.to_networkx(
vertex_query="MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name, n.age",
edge_query=(
"MATCH (a:Person)-[r:KNOWS]->(b:Person) "
"RETURN a._gar_id AS src, b._gar_id AS dst, r.since"
),
)
print(G.number_of_nodes(), G.number_of_edges())
print(G.nodes[0]) # {'name': 'Alice', 'age': 30}
print(G[0][1]) # {'since': '2020-01-01'}
# Standard NetworkX algorithms work on the result
print(nx.in_degree_centrality(G))
print(nx.shortest_path(G, source=0, target=3))
```
### Insert
```python
import pandas as pd
from knut import KnutClient
client = KnutClient()
# Vertices — _gar_id is added automatically (0-based row index)
client.insert_vertices("Person", pd.DataFrame({
"name": ["Alice", "Bob", "Carol"],
"age": [30, 25, 35],
}))
# Edges — src/dst reference _gar_id values of the endpoint nodes
client.insert_edges("Person", "KNOWS", "Person", pd.DataFrame({
"src": [0, 0, 1],
"dst": [1, 2, 2],
"since": ["2020-01-01", "2019-06-15", "2021-03-10"],
}))
```
### Raw Arrow query
```python
import pyarrow as pa
table: pa.Table = client.query("MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name")
df = table.to_pandas()
```
### Proto encoding
The client speaks raw gRPC Flight SQL without any generated proto files or `grpc_tools`.
`knut/_proto.py` hand-encodes the two Flight SQL messages used
(`CommandStatementQuery`, `CommandStatementUpdate`) with a small varint
wire-format encoder — the only dependency is `pyarrow`.
### API reference
| Method | Description |
|---|---|
| `KnutClient(host, port, tls)` | Connect to a `graphar-flight` server |
| `query(cypher)` | Run a registered Cypher query → `pyarrow.Table` |
| `query_df(cypher)` | Run a registered Cypher query → `pandas.DataFrame` |
| `insert_vertices(label, df)` | Bulk-insert vertices via DoPut |
| `insert_edges(src, type, dst, df)` | Bulk-insert edges via DoPut |
| `to_networkx(vertex_query, edge_query, ...)` | Fetch graph → `networkx.Graph` |
### Tests
```bash
cd python
pytest tests/ -v # 13 tests, no server required (Rust: 19 unit + 6 doctests)
```
---
## Building & testing
```bash
# Rust — build and test all crates
cargo build
cargo test
# Python — install and test
cd python
pip install -e ".[dev]"
pytest tests/ -v
```
### Running the full stack locally
```bash
# 1. Start FalkorDB
podman run -p 6379:6379 localhost/knut-falkordb-valkey9:latest
# 2. Build and run the Flight SQL server (see graphar-flight example above)
cargo run --example server
# 3. Run the Python demo
cd python
python examples/social_graph.py
```
---
## Bifröst 🌈 — Iceberg tables → FalkorDB graph
`knut-bifrost` watches Iceberg 1.11+ tables and continuously materialises them
as a property graph: snapshot polling, incremental reads, a
declarative-or-custom transform fabric, and an idempotent `MERGE` sink.
Two read lanes: **Spark-free** (`IcebergSource`, feature `skade` — pure Rust, no
JVM, a local warehouse directory) and **Spark Connect** (`SparkConnectSource`,
feature `spark` — the only route to a remote REST/Glue/Hive catalog or an
S3-backed warehouse). Both are **library-only**; the `bifrost` CLI wires
`FileSource` only. Which one to pick, what must be running for each, and the
transfer-method menu into FalkorDB:
[`iceberg-falkordb-guide.md`](iceberg-falkordb-guide.md). Full design:
[`bifrost-design.md`](bifrost-design.md).
```yaml
# bifrost.yml
version: 1
tables:
- table: lake.social.users
mode: incremental
pipeline:
- vertex: { label: Person, id_column: user_id, properties: [name] }
- fk_edges:
type: WORKS_AT
src: { label: Person, column: user_id }
dst: { label: Company, column: company_id }
```
```rust
let source = SparkConnectSource::connect("sc://spark:15002").await?; // feature "spark"
let sink = FalkorDbSink::connect("redis://127.0.0.1:6379", "social").await?;
let mut l = Listener::new(MappingSpec::load("bifrost.yml")?, TransformRegistry::new(),
source, sink, SyncState::open("state.json")?);
l.run(Duration::from_secs(30), |round| { println!("{round:?}"); true }).await?;
```
---
## Test matrix
Unit tests (`cargo test --workspace`, no containers) and a podman-backed
integration matrix (`-- --ignored`, via [testcontainers] over the podman
socket). Container images for the matrix live in [`containers/`](containers/).
| Layer | Unit tests | Live containers (`--ignored`) |
|---|---|---|
| `graphar` formats | round-trip × {parquet, csv, json, orc, arrow-ipc} + empty-input | — |
| `graphar` metadata | YAML round-trips, chunk-path formatting (vertex/edge/offset/property) | — |
| `graphar` merge | two-group row-aligned merge, missing-column errors | — |
| `graphar` storage | `ChunkStore` local round-trip × 5 formats, raw bytes | **SeaweedFS** + **RustFS** S3 round-trips × 5 formats |
| `graphar-falkordb` | Cypher rendering, UNWIND query shape, batch splitting | live load + Cypher count/property checks vs **FalkorDB** and **FalkorDB-on-Valkey 9** (`KNUT_FALKORDB_IMAGE`) |
| `graphar-flight` | auto-schema inference, conversion, registry, named-table resolution, `SELECT … FROM` rewrite, auth (bearer/Basic/open), `skade` Iceberg-export round-trip (no container) | **Power BI path**: live FalkorDB + Basic handshake → bearer, `GetCatalogs`/`GetDbSchemas`/`GetTables`/`GetSqlInfo`, `SELECT * FROM <named query>` via the stock `FlightSqlServiceClient`; graph → GraphAr export round-trip |
| `knut-bifrost` | transforms, mapping YAML→pipeline, custom registry, state ledger, `FileSource` scan/ledger, Airflow asset-event model, **CDC change-row partitioning + `DETACH DELETE`**, **`skade` `IcebergSource`** read + snapshot tracking (no container), **create→detect→ingest lifecycle matrix** + a subprocess test that drives the `bifrost` CLI and asserts its JSON log events | **TOP GUN**: Spark 4.1.2 Connect + Iceberg 1.11.0 → bifröst → FalkorDB (initial + idle + incremental); **OSM** `relations.parquet` → `FileSource` → FalkorDB; real-FalkorDB lifecycle; **Valkey shared-ledger** across instances |
| `knut-pipelines` | DAG model (validate/topo), progress + `ViewerState` folding, proto conversion, snarl build | **live Spark 4.2**: `execute_sql` job progress + a full `define_graph → StartRun` SDP run (QUEUED→…→COMPLETED) |
```bash
# build the matrix images once
podman build -t knut-falkordb-valkey9 -f containers/falkordb-valkey9/Containerfile .
podman build -t knut-spark-iceberg -f containers/spark-iceberg/Containerfile .
# run everything that needs podman
cargo test --workspace -- --ignored
cargo test -p knut-bifrost --features spark --test integration_spark_falkordb -- --ignored
```
[testcontainers]: https://crates.io/crates/testcontainers
## Benchmarks
knut follows the [nornir](../nornir) bench contract — one suite, single-shot
real workloads, one `BenchRun` JSON line for the warehouse. See
[`.nornir/benching.md`](.nornir/benching.md).
```bash
cargo run -p xtask --release --example nornir-bench # canonical
cargo bench -p xtask # same suite
```
---
## Roadmap
| Item | Status |
|---|---|
| GraphAr Parquet + CSV read/write | ✓ done |
| FalkorDB bulk insert via Cypher UNWIND | ✓ done |
| Arrow Flight SQL server (Cypher dialect) | ✓ done |
| Python client → NetworkX | ✓ done |
| `merge_property_groups` / `VertexReader::read_merged` | ✓ done |
| ORC + JSON + Arrow IPC file formats | ✓ done |
| Object storage (S3 / GCS / Azure / SeaweedFS / RustFS via `object_store`) | ✓ done |
| Flight SQL authentication (TLS + bearer token) | ✓ done |
| Bifröst: Spark Connect + Iceberg → transform fabric → FalkorDB | ✓ done |
| Container test matrix (FalkorDB-on-Valkey 9, Spark 4.1.2 + Iceberg 1.11, SeaweedFS, RustFS) | ✓ done |
| nornir bench suite | ✓ done |
| Airflow 3 asset events (AIP-82): emit + `FileSource` listener | ✓ done |
| Power BI / ADBC via Flight SQL metadata surface (catalogs, schemas, tables, SqlInfo, prepared statements) | ✓ done |
| Flight SQL auth: bearer + Basic/handshake (Power BI/ODBC) + mutual TLS | ✓ done |
| Bifröst changelog CDC (Iceberg `_change_type` → upserts + `DETACH DELETE`) | ✓ done |
| Spark-free Iceberg source via `skade` (`IcebergSource`, snapshot-incremental) | ✓ done |
| Graph → lakehouse export: GraphAr file + Iceberg table (via `skade`) | ✓ done |
| Valkey-backed shared `SyncState` + graceful shutdown | ✓ done |
| `Date`/`Timestamp`/`List` → Cypher rendering | ✓ done |
| Spark Declarative Pipelines viewer (`knutpipe pipeline`: define + run + stream) | ✓ done |
| WASM / Flight transform plugins; REST Iceberg catalog; Iceberg-table CDC export | proposed |
The full proposition list with rationale: [`.nornir/plan.md`](.nornir/plan.md).
---
## License
Apache-2.0