RocksGraph
A Gremlin-inspired property graph query engine written in Rust, backed by RocksDB. RocksGraph takes the traversal model from TinkerPop but makes deliberate departures where the standard's design choices create unnecessary complexity or overhead. See docs/design_principles.md for the full rationale.
Status: Beta (v0.1.0). Under active development. Preparing for release on crates.io.
Overview
RocksGraph translates a subset of Gremlin traversal queries into a logical IR, optimizes them, and executes them against a persistent RocksDB store. It is designed as a clean, layered architecture separating the user-facing session API, query planning, optimization, and execution.
Architecture
User code
│ Graph::open / graph.read() / graph.begin()
▼
api User-facing session layer (Graph, ReadSession, TxSession)
│ session.g() → ReadTraversal / WriteTraversal
▼
gremlin Rust DSL; accumulates LogicalSteps into a LogicalPlan
│
▼
planner LogicalPlan optimizer (index-seek folding, filter reordering, …)
│
▼
engine::volcano Pull-based Volcano iterator pipeline
│
▼
graph Query-scoped overlay (OCC dirty tracking, read-your-writes)
│
▼
store / RocksDB OptimisticTransactionDB persistence
| Module | Visibility | Role |
|---|---|---|
api |
pub |
Graph, ReadSession, TxSession — the only types users import directly |
gremlin |
internal | Fluent step builders; converts method chains into a LogicalPlan |
planner |
internal | Engine-agnostic LogicalPlan IR + optimizer rules |
engine::volcano |
internal | Pull-based Volcano iterator execution engine |
graph |
internal | Query-scoped in-memory overlay over a GraphStore transaction |
store |
internal | Pluggable storage backend abstraction; RocksDB implementation |
schema |
pub |
Label/property-key registry; Auto vs Strict schema modes (see Schema Modes) |
Value Types
All user-facing query inputs and outputs use types from gremlin::value, re-exported at the crate root:
| Type | Description |
|---|---|
Value |
Scalar or composite result: Null, Bool, Int32, Int64, UInt16, Float32, Float64, String, Uuid, Vertex, Edge, Property, List, Map, Path |
Predicate |
Filter condition: Predicate::Eq, Within, Without, Gt, Gte, Lt, Lte, Between, Ne |
Vertex |
Materialized vertex: id, label (decoded string name), properties |
Edge |
Materialized edge: out_v, in_v, label (decoded string name), rank (u16, see Value::UInt16), properties |
Property |
Key-value property element returned by .properties() |
Map |
Ordered key-value map returned by .group() or .group_count() |
Path |
Sequence of values with per-step labels returned by .path() |
Predicate constructors are free functions: eq, ne, gt, gte, lt, lte, between, within, without.
Reserved keys: id, label, rank
"id", "label", and "rank" are reserved — .has(), .values(), and .properties()
all reject them. Access them exclusively through dedicated steps, both for filtering and
for extraction:
// id — HasIdStep / IdStep
.hasId // filter: Eq (single) or Within (multiple)
.hasId // filter: any Predicate works (vertex ids are ordered i64)
.id // extract: Value::Int64 (vertex) / Value::String (edge)
// label — HasLabelStep / LabelStep
.hasLabel // filter: Eq/Within
.hasLabel // filter: eq/ne/within/without (no gt/lt/between — label
// names aren't meaningfully ordered)
.label // extract: Value::String, decoded from the schema registry
// rank — HasRankStep / RankStep (edge-only; vertices have no rank)
.hasRank // filter: Eq (single), Within (multiple), or any Predicate
.hasRank // filter: eq/ne/within/without/gt/lt/between
.rank // extract: Value::UInt16
// negation for hasId()/hasLabel() goes through not(), same as any other filter:
.not // "every vertex except 1 and 2"
Session Model
Every interaction with the graph goes through a session. Sessions are obtained from a Graph handle:
Graph::open(path)
├── .read() → ReadSession read-only snapshot; no commit needed
└── .begin() → TxSession OCC read-write transaction
├── .commit() atomically flush mutations
└── .rollback() discard (also called automatically on drop)
Each session exposes a single method g() that returns a blank traversal. All Gremlin step methods live on the traversal, not on the session:
// Read path
let mut snap = graph.read;
let name = snap.g.V.out.values.next?.unwrap;
// Write path
let mut tx = graph.begin;
tx.g.addV.property.property.next?;
tx.g.V.out.count.next?; // read-your-writes within the same tx
tx.commit?;
Graph is cheap to clone (wraps an Arc internally), safe to share across threads. Sessions are single-threaded — create one per thread or per request.
OCC conflict handling
RocksGraph uses Optimistic Concurrency Control via RocksDB's OptimisticTransactionDB. commit() returns StoreError::Conflict if a concurrent transaction modified an overlapping key. The caller must retry from scratch with a new TxSession:
loop
Key invariants enforced by the transaction layer:
- Bidirectional edge indexing: both OUT and IN indices are written on commit
- Dangling edge prevention: edge endpoints are verified to exist before insertion
- Degree validation: vertices with incident edges cannot be dropped
- Tombstone visibility: deleted elements are invisible to later reads within the same transaction
Supported Gremlin Steps
Vertex labels, edge labels, and property keys are plain strings (e.g. "person", "knows") at
the traversal API. The schema module interns them to compact numeric IDs internally — see
Schema Modes for how and when that registration happens.
Traversal
| Step | Method |
|---|---|
V(ids) |
.V([id, ...]) |
out(labels) |
.out([label, ...]) |
in(labels) |
.r#in([label, ...]) — in is a Rust keyword, hence the raw identifier |
both(labels) |
.both([label, ...]) |
outE(labels) |
.outE([label, ...]) |
inE(labels) |
.inE([label, ...]) |
bothE(labels) |
.bothE([label, ...]) |
inV() |
.inV() |
outV() |
.outV() |
otherV() |
.otherV() |
Filtering
| Step | Method | Notes |
|---|---|---|
has(key, value) |
.has(key, pred) |
key is a user property name (&str) — "id"/"label"/"rank" are rejected, use the steps below |
hasLabel(labels) |
.hasLabel([label, ...]) / .hasLabel(pred) |
accepts a list (Eq/Within) or any Predicate except range predicates |
hasId(ids) |
.hasId([id, ...]) / .hasId(pred) |
accepts a list (Eq/Within) or any Predicate |
hasRank(pred) |
.hasRank(pred) |
edge-only; vertices never match |
is(pred) |
.is(pred) |
filter the current scalar value |
where(traversal) |
.r#where(__().xxx()) |
sub-traversal filter |
not(traversal) |
.not(__().xxx()) |
negation filter |
and(traversals) |
.and([__().xxx(), __().yyy()]) |
passes if every sub-traversal yields a result |
or(traversals) |
.or([__().xxx(), __().yyy()]) |
passes if any sub-traversal yields a result |
choose(traversal) |
.choose(pred, true, false?) |
conditional branching |
limit(n) |
.limit(n) |
|
range(lo, hi) |
.range(lo, hi) |
pagination into the result stream |
skip(n) |
.skip(n) |
skip first N results |
tail(n) |
.tail(n) |
keep last N results |
dedup() |
.dedup() |
|
order() |
.order() |
ascending sort on the current value |
order().by(key) |
.order().by("key") / .order_by("key", dir) |
sort by a resolved property value; chain .by(k1).by(k2) for multi-key tie-breaking |
Extraction & Aggregation
| Step | Method | Notes |
|---|---|---|
values(keys) |
.values([key, ...]) |
key is a user property name (&str) — "id"/"label"/"rank" are rejected, use the steps below |
properties(keys) |
.properties(["key", ...]) |
returns Property elements; "id"/"label"/"rank" are rejected |
id() |
.id() |
extracts the element id as a scalar (Int64 for vertices, String for edges) |
label() |
.label() |
extracts the element label as a String |
rank() |
.rank() |
extracts the edge rank as UInt16; errors on a vertex traverser |
select(label) |
.select(label) |
extract a labelled value from the path history |
count() |
.count() |
|
sum() |
.sum() |
numeric sum |
mean() |
.mean() |
numeric mean |
max() |
.max() |
numeric maximum |
min() |
.min() |
numeric minimum |
fold() |
.fold() |
collects all results into a single Value::List |
unfold() |
.unfold() |
flattens a list back into individual traversers |
group() |
.group() |
keyed list aggregation into a Map |
groupCount() |
.group_count() |
keyed count aggregation into a Map |
path() |
.path() |
returns Value::Path with per-step labels |
withProperties(keys) |
.withProperties(["key", ...]) |
configures which properties are included when a vertex/edge is materialized in the result — [] fetches all, named keys fetch only those, omitting the call returns id + label only. Not a mutation; available on both ReadTraversal and WriteTraversal since either can terminate in a materialized read-back |
Mutation (WriteTraversal only)
| Step | Method |
|---|---|
addV(label) |
.addV(label) |
addE(label) |
.addE(label) |
from(vertex_id) |
.from(vertex_id) — optional; if omitted, the upstream traverser's vertex is used as the out-vertex |
to(vertex_id) |
.to(vertex_id) — optional; if omitted, the upstream traverser's vertex is used as the in-vertex |
property(key, value) |
.property(key, value) — "id" sets the vertex/edge id |
drop() |
.drop() — drops whatever the traverser carries: a vertex, an edge, or (after .properties([..])) a single property key |
Composition
| Step | Method | Notes |
|---|---|---|
as(label) |
.as_(\"label\") |
labels the current traverser for later select() |
identity() |
.identity() |
pass-through — emits the traverser unchanged |
constant(value) |
.constant(v) |
replaces every traverser with a fixed value |
local(traversal) |
.local(__().xxx()) |
runs the sub-traversal on each traverser and emits all results |
repeat(traversal) |
.repeat(__().xxx()) |
loop body |
until(traversal) |
.until(__().xxx()) |
loop termination condition |
emit() / emit_if(traversal) |
.emit() / .emit_if(__().xxx()) |
emit intermediate results during repetition |
union(traversals) |
.union([__().xxx(), __().yyy()]) |
merges all result streams |
coalesce(traversals) |
.coalesce([__().xxx(), __().yyy()]) |
first non-empty branch wins |
Terminal Operations
| Operation | ReadTraversal | WriteTraversal | Returns |
|---|---|---|---|
next() |
✓ | ✓ | Result<Option<Value>, StoreError> |
to_list() |
✓ | ✓ | Result<Vec<Value>, StoreError> |
iter() |
✓ | ✓ | Result<BuiltTraversal, StoreError> — lazy Iterator<Item = Result<Value, StoreError>> |
explain() |
✓ | ✓ | Result<String, StoreError> — pretty-printed physical plan tree |
Usage
Opening a graph
use Graph;
// Open an existing database or create a new one on disk:
let graph = open?;
// or a temporary directory for tests:
let graph = open?;
Graph is Clone — clone it freely to share across threads.
Read queries
use ;
let graph = open?;
let mut snap = graph.read;
// Count neighbors of vertex 1 via "knows" edges
let count = snap.g.V.out.count.next?.unwrap;
assert_eq!;
// Fetch property values
let name = snap.g.V.values.next?.unwrap;
assert_eq!;
// Fetch vertex id and label (decoded to its string name) via the dedicated steps
let id = snap.g.V.id.next?.unwrap;
let label = snap.g.V.label.next?.unwrap;
// Sub-traversal filter: outgoing "knows" edges whose other endpoint is vertex 2
let ct = snap.g
.V
.outE
.r#where
.count
.next?.unwrap;
// Lazy iteration
for result in snap.g.V.out.iter?
Inspecting query plans
explain() builds the physical plan and returns a pretty-printed tree showing
exactly which operators the engine will execute — including optimizer rule
effects like index lookup folding and filter reordering:
let plan = snap.g.V.out.hasLabel.count.explain?;
println!;
// PhysicalPlan
// └─ VStep(ids=[1])
// └─ InOutStep(direction=OUT, labels=[1])
// └─ HasLabelStep(vertex_pred=Eq(Int32(2)))
// └─ CountStep()
// See how the optimizer folded hasId into a VStep index seek:
let plan = snap.g.V.hasId.outE.where.explain?;
println!;
// PhysicalPlan
// └─ VStep(ids=[1])
// └─ GetEStep(labels=[...], end_vertex_ids=[2], rank=Some(0))
Write transactions
use ;
let graph = open?;
let mut tx = graph.begin;
// Add vertices — "id" is the reserved property key for the vertex id.
// "person" and "knows" register automatically on first use (SchemaMode::Auto,
// the default) — see "Schema Modes" below for the alternative, explicit-declaration mode.
tx.g.addV.property.property.property.next?;
tx.g.addV.property.property.property.next?;
// Add an edge
tx.g.addE.from.to.property.next?;
tx.commit?;
Creating edges from a traversal (variable source/target)
.from() / .to() are only needed when you want a literal endpoint. Omit either one
and addE() uses the upstream traverser's vertex instead, creating one edge per upstream
traverser — useful for "connect every result of a traversal to a fixed vertex" patterns:
use ;
let graph = open?;
let mut tx = graph.begin;
# tx.g.addV.property.property.next?;
# tx.g.addV.property.property.next?;
# tx.g.addV.property.property.next?;
# tx.g.addE.from.to.next?;
# tx.g.addE.from.to.next?;
// For every vertex `1` knows, create a "friends" edge from that vertex to vertex 1 —
// no `.from()` needed; the current traverser (each "knows" target) becomes the out-vertex.
let edges = tx.g.V.out.addE.to.property.to_list?;
assert_eq!; // one edge per upstream traverser
tx.commit?;
addE() requires at least one of .from() / .to() — calling it with neither (and no
upstream vertex producer) returns StoreError::TraversalError.
Deleting elements
drop() deletes whatever the traverser carries — a vertex, an edge, or (after .properties([..]))
a single property — and is a no-op if the traversal matched nothing:
use ;
let graph = open?;
let mut tx = graph.begin;
// Drop a single property; other properties on the same vertex/edge are untouched.
tx.g.V.properties.drop.next?;
// Drop an edge.
tx.g.V.outE.drop.next?;
// A vertex with incident edges can't be dropped directly — drop its edges first.
match tx.g.V.drop.next
tx.commit?;
Predicate filtering
Predicate has constructors for eq, ne, gt, gte, lt, lte, between, within, and
without:
- User properties (
has(key, pred)wherekeyis a&str, oris(pred)aftervalues()): everyPredicatevariant is supported. hasId(): everyPredicatevariant is supported (vertex ids are orderedi64; edge ids are opaque strings, sogt/gte/lt/lte/betweennever match an edge but don't error either).hasLabel():eq,ne,within, andwithoutare supported; range predicates (gt,gte,lt,lte,between) returnStoreError::UnsupportedOperationsince labels have no ordering.
use ;
// Scalar filter after values() — a plain scalar is shorthand for Predicate::Eq
let marko_age = snap.g.V.values.is.to_list?;
// has() with a plain scalar — also shorthand for Predicate::Eq
let by_name = snap.g.V.has.to_list?;
// Range and membership predicates work on properties and ids
let adults = snap.g.V.has.to_list?;
let by_age_range = snap.g.V.has.to_list?;
// A fixed-size array of values is shorthand for Eq (single)/Within (multiple) —
// hasId()/hasLabel() collapse it the same way has()/is() collapse a bare scalar to Eq
let result = snap.g.V.hasId.count.next?.unwrap;
Performance of within with large lists
-
V([]).hasId([...])— the optimizer foldsHasIdStepwithEqorWithinintoVStep(ids=[...]), which fetches all vertices in a single batch call (get_vertices). Over a read-only snapshot (graph.read()) this is a single RocksDBmulti_getround-trip; inside a transaction (graph.transact()) it is still one batch call but resolves as one point lookup per id under the hood, since RocksDB transactional reads have no multi-get equivalent here. -
hasId([...])separated fromV([])by other filters —has()/hasLabel()/where()steps betweenV([])andhasId()get reordered and folded automatically, so write order among filters doesn't matter. Only a graph-navigation step in between (out(),in(),otherV(), etc.) blocks the fold — in that casehasIdfalls back to evaluating theWithinpredicate O(n) per traverser. -
has("prop", within([...]))— property-basedWithinis evaluated in-memory O(n) per traverser. Large lists here incur per-element comparison cost. For ID-based filtering, preferhasId()with an explicit ID list overhas("id", within([...])).
Idempotent upserts with coalesce
coalesce() only evaluates its branches once per incoming traverser — it needs a seed step
ahead of it to have anything to run against. A bare tx.g().coalesce([...]) with nothing
upstream gets zero traversers and silently does nothing (returns None), even if branch 2 would
otherwise create something. .V([id]) alone isn't a safe seed either: it filters out missing
ids, so if id doesn't exist yet it also emits zero traversers. .count() always emits
exactly one traverser (a count of 0 or 1) regardless of whether id exists, which is what
reliably drives coalesce() in the "may or may not exist yet" case:
use ;
let graph = open?;
let mut tx = graph.begin;
// Upsert vertex: return existing name or create new
tx.g
.V
.count // seed: always exactly one traverser
.coalesce
.next?;
// Upsert edge: check for existing or create. No `.count()` seed needed here — vertex 42 now
// exists (created above, visible via read-your-writes), so `.V([42])` alone already emits one
// traverser.
tx.g
.V
.coalesce
.next?;
tx.commit?;
Anonymous sub-traversals with __()
__() creates a context-free traversal used as an argument to where,
coalesce, union, repeat, not, choose, and until. The type is
#[doc(hidden)] because it's an internal implementation detail — you never
write it by hand. Import __ from the crate root:
use __;
If you see GraphTraversal in a compiler error, that's the hidden type
behind __(). The error message is referencing the internal type name, but
your code should only ever interact with it through __() — the same way you
pass |x| x + 1 without naming the closure type.
// where: filter edges whose other endpoint matches a condition
snap.g.V.outE.r#where.count.next?;
// union: merge results from multiple branches
snap.g.V.union.count.next?;
// coalesce: first non-empty branch (needs a `.count()` seed — see "Idempotent upserts" above)
tx.g.V.count.coalesce.next?;
// repeat: loop body
snap.g.V.repeat.times.explain?;
Multiple queries per session
g() returns a temporary traversal that borrows the session for exactly one statement. Once the statement ends, the borrow is released and you can call g() again:
let mut tx = graph.begin;
// Each call to g() is an independent query against the same transaction.
tx.g.addV.property.property.next?;
tx.g.addV.property.property.next?;
// alice and bob are both visible here (read-your-writes)
let count = tx.g.V.count.next?.unwrap;
tx.commit?; // both writes flushed atomically
Schema Modes
Vertex labels, edge labels, and property keys are interned to compact numeric IDs internally
by the schema module. How that registration happens is controlled by SchemaMode, set via
Graph::open_with_options (it sticks for the lifetime of the on-disk database — reopening an
existing database ignores the options passed and uses whatever was persisted):
SchemaMode::Auto(the default, used byGraph::open) — a label or property key is registered the first time a traversal uses it. This is the mode every example above uses; there is nothing extra to do.SchemaMode::Strict— nothing is registered implicitly. Every vertex label, edge label, and property key must be declared up front viaGraph::open_management(), or the write fails withStoreError::SchemaViolation. (Note: Property key definitions are global/graph-wide; a property key like"name"has a single, uniformDataTypedefinition effective across the entire graph, rather than being scoped to specific vertex or edge labels.)
use ;
let options = GraphOptions ;
let graph = open_with_options?;
// Declare the schema before any write reaches the engine.
let mut mgmt = graph.open_management;
mgmt.add_vertex_label
.add_property_key;
mgmt.commit?;
let mut tx = graph.begin;
tx.g.addV.property.property.next?; // Ok
tx.commit?;
let mut tx = graph.begin;
let err = tx.g.addV.property.next.unwrap_err; // undeclared label
assert!;
SchemaManagement::commit() is atomic and CAS-checked against concurrent schema changes: either
every staged label/key in the batch is applied, or none are. See the SchemaManagement
rustdoc for the full guarantees, and set_edge_mode /
set_schema_mode for changing graph-wide options (e.g. enabling multi-edges) after creation.
Bulk Loading
For large initial datasets (millions to billions of edges), use SstBulkLoader instead
of the transactional write path. It generates sorted RocksDB SST files offline and
ingests them atomically — bypassing WAL, memtable pressure, and OCC overhead entirely.
Measured: 69 M edges, 4.85 M vertices → ~300K edges/s, ~1.2 GB peak RAM.
use ;
// Describe the schema upfront (vertex labels, edge labels, property keys).
let schema = BulkSchema ;
// EdgeListSource reads a SNAP-format edge list lazily (O(1) memory).
let source = EdgeListSource ;
let = source.open?;
// Load into an empty database.
let stats: BulkLoadStats = new
.load_initial?;
println!;
// Database is now accessible via Graph::open("path/to/db")
Key properties:
EdgeMode::Single(default) — rank is always 0; duplicate(src, label, dst)→ error.EdgeMode::Multi— supports auto-assigned ranks (BulkEdge::rank = None) and explicit ranks (BulkEdge::rank = Some(r));Rank::MAX(65535) is reserved as sentinel.SchemaMode::Strict— validates all labels and property keys againstBulkSchemabefore writing any SST file.- Crash-safe: a
BULK_LOAD_IN_PROGRESSmarker in the schema CF is auto-cleared onGraph::openif ingest succeeded, or returnsStoreError::IncompleteLoadif it didn't. - Temporary files go in
work_dirand are cleaned up automatically (RAII guard).
See docs/design_bulkload_sst_ingest.md for the
full pipeline, memory budget allocation, and format details.
Development
Prerequisites: Rust toolchain 1.80+ (stable), just
The Minimum Supported Rust Version (MSRV) is 1.80. It is bumped
conservatively — only when a dependency or a desired language feature
requires it. The rust-version field in Cargo.toml tracks the
current floor.
# List all commands
# Build
# Run tests
# Format + clippy (required before committing)
# Auto-fix formatting
# Generate and open rustdoc
Benchmarks
Benchmark binaries live in src/bin/. The scripts/ directory contains helpers:
| Script | Purpose |
|---|---|
bench_write.sh |
Bulk-load a SNAP edge-list file into a new database via SST ingestion |
bench_read.sh |
Run the read traversal benchmark (Q1–Q9, prints throughput + latency) |
bench_integrity.sh |
Verify degree-CF consistency against a full adjacency scan |
instruments_read.sh |
Profile read benchmark with macOS Instruments |
instruments_write.sh |
Profile write benchmark with macOS Instruments |
# Build release binaries
# Bulk-load the LiveJournal dataset (requires bench_data/soc-LiveJournal1-shuffled.txt)
# Run read benchmark against the loaded database
# Verify structural integrity of the loaded database
Safety
RocksGraph's own code contains 5 unsafe blocks, all confined to the RocksDB
store layer (src/store/rocks/) and all performing the same operation:
std::mem::transmute to erase RocksDB transaction and snapshot lifetimes to
'static. This is necessary because rocksdb::Transaction and
rocksdb::Snapshot borrow the database handle, but our structs own both the
transaction and the Arc<OptimisticTransactionDB> — and Rust's borrow checker
can't see that they're heap-allocated together.
The invariant upholding these transmutes is documented in the module-level
comments of transaction.rs and snapshot.rs: the transaction / snapshot
field is declared before the Arc<DB> field in every struct, so the DB
handle is dropped first — guaranteeing the borrowed transaction/snapshot never
outlives it. Every callsite carries a // SAFETY: comment referencing this
invariant, and #![warn(clippy::undocumented_unsafe_blocks)] (enforced as an
error via just full-check's --deny warnings) keeps it that way for any
unsafe block added in the future.
The RocksDB dependency (rust-rocksdb) wraps a C++ library via FFI and is
widely audited.
Known Limitations
- Embedded only: no server/client mode; queries are executed in-process.
- Single-threaded per query: each volcano pipeline runs single-threaded; multiple sessions can run concurrently against a shared
Graph. - Schema ID space limits: up to
i32::MAX(~2.1 billion) distinct vertex labels and edge labels (independent namespaces), and 32767 property keys per graph — registering past that fails withStoreError::SchemaExhausted. (Label IDs are stored asi32; property-key IDs remainu16.) - Not TinkerPop-compatible: RocksGraph is Gremlin-inspired but intentionally departs from the standard. See docs/design_principles.md.
- No distributed backend: placeholder exists but is not implemented.
Operations
Backup & Restore
RocksDB stores all data in the directory passed to Graph::open() (or
Graph::open_with_options()). To back up:
- Close the graph:
graph.close()?;— this is best-effort if otherGraphclones or open sessions still hold a reference; seeGraph::closefor the exact semantics. - Copy the entire directory to your backup location.
- To restore, point
Graph::open()at a copy of that directory.
This is a cold backup: no writes should be in flight while you copy the directory. For a live
backup without stopping writes, use RocksDB's Checkpoint API directly via the raw RocksDB
handle — not yet wrapped by RocksGraph (see Roadmap).
Upgrade & Migration Policy
RocksGraph is pre-1.0 (0.x.y). Per semver, a minor version bump (0.x → 0.(x+1)) may
change the on-disk format with no schema-version check or automated migration path between
releases. Back up your data directory before upgrading the rocksgraph dependency on a project
with existing on-disk data, and validate the upgrade against a copy first.
Once the crate reaches 1.0, on-disk format compatibility will be covered by semver: breaking format changes will require a major version bump and a documented migration path.
Roadmap
Engine & Query
- Improve Gremlin step coverage (lambdas, side-effects, additional aggregation steps) — see docs/TODO.md for the prioritized list
- Bulk-load via SST ingestion (
SstBulkLoader) — streams vertices + edges throughExternalSorter, O(1) memory, 300–400K edges/s on LiveJournal (69M edges) -
ReadSession/ReadTraversal— read-only snapshot path with no OCC overhead -
next(), to_list(), iter()onReadTraversalandWriteTraversal - Support strict schema mode (see Schema Modes)
- Range predicates in
HasPropertyStep(Gt,Lt,Between, etc.)
Storage & Distribution
- Support distributed key-value backend (e.g. FoundationDB)
- Server-client mode (gRPC or WebSocket)
Developer Experience
- Publish as a public crate on crates.io
- GitHub Pages rustdoc site
License
RocksGraph is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License v2.0 or (at your option) any later version.
Copyright © 2026 Austin Han.