MarsDB
An embeddable property-graph database with an openCypher query subset: single binary, single file, optional in-memory mode.
$ marsdb :memory:
MarsDB graph database. Enter Cypher statements terminated by `;`. Ctrl-D to exit.
marsdb> CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'});
marsdb> MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name;
a.name | b.name
Alice | Bob
Install
CLI — installs the marsdb binary:
cargo install marsdb-cli
Or on macOS/Linux via Homebrew (tap):
brew install knoguchi/marsdb/marsdb
Rust library:
cargo add marsdb
let db = in_memory?; // or Database::open("path/to.db")
db.execute?;
let result = db.execute?;
// Or run a `;`-separated batch, one transaction per statement, one
// QueryResult per statement back:
let results = db.execute_batch?;
More: cargo run -p marsdb --example task_tracker (CRUD + aggregation),
--example social_graph (variable-length traversal, MATCH...CREATE), or
--example params_and_batch ($parameters, execute_batch) — full
source in marsdb/examples/. Each also writes an SVG
chart of its query result (via plotters)
to the current directory.
Python:
pip install marsdb
= # or .open(path)
# -> [{'n.name': 'Alice'}, {'n.name': 'Bob'}]
Prebuilt wheels cover macOS (arm64, x86_64) and Linux (x86_64, manylinux); other platforms install from the source distribution and need a Rust toolchain. To build from source directly:
cd marsdb-python
python3 -m venv .venv && source .venv/bin/activate
pip install maturin && maturin develop
CLI usage
marsdb # in-memory REPL
marsdb mydata.db # file-backed REPL
marsdb mydata.db "MATCH (n) RETURN n" # run one query, exit
marsdb :memory: "..." # explicit in-memory, one-shot
marsdb mydata.db "CREATE (a); CREATE (b); MATCH (n) RETURN n" # ;-separated batch
Natural language -> Cypher
marsdb-nl2cypher translates an English question into Cypher against a
database's actual schema (labels/relationship-types/properties in use,
introspected automatically), validates the result by really parsing it, and
retries once with the exact parse error fed back if the first attempt
doesn't parse. No HTTP/LLM-SDK dependency in the core crate — bring your
own LlmClient:
use Database;
use ;
let db = in_memory?;
db.execute?;
let = translate_and_run?;
A real, runnable example against a local Ollama instance:
ollama serve &
ollama pull llama3.2
cargo run -p marsdb-nl2cypher --example ollama_demo
MarsDB's narrower Cypher subset (vs. full Neo4j Cypher) is a deliberate fit
for this — a smaller grammar means fewer ways an LLM can generate something
unparseable. The prompt tells the model what's supported and what to
avoid (no bare --> shorthand, MERGE capped at one hop, etc.) — see
marsdb-nl2cypher/src/lib.rs's CAPABILITIES constant.
Architecture
marsdb-storage thin trait boundary over redb (file + in-memory backends)
marsdb-graph property graph model, CRUD, KV/adjacency encoding
marsdb-query openCypher subset: pest grammar -> AST -> IR -> executor
marsdb embeddable public Rust API (Database::open/in_memory/execute)
marsdb-cli the `marsdb` binary (REPL + one-shot mode)
marsdb-python PyO3 bindings, builds via maturin
marsdb-nl2cypher natural-language -> Cypher: schema introspection, prompt building, validate-and-repair
Storage runs on redb, a pure-Rust
single-file MVCC embedded KV engine. Query execution compiles Cypher to a
small Gremlin-shaped logical IR (AllNodesScan, NodeByLabelScan,
Seed, Expand, VarExpand, Filter) so a future Gremlin frontend can
target the same executor. Every Cypher statement runs inside one
transaction — a read-only MATCH ... RETURN opens a ReadTransaction
(a consistent snapshot that runs alongside other concurrent readers or a
concurrent writer without contending for redb's single-writer lock),
everything else opens a WriteTransaction, committed or aborted as a
whole.
Numbers: BENCHMARKS.md.
Execution materializes a Vec of bindings at every step rather than
pulling rows lazily — there's no general query optimizer or streaming
iterator model. Two narrow, hand-written exceptions: a MATCH (n[:Label]) RETURN ... LIMIT k with no WHERE/hops/ORDER BY pushes the LIMIT
straight into the storage scan (GraphStore::all_nodes_limited_in_txn
stops once it has k nodes, whether or not a label narrows it first —
correct only because nothing downstream could still drop a row); and
every ORDER BY ... LIMIT k site (WITH's own, non-aggregating
RETURN's, aggregating RETURN's) uses a top-k partial selection
(slice::select_nth_unstable_by + a sort of just the k-sized prefix)
instead of a full sort of every row. Both are real, targeted wins, not a
general "push predicates/limits through the plan" framework — see the
Roadmap.
Cypher coverage
CREATE, multi-label nodes ((n:Post:Message)), $parameters,
backslash-escaped string literals (\' \" \\ \n \r \t \b \f),
MATCH/OPTIONAL MATCH, undirected (-[r:TYPE]-) and variable-length
([:TYPE*min..max]) relationship patterns, WHERE (including the
string predicates STARTS WITH/ENDS WITH/CONTAINS), one WITH
boundary per statement (projection/rename, its own WHERE/
WITH...WHERE/ORDER BY/LIMIT), RETURN/DELETE/DETACH DELETE/
SET/REMOVE/MATCH ... CREATE (adds an edge between two
already-matched nodes — a node token whose variable is already bound
reuses that node instead of creating a new one). SET/REMOVE cover
both properties (SET n.prop = 'x'/REMOVE n.prop) and labels
(SET n:Label/REMOVE n:Label) — but, like DELETE, can't be
followed by anything else in the same statement (no SET ... RETURN
in one query yet — each is a terminal tail, not a chainable clause).
Multi-key ORDER BY, LIMIT, CASE, the built-in functions
coalesce()/toInteger(), and implicit-GROUP-BY aggregation
(count()/count(*)/sum()/avg()/min()/max()/collect(), with
DISTINCT inside an aggregate call). RETURN DISTINCT (result-set-level
dedup of the whole projected row, applied after grouping for an
aggregating RETURN — a separate mechanism from DISTINCT inside one
aggregate call, which only affects that aggregate's own accumulation).
Two independent MATCH parts
across one WITH boundary (MATCH (a) WITH a MATCH (b) ..., where b's
pattern doesn't chain from a) correctly cross-join, carrying a
alongside every row b produces. UNWIND <list> AS x (fans a list out
into one row per element, cross-joined against existing rows; its own
WHERE works without needing a second WITH) — <list> is an inline
Cypher-text list literal ([1, 2, 'a', $p]) or a variable bound by a
preceding WITH ... collect(...); UNWIND $param where $param itself
names a list isn't supported yet (no list-valued parameters — every
$param is a single scalar). MERGE <pattern> [ON CREATE SET ...] [ON MATCH SET ...] (match-or-create: tries the pattern as an ordinary MATCH
first, creates exactly one new instance if nothing matched) — capped at
one relationship hop (MERGE (n:Label {props}) or MERGE (a)-[:TYPE]-> (b)); an unconstrained node pattern that isn't already bound (MERGE (n), no label or property) is rejected rather than matching/creating
arbitrarily. Named-path capture (MATCH p = (a)-[:KNOWS]->(b) RETURN p,
fixed-hop patterns only) and shortestPath((a)-[:TYPE*..N]-(b)) (real
shortest-path search via BFS, not just the first path found — both
endpoints must already be matched by a preceding clause), plus
length(p) to measure one.
Verified against all 7 of LDBC SNB Interactive's short-read reference
queries (IS1-IS7) — see marsdb-query/tests/ldbc_is_queries.rs. Not
verified: LDBC's complex queries (IC1-14: the full query set beyond one
hand-crafted grouping+WITH...WHERE+ORDER BY+LIMIT+collect()
checkpoint — see marsdb-query/tests/smoke.rs), comma-separated patterns
within one MATCH/CREATE clause beyond a single linear chain (general
cross-joins — different from the cross-join WITH-chaining above, which
works), chaining past one WITH boundary, MERGE patterns with more than
one relationship hop (whole-pattern atomicity across multiple
simultaneously-unbound hops isn't attempted), named-path capture over a
variable-length pattern (only shortestPath() tracks the hop-by-hop chain
needed to reconstruct a path over *-traversal), or shortestPath() with
a minimum hop count greater than 1 (a plain visited-set BFS can't
correctly answer "shortest path of at least N hops" for N > 1 without a
different algorithm). One more gap the TCK surfaced directly: no
compile-time semantic validation (an undefined variable/function, or a
wrong-type function argument, is only caught while evaluating an actual
row — a query whose MATCH matches zero rows never gets checked at all).
WHERE does have real three-valued NULL logic (AND/OR/NOT and every
comparison correctly propagate "unknown" rather than collapsing it to
false) — CASE's WHEN and DISTINCT dedup deliberately don't, since they
need a definite yes/no, not "unknown".
Roadmap
- List-valued
$parameters, to unblockUNWIND $items AS x - From-scratch storage engine (page format, B-tree, crash recovery) as an
alternate
marsdb-storagebackend, independent of redb - Gremlin frontend targeting the existing IR
- Real query optimizer (cost-based join ordering, a semantic-validation binder pass) — the two targeted wins below (LIMIT push-down, ORDER BY+LIMIT top-k) are hand-special-cased, not a general framework
Testing
cargo test --workspace # ~8s
cargo test -p marsdb-graph --test stress -- --ignored --nocapture # ~15s, large-scale
cargo test -p marsdb-crash-harness -- --ignored --nocapture # ~7s/30 runs, SIGKILL-and-verify
cargo bench -p marsdb-graph
cargo bench -p marsdb
marsdb-crash-harness is a process-crash durability check, not a full power-loss
test (OS stays up, page cache intact — a real power-loss test needs fault
injection like dm-flakey, not just SIGKILL): it spawns a child process
committing one transaction at a time, SIGKILLs it at an unpredictable point,
reopens the file fresh, and asserts every acknowledged commit survived intact
with no gaps or duplicates. CRASH_TEST_RUNS=200 (default 30) for more
confidence at the cost of runtime.
The Cypher parser (the one part of MarsDB that takes raw, untrusted string
input directly) is fuzzed via cargo-fuzz — needs nightly:
cargo install cargo-fuzz
cd marsdb-query && cargo +nightly fuzz run parse -- -max_total_time=120
Only claim: never panics. A parse error (Result::Err) is the expected,
correct outcome for most fuzzer-generated input. Keep at least one real
Cypher string in fuzz/corpus/parse/ — running against a genuinely empty
corpus made libFuzzer's own startup/seed-bootstrapping phase (not anything
in MarsDB) take upwards of an hour instead of seconds on this target; a
seeded corpus doesn't hit it.
marsdb-tck runs a real subset of the
openCypher TCK (pulled in as a
git submodule pinned to a fixed commit — see marsdb-tck/VENDOR.md; 220
.feature files, 3880 scenarios) against MarsDB, via a purpose-built
Gherkin-subset parser and structural result comparison (not string
matching):
git submodule update --init marsdb-tck/openCypher
cargo run --release -p marsdb-tck
Attempts every scenario, including the ~75% that use Cypher features MarsDB
doesn't implement at all (temporal/spatial types, list comprehensions,
CALL, arithmetic expressions, ...) — those report as a distinct
"rejected at parse time" outcome, not lumped in with genuine wrong answers.
Of the scenarios MarsDB's grammar accepts at all, it gets the right
answer in the large majority — the real, checked-for-real signal this
exists to produce, not the flat pass-rate over the whole suite. Side-effect
assertions and the TCK's typed error taxonomy aren't checked (see the
crate's doc comments for why).
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.