Rindle keeps a query result current by incremental view maintenance (IVM): you build a query, hydrate a materialized view from it, push the changes that happen to the underlying tables, and the view updates by the difference — never by re-running the query. The contract is view-after-push == fresh-query: applying the change stream leaves the view exactly equal to running the query from scratch.
Under the hood, Rindle is the rindle engine (with optional rindle_replica and
rindle_sqlite crates). This page walks the data path end to end. For the shapes a
query may take, see Supported queries; for the change
stream itself, see Change model.
The data path
A query travels through four stages:
- Build an
Ast— either with Deltic, the fluentrindle::tablequery builder, or by deserializing the engine’s JSON wire format. - Lower that
Astinto a wired dataflowGraphof operators withrindle::build_pipeline. - Hydrate a
Viewover the graph to materialize the initial result set. - Push source changes and read the incrementally-maintained
ViewData.
1. Build a query
The fluent builder produces an Ast. Each method returns the builder, and
build finishes it:
use rindle::table;
let ast = table("issue")
.r#where("open", true)
.build();
r#where takes a field name and a value. build consumes the builder and returns
the Ast. (r#where is spelled with the raw-identifier escape because where is a
Rust keyword.)
You can also deserialize an Ast directly from its JSON wire format via serde, which is
how a JS client hands a query to the engine.
2. Lower it into a graph
rindle::build_pipeline walks the Ast and allocates the operator graph. Its
signature is:
pub fn build_pipeline(
graph: &mut rindle::Graph,
ast: &rindle::Ast,
resolve: &impl Fn(&str) -> Option<(rindle::NodeId, rindle::Schema)>,
) -> Result<rindle::NodeId, rindle::BuildError>;
You first register each base table as a source on the Graph, then pass a resolve
closure that maps a table name to its source NodeId and Schema. build_pipeline
returns the NodeId of the pipeline’s top operator (or a BuildError if the Ast is
outside the supported subset):
use std::collections::HashMap;
use rindle::{build_pipeline, table, Graph, NodeId, Schema};
let mut graph = Graph::new();
// Register the base table as an in-memory source. `Schema::new` takes the
// columns, the primary-key column indices, and the sort.
let schema = Schema::new(vec!["id", "open"], vec![0], vec![(0, true)]);
let issue_src: NodeId = graph.add_source(schema.clone(), Vec::new());
// `resolve` maps each table name to (source NodeId, schema).
let mut sources: HashMap<&str, (NodeId, Schema)> = HashMap::new();
sources.insert("issue", (issue_src, schema.clone()));
let resolve = |name: &str| sources.get(name).cloned();
let ast = table("issue").r#where("open", true).build();
let top: NodeId = build_pipeline(&mut graph, &ast, &resolve)
.expect("build the pipeline");
In production prefer the fallible Graph::try_add_source, which validates ingest row
widths and returns a RindleError instead of panicking on a malformed row.
3. Hydrate a view
A View is the materialized sink. Add it over the pipeline’s top operator with
Graph::add_view, wire the final edge with Graph::set_sink_edge, then hydrate:
let view = graph.add_view(top, schema.clone());
graph.set_sink_edge(top, view);
graph.hydrate(view);
// Read the materialized tree. `ViewData` is `List(..)` or `One(..)`.
let data = graph.view_data(view);
Graph::hydrate drains the input pipeline once to build the initial result. As with
push, there is a fallible peer, Graph::try_hydrate, that returns Result<(), RindleError> — use it in a server.
4. Push changes
A mutation to a base table is a rindle::SourceChange. There are exactly three:
pub enum SourceChange {
Add(OwnedRow),
Remove(OwnedRow),
Edit { row: OwnedRow, old: OwnedRow },
}
Push one with Graph::source_push, then flush_view to close the transaction and
fire the view’s listeners:
use rindle::{value::owned_row, OwnedValue, SourceChange};
graph.source_push(
issue_src,
SourceChange::Add(owned_row(vec![OwnedValue::Int(7), OwnedValue::Bool(true)])),
);
graph.flush_view(view);
let updated = graph.view_data(view); // reflects the new row
The cost of the push is proportional to the change, not to the table size. In a
server, drive mutations through the fallible Graph::try_source_push (returns
RindleError); the infallible source_push / hydrate shown above .expect the result
and are for tests and prototyping. See the crate’s fault model for the full
survivability contract.
The engine is single-threaded by design: one
Graphper thread (it is!Send). Scale with N independent graphs and message passing, never a sharedArc<Mutex<Graph>>.
Query planner (opt-in)
Some queries can be lowered more than one way. A correlated EXISTS can be driven
from the parent (filter the parent, probe the child) or flipped to be driven
from the child (stream matching children up to their parents) — and which is cheaper
depends on the data. Rindle ships a cost-based planner (rindle-planner) that picks.
Planning is a pure Ast → Ast step that runs before lowering (step 2
above): it annotates each flippable EXISTS with a flip decision and changes
nothing else. It is result-preserving — the flipped and unflipped plans return
the same rows; only the work differs. The replica wrappers expose it as an opt-in at
open time, and leave it off by default:
// single-threaded:
let db = rindle_replica::Db::open_with_planning("app.db", true)?;
// multi-threaded (the engine rindled runs):
let (cluster, events) = rindle_replica::Cluster::open_with_planning("app.db", 4, true)?;
The cost model behind it is a real-SQLite model in rindle-sqlite — it reads
SQLite’s own scan-status and table statistics rather than guessing. See
run the daemon for where planning fits in a
deployment.
Driving it from a database: rindle_replica
Wiring sources, resolves, and views by hand is the low-level path. The rindle_replica
crate packages the whole lifecycle over a SQLite database: open a file, register the
tables a query touches, register the query, and subscribe to its change stream.
use rindle_replica::{Db, QueryId, Update};
use rindle::table;
let db = Db::open("app.db")?;
db.register_table("issue")?;
// `Db::query` takes a caller-chosen `QueryId` tag and an `Ast` (build it with
// `rindle::table(..)`), not a SQL string.
let ast = table("issue").r#where("open", true).build();
let query = db.query(QueryId(1), ast)?;
// `subscribe` takes a callback. It fires once immediately with `Update::Hydrated`
// (the initial set), then `Update::Changed` after every committed write that
// affects this query.
query.subscribe(|update: &Update| match update {
Update::Hydrated { tx_id, changes } => {
println!("hydrated @ {tx_id:?}: {} change(s)", changes.len());
}
Update::Changed { tx_id, changes } => {
println!("changed @ {tx_id:?}: {} change(s)", changes.len());
}
});
Each changes element is a rindle_replica::ChangeEvent (the engine’s owned
CaughtChange) — the incremental delta, not a re-fetch. Its variants mirror the
engine’s change model: Add, Remove, Edit, and Child (for a change to a nested
relationship). The variants are not Insert/Delete/Update.
Writes go through a single controlled writer. Db::write opens a transaction and
returns a WriteTxn; its commit derives each query’s incremental events against the
pre-commit snapshot and delivers them to subscribers:
let mut tx = db.write()?;
tx.exec_batch("INSERT INTO issue (id, open) VALUES (8, 1)")?;
tx.commit()?; // derives + delivers `Update::Changed` to affected subscribers
This one-writer, write-then-abort model is what makes a delivered event exactly equal a freshly-hydrated query. For the subscription shapes and how a view stays in sync, see Replica and views.
Where to go next
- Overview — what Rindle is and when to reach for it.
- Quickstart — stand up a live query end to end.
- Change model — the
Add/Remove/Edit/Childdeltas in depth. - Replica and views — subscriptions and view sync.
- Supported queries — the matrix of what builds and pushes.
- Crates —
rindle,rindle_replica, andrindle_sqlite.