Custom Storages
Graphersal's reference storage is TraversalGraph, an in-memory graph. A host (a server, an
application) can bring its own in-memory storage instead: any type that implements the
GraphStorage trait. The engine, the Rhai DSL, saved queries, the session of the playground and
the dev server (graphersal-session) and the persist Store then work over it.
EXPERIMENTAL in 0.1.x.
GraphStorage,PersistentStorage,AnyGraphand the conformance crategraphersal-storage-testsmay change in any 0.1.x release. Pin the exact versions (graphersal = "=0.1.0",graphersal-storage-tests = "=0.1.0").graphersal-sessionis internal and has no stability guarantees at all.
How it fits together
The engine (GraphTraversalSource<'g, S>) is generic over the storage and statically
dispatched: a traversal over a host storage is compiled for that storage, with no virtual call
per element. Everything above the engine holds one object-safe type per layer instead, and makes
one virtual call per operation (a terminal, a schema method, a catalog call, a view):
| Layer | Holds | Implemented for |
|---|---|---|
Rhai DSL, saved queries (graphersal::script) | Arc<dyn AnyGraph> | every Graph<S> (sealed blanket implementation) |
Session (graphersal-session) | Arc<dyn SessionGraph>, Arc<dyn SessionStore>, Arc<dyn GraphFactory> | every Graph<S>, every Store<S>, StorageKind<S> |
Store (graphersal::persist) | Store<S>, internally a non-generic core | every S: PersistentStorage |
Graph<S> (in graphersal::storage, also in the prelude) is the shared form of a storage: S
behind a read-write lock, with read()/write() guards that dereference to S. Graph without
a parameter means Graph<TraversalGraph>.
What a host implements
GraphStorage(required). Only the read methods, the element-creating and element-removing methods and the property writes are required; everything else has a default that is correct for a simple, non-transactional storage. The trait's rustdoc ("A guide for storage implementers") is the reference: handles that never alias, the write checks and their public helpers, units of atomicity, capabilities, change capture, statistics, schema and the catalog.Default(required by the DSL and the session). A whole script runs as one unit by moving the storage out of the shared lock while the write lock is held (std::mem::take), which leaves an empty instance behind. The session also builds every new graph (a sample, a file load) by reading it into aTraversalGraphand copying it into anS::default()(through the load sink of aPersistentStorage, through the write methods otherwise).Send + Sync + 'static, so the graph can be shared between threads.- Change capture (optional; needed for commit hooks, dry runs and the Store). A storage that
claims
change_capture(and thereforeatomic) incapabilities()builds theChangeSetof every committed unit itself with the public constructors (ChangeSet::builder, oneMutationconstructor per kind). There is no reusable undo log or capture component: the storage derives what a unit changed from its own bookkeeping. It embeds aCommitHooks(graphersal::storage) and calls it at its commit point, so the order of the hooks (user hooks in registration order, then the journal) is implemented once; the rustdoc ofCommitHookssays when to call each event. PersistentStorage(optional, featurepersist; needed for snapshots and the Store): the lineage id, the commit position, a load sink outside units,clear, the journal slot and replay. See Your Own Storage.
Units and transactions come for free: the Transactional extension trait (transaction,
transaction_with, dry_run) is implemented for every GraphStorage on top of the trait's
unit methods. dry_run needs change_capture.
Plugging it in
| Front end | Entry point |
|---|---|
| Engine | GraphTraversalSource::new(&storage) (reads), GraphTraversalSource::new(&mut storage) (writes) |
| Shared graph | Graph::new(storage); Graph::new_persistent(storage) for a PersistentStorage (adds g.export_snapshot(path) in the DSL) |
| Rhai DSL | graph_scope(graph), graph_scope_with_options(..), every eval_* function: they take impl IntoAnyGraph, i.e. an Arc<Graph<S>> or an Arc<dyn AnyGraph> |
| Session | Session::with_storage(Arc::new(StorageKind::<S>::persistent())) for a PersistentStorage (graphs, .gsnap loads, stores in memory), StorageKind::<S>::new() for any other storage (graphs only); session.load_shared(graph, name, kind) serves a graph the host already holds, session.attach_store(Arc::new(store), label) a Store |
| Store | Store::create_in(dir, storage, options), Store::open_in(dir, options, S::default), open_backup_in, open_read_only_in, create_from_packed_in; the Store page has the table |
| Snapshots | persist::write_snapshot(&storage, writer, &options), persist::read_snapshot_into(reader, &options, S::default()) |
A Store requires the capabilities atomic and change_capture and fails with
StoreFailure::MissingCapability without them. The files a Store<S> writes do not depend on
S: a store written by a host storage opens as Store<TraversalGraph> (in graphersal store,
the dev server, Python) and the other way round.
A complete host
The example below is a test of graphersal-session (crates/graphersal-session/tests/custom_storage_example.rs),
compiled and run by cargo test -p graphersal-session. The Forwarding storage of
graphersal-storage-tests stands in for the host's storage (see below).
use std::sync::Arc;
use graphersal::TraversalGraph;
use graphersal::persist::{MemDir, Store, StoreDir, StoreOptions};
use graphersal::prelude::{ElementProperty, GraphTraversalSource};
use graphersal::script::eval_value;
use graphersal::storage::{Graph, GraphStorage, Transactional};
use graphersal_session::{RunOptions, Session, StorageKind};
use graphersal_storage_tests::forwarding::Forwarding;
/// The host's storage. A real host writes its own `GraphStorage` (+ `Default`, and
/// `PersistentStorage` to be stored); `Forwarding` stands in for it.
type MyStorage = Forwarding;
fn modern() -> MyStorage {
Forwarding::new(TraversalGraph::tinkerpop_modern())
}
#[test]
fn a_host_plugs_its_own_storage_into_every_front_end() -> Result<(), Box<dyn std::error::Error>> {
// 1. The engine is generic: a traversal over `&S` (reads) or `&mut S` (writes).
let mut storage = modern();
let people = GraphTraversalSource::new(&storage)
.v(None)
.has_label("person")
.count()
.next()?;
assert_eq!(people, Some(ElementProperty::from(4)));
storage.transaction(|g| {
GraphTraversalSource::new(&mut *g).add_v("robot").next()?;
Ok::<_, Box<dyn std::error::Error>>(())
})?;
assert_eq!(storage.vertex_count(), 7);
// 2. The DSL and saved queries take any `Arc<Graph<S>>` (`impl IntoAnyGraph`).
// `new_persistent` also gives the DSL's `g.export_snapshot(path)`.
let graph = Arc::new(Graph::new_persistent(modern()));
let _definition = eval_value(
graph.clone(),
r#"g.define_query(#{name: "older_than", params: #{age: "integer"},
body: "fn older_than(age) { g.V().has(\"age\", P.gt(age)).values(\"name\").order() }"})"#,
)?;
let names = eval_value(
graph.clone(),
r#"g.query("older_than", #{age: 30}).toList()"#,
)?;
assert_eq!(names.to_string(), r#"["josh", "peter"]"#);
// 3. A Store of the storage: `create_in` / `open_in` (it must claim `atomic` and
// `change_capture`). Every commit goes to the write-ahead log.
let dir: Arc<dyn StoreDir> = Arc::new(MemDir::named("custom-storage-example"));
let store = Store::create_in(&dir, modern(), StoreOptions::new())?;
store.graph().write().transaction(|g| {
GraphTraversalSource::new(&mut *g)
.add_v("person")
.property("name", "ada")
.next()?;
Ok::<_, Box<dyn std::error::Error>>(())
})?;
store.close()?;
// `open_in` takes how to build the empty instance the snapshot is loaded into.
let store: Store<MyStorage> = Store::open_in(&dir, StoreOptions::new(), MyStorage::default)?;
assert_eq!(store.graph().read().vertex_count(), 7);
// 4. A session over the storage: every graph it builds (samples, loads, stores in memory,
// forks) is a `MyStorage`; here it serves the Store opened above.
let mut session = Session::with_storage(Arc::new(StorageKind::<MyStorage>::persistent()));
session.attach_store(Arc::new(store), "my store");
let answer = session.execute(
r#"g.V().has("name", "ada").count().next()"#,
&RunOptions::default(),
);
assert!(answer.contains(r#""ok":true"#), "{answer}");
assert_eq!(
session.graph().storage_type_name(),
std::any::type_name::<MyStorage>()
);
let mark = session.store_mark("after-ada")?;
assert_eq!(mark["mark"]["name"], "after-ada");
Ok(())
}
What stays TraversalGraph-only
- Scratch graphs. Graphs a script creates itself are always
TraversalGraphs, whatever the host's storage:GraphSource::*,__,_g, the results ofsubgraph()/cap()/toGraph()andschema.toGraph(). A host-chosen scratch storage is not planned. withSideEffect(name, graph)into a host graph. Asubgraph()writes only into aTraversalGraph; a foreign target fails withGraphError::Unsupported. Let the subgraph land in a scratch graph and copy what is needed.- Compressed properties. A foreign storage keeps compression rules in its catalog like any
definition (defined, listed, persisted, dropped), but nothing applies them:
recompressfails withGraphError::Unsupportedand the compression statistics are absent. The session's compression manager lists and edits the rules and says "not supported by this storage" for the rest. - The undo log and its change-capture derivation. A foreign storage brings its own (see "Change capture" above).
- The Store's internal working graphs. The merge checkpoint's delta graph, salvage
(maintenance mode) and repair work on internal
TraversalGraphs and write files; they never touch the host's graph. - The shipped front ends. The CLI (REPL, one-shot runner), the dev server and its MCP
endpoint, the Python binding and the WebAssembly playground serve
TraversalGraph. A host that serves its own storage builds ongraphersal-sessioninstead. - Sample graphs and
TraversalGraph::with_execution_policy. The sample constructors (TraversalGraph::tinkerpop_modern(), ...) buildTraversalGraphs (the session copies them into the host storage); the trait methodexecution_policy()gives a host storage the same policy hook.
Operations a storage does not implement fail with GraphError::Unsupported, which names the
operation; its help points to the storage's documentation and capabilities.
Testing a storage
The conformance suite
Add graphersal-storage-tests as a dev-dependency (with its persist feature to test the
PersistentStorage side) and invoke the macro in one test of the implementing crate:
graphersal_storage_tests::conformance!(my_storage, base = MyStorage::default() => base);
Each contract item becomes its own test; the capability-conditional items (rollback, savepoints,
hooks, change sets, dry runs) run for what capabilities() claims. See
Storage Conformance.
The Forwarding pattern
graphersal_storage_tests::forwarding::Forwarding forwards every GraphStorage (and, with
persist, PersistentStorage) method to a wrapped TraversalGraph, but has its own element
handle types. Graphersal uses it to prove that the front ends are storage-independent:
crates/graphersal/tests/all/foreign_storage_tests.rsruns every documented DSL example and a curated set of mutating, whole-script, dry-run and saved-query scripts on both storages and requires the same results;crates/graphersal/tests/all/foreign_store_tests.rsruns the Store onForwardingover every backend (commits, reopen, checkpoints, rollback, backups, ZIP, marks, damage) and opens the files with the other storage type;crates/graphersal-session/src/storage/tests.rsrequires the same JSON from a session over either storage;GRAPHERSAL_TEST_STORAGE=forwardingruns the whole TinkerPop suite on it, gated against the same baseline;- the CI job "foreign storage proof" runs all four.
A host can follow the same pattern for its own storage: run the same scripts on a
Graph<TraversalGraph> and on a Graph<MyStorage> loaded with the same data and compare the
rendered results. Differences are either bugs of the storage or behaviour the host chose (and
documents).
Cost
A storage behind Graph<S> runs within 1-4 % of TraversalGraph itself on the measured
traversals (measured with the Forwarding test storage), because the engine is monomorphised for it.
The price is build time: one engine instantiation per storage type a host uses.