falkordb-rs
FalkorDB Rust client
Usage
Installation
Install it with cargo add:
Run FalkorDB instance
Docker:
Code Example
use ;
// Connect to FalkorDB
let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into
.expect;
let client = new
.with_connection_info
.build
.expect;
// Select the social graph
let mut graph = client.select_graph;
// Create 100 nodes and return a handful
let mut nodes = graph.query
.with_timeout
.execute
.expect;
// Each item is a `FalkorResult<Row>`; read columns by index or name.
while let Some = nodes.data.next
Features
Header-aware result rows
QueryResult::data iterates the result set as FalkorResult<Row>. Each Row pairs the query
header (the column aliases) with that row's values, so you read columns by name or index and a
row that fails to parse surfaces as an Err instead of being silently swallowed:
use ;
let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into
.expect;
let client = new
.with_connection_info
.build
.expect;
let mut graph = client.select_graph;
let mut result = graph
.query
.execute
.expect;
for row in result.data.by_ref
Row offers borrowing accessors (get, get_at, get_all), typed accessors
(try_get::<T>, try_get_at::<T>), and consuming conversions (into_values, into_map). Typed
access is strict — no silent lossy casts — via the [FromFalkorValue] conversion trait. Because
collect short-circuits on the first Err, a whole result set can be gathered with
result.data.collect::<falkordb::FalkorResult<Vec<_>>>().
FalkorDB rejects a query whose result columns are not uniquely named, so rows from a query always
have distinct columns; if a Row ever does hold duplicates, the access paths are still defined
(get/try_get return the first match, get_all returns every match, into_map keeps the last).
To opt back into the pre-0.7 behavior (bare Vec<FalkorValue> rows, parse errors collapsed to
FalkorValue::Unparseable), call result.data.into_values_lossy(). A runnable version lives in
examples/rows.rs. Upgrading from 0.6? See the
0.7 migration guide.
Type-safe query parameters
Pass Rust values straight into a query — the client encodes them as Cypher literals and escapes them for you, so you never hand-quote strings or risk Cypher injection:
let res = graph
.query
.with_param
.with_param
.execute?;
Add several at once from an array, Vec, or map with with_params (the values share a single
type; use chained with_param calls, as above, for a mix of types):
.with_params
Supported value types include integers, floats, boolean values, strings, Option (encoded as
null), arrays/Vec, and string-keyed HashMap/BTreeMap (nested freely). Points and vectors
cannot be bound directly (a FalkorDB limitation) — pass the components and construct them in the
query:
use BTreeMap;
let coords = from;
graph.query.with_param.execute?;
If you really need a raw Cypher expression, with_raw_param("key", "…") is the explicit escape
hatch — no escaping is applied to the value (the parameter name is still validated).
Batch & pipelined execution
Normally each query is one network round-trip. graph.batch() queues several queries and sends them
over a single Redis pipeline in one round-trip, returning one result per query in submission
order. Queue queries with query (a GRAPH.QUERY) / ro_query (a GRAPH.RO_QUERY) and set
per-query parameters on the returned handle:
let mut batch = graph.batch;
for movie in &movies
batch.ro_query;
let results = batch.execute?; // Vec<BatchItemResult>, one per query, in order
for in results.into_iter.enumerate
On the async client it is identical but for the await:
let mut batch = graph.batch;
// … queue queries …
let results = batch.execute.await?;
Key points:
- Per-item errors. A failing query (bad Cypher, or a parameter that can't be encoded) becomes
that slot's
Err; the other queries are unaffected. The outerResultonly fails if the whole batch could not be completed — and if that happens after the pipeline was sent, the server may have run some or all queries (the state is unknown), which matters for writes. - Not a transaction. A pipeline is not
MULTI/EXEC: every queued query is executed, so a failure in one does not roll back or stop the others. - Results are eager. Each query's rows are parsed up front into a
Vec<Row>(the sameRowas elsewhere), since many result sets coexist in one batch. - Owned queries. To build queries ahead of time, construct
BatchQuery::write(..)/BatchQuery::read(..), attach params/timeout, andbatch.push(query).
A runnable version lives in examples/batch.rs.
Waiting for background operations
Some FalkorDB operations finish after the command that starts them returns: when you create or
drop an index or constraint, the request returns immediately while the index is populated (or the
constraint is enforced) on a background worker thread, and GRAPH.COPY can fail transiently while
the server is unable to fork. The eager methods
(create_index, create_unique_constraint, copy_graph, …) stay fire-and-forget, but every
one of them now has an additive *_op builder that adds explicit, opt-in waiting while keeping
full backward compatibility.
Each builder offers .execute() (non-blocking, identical to the eager method) and .wait() /
.wait_with(WaitOptions) terminals. For index and constraint builders, .wait() blocks until the
operation has actually taken effect (the index/constraint becomes operational or is dropped),
returning FalkorDBError::Timeout if it does not happen in time. For the copy builder, GRAPH.COPY
is already blocking on the server, so .wait() simply retries transient could not fork failures
with backoff; it does not verify the copied contents (that remains the caller's responsibility).
use ;
use Duration;
let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into
.expect;
let client = new
.with_connection_info
.build
.expect;
let mut graph = client.select_graph;
// Fire-and-forget, exactly like `create_index` (returns as soon as the server accepts it):
graph.create_index_op
.execute
.expect;
// Block until the index is actually operational (default 30s readiness timeout):
graph.create_index_op
.wait
.expect;
// A unique constraint reports a *distinct* error if existing data violates it:
match graph.create_unique_constraint_op
.wait_with
// Copy a graph, retrying transient `could not fork` failures:
let _copy = client.copy_graph_op
.wait
.expect;
The same builders exist on the async client/graph; just await the terminals:
use ;
let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into
.expect;
let client = new_async
.with_connection_info
.build
.await
.expect;
let mut graph = client.select_graph;
graph.create_index_op
.wait
.await
.expect;
tokio support
This client supports nonblocking API using the tokio runtime.
It can be enabled like so:
Currently, this API requires running within a
multi_threaded tokio scheduler, and
does not support the current_thread one, but this will probably be supported in the future.
The API uses an almost identical API, but the various functions need to be awaited:
use ;
use StreamExt; // brings `.next().await` onto the result stream
// Connect to FalkorDB
let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into
.expect;
let client = new_async
.with_connection_info
.build
.await
.expect;
// Select the social graph
let mut graph = client.select_graph;
// Create 100 nodes and return a handful
let mut nodes = graph.query
.with_timeout
.execute
.await
.expect;
// `nodes.data` is a `Stream<Item = FalkorResult<Row>>`; pull rows with `.next().await`:
while let Some = nodes.data.next.await
The result set (nodes.data) is an owned, Send + 'static Stream, so it can be moved into a
spawned task and driven with the full StreamExt / TryStreamExt toolbox. The graph
handle itself is Send + Clone: cloning is cheap and shares one schema cache, so to use a graph
from several concurrent tasks you just clone it — no Arc<Mutex<_>> wrapping required.
Async streaming
Because results are a Stream, the standard combinators just work. Import the extension traits
(use futures::{StreamExt, TryStreamExt};) and:
use ;
// Collect a typed stream in one line (errors short-circuit):
let years: = graph
.query
.execute
.await?
.data
.map
.try_collect
.await?;
// Move a result stream into its own task (it is `Send + 'static`):
let mut stream = graph.query.execute.await?.data;
let count = spawn
.await
.unwrap?;
// Fan out a follow-up query per row with bounded concurrency, over cloned handles:
let enriched: = graph
.query
.execute
.await?
.data
.map
.buffer_unordered
.try_collect
.await?;
A runnable version lives in examples/async_stream.rs.
Connection Strategy and Multiplexing
The asynchronous client chooses how it manages its underlying Redis connections via a
ConnectionStrategy:
Multiplexed(the async default): a small number of shared, cloneable, auto-reconnecting connections. Many concurrent commands are pipelined over each socket, so a single connection can carry many in-flight requests at once. This avoids the borrow/return bottleneck and is the most efficient option for highly concurrent workloads.Pooled: a fixed pool of independent connections, each used by exactly one command at a time (borrow/return). This gives strict per-command isolation and a natural cap on in-flight commands. It is the only strategy for the synchronous client.
Select or tune the strategy on the builder:
use falkordb::{ConnectionStrategy, FalkorClientBuilder};
use std::num::NonZeroU8;
# async fn doc() {
// Spread commands across 4 shared multiplexed sockets (the default uses 8).
let client = FalkorClientBuilder::new_async()
.with_connection_strategy(ConnectionStrategy::Multiplexed {
connections: NonZeroU8::new(4).unwrap(),
})
// Optional backpressure: cap concurrently in-flight commands per socket.
.with_max_inflight(std::num::NonZeroUsize::new(256).unwrap())
.build()
.await
.expect("Failed to build client");
assert_eq!(client.connection_pool_size(), 4);
# }
Notes and caveats:
- Behavior change: the async default is now multiplexed (previously an exclusive
borrow-pool). The API is source-compatible;
with_num_connectionsnow sets the number of underlying connections/sockets for the active strategy, andconnection_pool_size()reports that count. - Backpressure: multiplexed mode does not bound the number of outstanding requests
unless you set
with_max_inflight(n)(wherenis aNonZeroUsize; ignored by the pooled strategy, whose pool size already caps in-flight commands). - Sentinel: a multiplexed connection built from a Sentinel-resolved node would not
re-resolve the master/replica on failover, so for Sentinel deployments the client
transparently falls back to the pooled strategy (which re-resolves on reconnect).
connection_strategy()returns this effective strategy.
A runnable example is provided in examples/multiplexed_async.rs.
SSL/TLS Support
This client is currently built upon the redis crate, and therefore supports TLS
using
its implementation, which uses either rustls or
native_tls.
This is not enabled by default, and the user just opt-in by enabling the respective features: "rustls"/"native-tls" (
when using tokio: "tokio-rustls"/"tokio-native-tls").
For Rustls:
For Native TLS:
TCP Keepalive
Long-lived clients behind NATs, stateful firewalls, or idle-timeout-enforcing proxies can silently lose their TCP sessions. The builder exposes TCP-level socket settings to prevent this:
use FalkorClientBuilder;
use Duration;
// Convenience: just enable keepalive with a 30-second idle timeout
let client = new
.with_tcp_keepalive
.build
.expect;
// Or full control via redis::io::tcp::TcpSettings
let settings = default
.set_nodelay
.set_keepalive;
let client = new
.with_tcp_settings
.build
.expect;
Note: TCP settings apply to direct Redis TCP connections only. Unix-domain socket / embedded connections and the Sentinel connection path are not affected.
Read-only Queries and Replica Routing
Read-only queries (ro_query and call_procedure_ro) can be served from
replica nodes, taking read load off the primary. When the client connects to a
Redis Sentinel deployment that exposes readable replicas, it automatically
builds a dedicated read-only connection pool that routes those queries to a
replica. Writes always go to the primary.
Connection pool sizing: When readable replicas are present the client opens a second pool of up to
num_connectionsadditional connections (one per slot) alongside the primary pool. Size your pool limits and file-descriptor limits accordingly.
use FalkorClientBuilder;
let client = new
// A Sentinel endpoint, e.g. falkor://127.0.0.1:26379
.with_connection_info
.build
.expect;
// `true` only when readable replicas are available.
if client.reads_from_replicas
let mut graph = client.select_graph;
// Writes go to the primary.
graph.query.execute.expect;
// Read-only queries are served from a replica when one is available.
let mut nodes = graph.ro_query.execute.expect;
This behavior is fully backward compatible: against a single node (or any
deployment without readable replicas), ro_query / call_procedure_ro
transparently fall back to the primary connection, and reads_from_replicas()
returns false. See examples/readonly_replica.rs
for a complete working example.
Tracing
This crate fully supports instrumentation using the tracing crate, to use
it, simply, enable the tracing feature:
Note that different functions use different filtration levels, to avoid spamming your tests, be sure to enable the correct level as you desire it.
Typed result mapping (serde)
Enable the optional serde feature to map query results straight into your own types instead of hand-matching every
FalkorValue variant:
Derive serde::Deserialize on your type and call FalkorValue::deserialize_into (or the free function
falkordb::from_falkor_value) on a returned value. A node is deserialized from its properties, and scalars, Option,
sequences and maps map onto the matching Rust types:
use ;
use Deserialize;
let connection_info: FalkorConnectionInfo = "falkor://127.0.0.1:6379".try_into
.expect;
let client = new
.with_connection_info
.build
.expect;
let mut graph = client.select_graph;
let mut result = graph.query.execute
.expect;
for row in result.data.by_ref
A runnable version lives in examples/typed_mapping.rs.
To map a whole result set in one shot, call query_as::<T>() before execute(). Each row is
deserialized into a T, and the result's data becomes an iterator of FalkorResult<T>, so it
collects directly into a Vec:
let movies: = graph
.query
.
.execute
.expect
.data
.
.expect;
A single-column row (such as RETURN m) is deserialized from that one column's value, so a node
maps from its properties and RETURN count(m) maps a scalar. A multi-column row (such as
RETURN m.title AS title, m.year AS year) maps each column alias onto the matching struct field,
or yields the values in order for a tuple. The query header and stats remain available on the
returned result.
Embedded FalkorDB Server
This client supports running an embedded FalkorDB server, which is useful for:
- Testing without external dependencies
- Embedded applications
- Quick prototyping and development
To use the embedded feature, enable it:
Requirements
redis-servermust be installed and available in PATH (or you can specify a custom path). It is not downloaded automatically — install it from your package manager (e.g.brew install redis,apt-get install redis-server).- The
falkordb.somodule is provisioned automatically whenauto_downloadis enabled (the default): it is downloaded from the official FalkorDB releases, verified against a pinned SHA-256 checksum and cached locally. You can also pointfalkordb_module_pathat an existing module, or disableauto_downloadto use only explicit/system-installed binaries. - On macOS the module requires OpenMP:
brew install libomp.
Supported auto-download platforms: Linux x86_64/aarch64 (glibc and musl/Alpine, plus RHEL 8/9 and Amazon Linux 2023 on x86_64) and macOS aarch64 (Apple Silicon).
Self-contained vs. already-installed
use EmbeddedConfig;
use PathBuf;
// Self-contained (default): download + cache the module if it is missing.
let _auto = default;
// Offline: use only binaries already on the machine (no network access).
let _offline = EmbeddedConfig ;
The cache directory defaults to ~/.cache/falkordb-rs (Linux) or
~/Library/Caches/falkordb-rs (macOS) and can be overridden with the
cache_dir field or the FALKORDB_RS_CACHE_DIR environment variable.
Usage Example
use ;
// Create an embedded configuration with defaults
let embedded_config = default;
// Or customize the configuration:
// let embedded_config = EmbeddedConfig {
// redis_server_path: Some(PathBuf::from("/path/to/redis-server")),
// falkordb_module_path: Some(PathBuf::from("/path/to/falkordb.so")),
// db_dir: Some(PathBuf::from("/tmp/my_falkordb")),
// falkordb_version: None, // pin a different release, e.g. Some("v4.18.10".into())
// cache_dir: None, // override the download cache location
// ..Default::default()
// };
// Build a client with embedded FalkorDB
let client = new
.with_connection_info
.build
.expect;
// Use the client normally
let mut graph = client.select_graph;
graph.query.execute.expect;
// The embedded server will be automatically shut down when the client is dropped
The embedded server:
- Spawns a
redis-serverprocess with the FalkorDB module loaded - Uses Unix socket for communication (no network port)
- Automatically cleans up when the client is dropped
- Can be configured with custom paths, database directory, and socket location
Actionable error hints
FalkorDBError::mitigation_hint() turns common, recognizable failures into a short, actionable
remediation tip — handy for logs and AI tooling. It is purely additive: the raw error and its
Display/Debug output are unchanged, hints are fixed &'static strs (so they never echo text from
the underlying message), and unrecognized errors return None.
use FalkorDBError;
let err = ConnectionDown;
if let Some = err.mitigation_hint
Development
This repository ships a just file that automates the
whole development cycle — formatting, linting, building, docs, tests, coverage,
benchmarks, the dependency audit and a Dockerized FalkorDB server. It is the recommended
entry point for day-to-day work and mirrors the commands the CI gates run.
Install the runner once with cargo install just (or brew install just), then list
every available recipe:
Common recipes
# Fast inner loop (no server needed): format, lint and build.
# Run every required CI gate locally (no server needed):
# fmt-check, clippy, build, doc, deny.
# Post-task gate: every CI gate PLUS strict clippy over all targets/features
# (examples, tests, benches). Run this before declaring work done.
# Format / lint / docs individually.
# Full validation including the server-backed test suite (manages Docker for you):
# spins up FalkorDB, populates the fixture, runs the suite, tears it down.
Server-backed recipes
Tests, coverage and benchmarks need a reachable FalkorDB instance. The db-* recipes
manage one via Docker, and the *-local wrappers do it for you automatically:
# Manage a FalkorDB container yourself.
# Or let a single recipe manage the container lifecycle end-to-end.
Targeted recipes are available too, e.g. just test-parity, just test-embedded,
just test-one <filter>, just proptest, just bench-one '<criterion-id>', and
just coverage-html.
The host, port, Docker image and feature set can be overridden on the command line, for
example just port=6380 test or just image=falkordb/falkordb:latest db-up.
Regenerating llms.txt
The repository ships an llms.txt — a curated, machine-readable summary of the public
API, idioms and pitfalls for AI coding assistants (the llmstxt.org
convention). Its narrative lives in docs/llms.template.md; the
## Public API block is generated from src/lib.rs. Whenever you change the public API,
regenerate it and commit the result:
A check-llms CI job runs just check-llms on every pull request (and before a release), so a
stale llms.txt fails the build.
Reproducing CI locally
The GitHub Actions workflows invoke these same recipes, so a failing CI job can be reproduced with a single command:
| CI job | Recipe |
|---|---|
check-fmt |
just fmt-check |
check-clippy |
just clippy |
check-build |
just build |
check-doc |
just doc |
check-deny |
just deny |
check-proptest |
just proptest |
integration-tests |
just integration and just integration --all-features |
integration-tests-tokio |
just integration --features tokio |
coverage |
just coverage |
check-llms |
just check-llms |
Run just ci to execute every required no-server gate at once, or just verify to also
run the server-backed suite. The integration and coverage recipes need a reachable
FalkorDB instance (use just db-up first, or the *-local wrappers).
Testing
Running Tests
This project includes both unit tests and integration tests.
Unit Tests
Unit tests don't require a running FalkorDB instance:
# Run all unit tests
# Run unit tests with embedded feature
Property-Based Tests
The crate ships proptest suites that need no running server:
src/value/param_proptest.rs checks query-parameter encoding (encoding arbitrary values never
panics, string escaping is lossless, NUL is rejected), and src/value/de_proptest.rs checks the
optional serde mapping (agreement with serde_json, no panics, malformed-row rejection). Run
just these:
# 256 cases per property (the proptest default)
# crank the generated case count up (or set PROPTEST_CASES yourself)
# equivalent raw cargo command
They also run in CI: as the dedicated check-proptest job, and within the coverage job.
Integration Tests
Integration tests require a running FalkorDB instance. The easiest way to run them is using Docker:
# Using the provided script (requires Docker)
# Or manually start FalkorDB and run tests
# With async support
# Clean up
&&
CI Integration Tests
Integration tests are automatically run in GitHub Actions using Docker services. See .github/workflows/integration-tests.yml for the CI configuration.
Benchmarks
The crate ships a criterion benchmark,
benches/async_strategies.rs, that compares the two async connection strategies
(Pooled vs Multiplexed) across a range of connection counts (1, 8, 32) and
concurrency levels (1, 8, 64, 256). Benchmarks are developer/PR-time tools and are not
part of the required CI gates.
They require a running FalkorDB instance and the tokio feature:
# Start a server (configure with FALKORDB_HOST / FALKORDB_PORT; defaults to 127.0.0.1:6379)
# Run the full benchmark suite
# Run a single case (criterion accepts a filter on the benchmark id)
# Clean up
&&
When no server is reachable the benchmark prints a notice and skips its work, so it stays runnable in serverless CI.
Interpreting the results
Each case reports the wall-clock time to complete a batch of concurrency read queries,
and the corresponding throughput (Kelem/s). criterion writes a full HTML report to
target/criterion/report/index.html.
What to expect:
- At concurrency = 1 the two strategies are close: a single in-flight command cannot benefit from multiplexing, so the per-request latency dominates.
- As concurrency rises (64, 256) the
multiplexedstrategy should pull ahead ofpooledat the same connection count, because many commands are pipelined over each shared socket instead of waiting for an exclusive connection from the pool. The gap is largest at low connection counts (e.g.multiplexed_1vspooled_1), where the pool becomes a hard bottleneck while a single multiplexed socket keeps absorbing work. - Higher connection counts narrow the gap: a large pool (e.g.
pooled_32) hides much of its borrow/return cost, approaching multiplexed throughput at the expense of holding more sockets open.
Absolute numbers depend heavily on your hardware, the server, and network latency, so treat them as relative comparisons between strategies on the same machine rather than portable figures.
Memory and CPU usage
benches/async_strategies.rs measures wall-clock throughput. A second, non-criterion
harness, benches/resource_usage.rs, measures peak memory (RSS) and CPU time
(user/system) per strategy. Because peak RSS is a process-wide high-water mark that cannot
be reset between iterations, the harness runs each strategy in its own subprocess and
prints a table:
&&
Example output (numbers are illustrative — they vary by machine and server):
strategy peak_rss_MiB cpu_user_ms cpu_sys_ms wall_ms queries/sec
pooled:1 ...
multiplexed:1 ...
...
What to expect:
- Memory: at the same connection count, peak RSS is comparable — both strategies hold
that many sockets. The real saving is that
multiplexedsustains high concurrency with far fewer connections (e.g.multiplexed:1vs a largepooled:N), and each connection carries its own read/write buffers, so cutting connection count cuts RSS. - CPU:
multiplexedremoves the borrow/return machinery — thempscchannel, theMutex, and the per-command task spawn the pool uses to return a connection — so it generally spends less CPU per request and produces less transient allocation churn.
When no server is reachable the harness prints a notice and exits cleanly, so it stays runnable in serverless CI.