genegraph-storage
A storage layer for graph-based vector databases.
Implements the Lance format with an in-house writer/reader (lancefmt, pinned to the Lance v2.1 spec) — no dependency on the official lance crate. Parquet interop paths and other formats are available via the StorageBackend trait.
Provided functionalities:
save_metadata,load_metadata: a simple wrapper for all the data in the directorysave_*/load_*dense matrices, sparse matrices, vectors, lambdas, indices- named collections (RFC #81): schema-driven vector spaces (
save_vectors/load_vectors, any column layout with one non-nullFixedSizeListvector column) and first-class graph storage (save_graph/load_graph, weighted or topology-only,u32/u64node ids,f64weights by default with an explicitf32width available) - vector-space ↔ graph linkage (a vector space references a graph collection through its
graphproperty; resolved in one call withCatalog::describe_vector_space) - transactional generations: atomic metadata commits (tmp + fsync + rename),
scoped_generation(n)handles, generation listing/deletion for sweeps, reader pins - catalog contract (
src/catalog.rs):TableDescriptor+Catalogtrait mirroring the Lance Namespace / Polaris Generic Table API shape, withLocalRegistryover the JSON metadata registry - parquet interop (
save_dense_to_file/load_dense_from_file)
A storage layer for:
javelin-tui: a graph-based vector database Text-Interface andarrowspace: the next iteration of vector search
Usage
Simple example (kept in sync with the compile-checked doc-test on LanceStorageGraph):
use LanceStorageGraph;
use GeneMetadata;
use StorageBackend;
use Metadata;
use ;
use DenseMatrix;
let base = temp_dir.join;
let storage = new;
// some 2D data
let dense: = vec!;
let = ;
let data = from_iterator;
// seed metadata FIRST to initialize the storage directory
let md = seed_metadata
.await
.unwrap;
let md_path = storage.save_metadata.await.unwrap;
// your data is saved in an efficient Lance format
storage
.save_dense
.await
.unwrap;
// Loading back
let loaded = storage.load_dense.await.unwrap;
assert_eq!;
remove_dir_all.ok;
Graphs are stored as edge-list collections and convert to CSR at the API boundary:
use ;
let edges = vec!;
storage.save_graph.await.unwrap;
let graph = storage.load_graph.await.unwrap;
let csr = graph.to_csr.unwrap; // sprs CsMat<f64>
Weights default to f64 — the same width and exactness as the value
column of the sparse-matrix artifacts — and are persisted faithfully:
the storage layer makes no domain assumptions, so normalization
transforms (x/(1+x), 1 - exp(-x), atan(x)/(π/2) — never
dataset-wide min-max, which is incompatible with immutable generations)
belong to the producer. Producers that want a guard against a forgotten
transform can declare
GraphWriteOptions { weight_range: Some((0.0, 1.0)), .. } — an opt-in
bounds assertion on the already-computed values. Memory-bound consumers
can declare weight_type: WeightType::F32 to halve the storage bytes;
values that cannot be stored exactly at the declared width are rejected
instead of being silently narrowed (values beyond the f32 range surface
Overflow).
Failure semantics of the registry-coupled collection writes: the artifact is written first and the registry entry is published only afterwards (the registry is the single commit point), so a live entry never points at a missing artifact. If the publish itself fails — or the process dies before it — the artifact remains as unreferenced residue that a later sweep reclaims; crash recovery for interrupted saves is an orphan sweep, never a compensating registry rewrite.
Consumers that run their own metadata registry (e.g. an
ArrowSpaceMetadata commit pointer at the instance metadata path) use the
registry-free collection I/O — no GeneMetadata read or write occurs,
and registry ownership stays with the caller:
// dataset-level collection metadata is still stamped (kind, layout
// facts, user properties)
storage
.save_graph_to_path
.await
.unwrap;
let graph = storage.load_graph.await.unwrap; // path-resolved
Scalar collections (a single Float64 column — lambdas, norms, ...) are
kind=vector-space and load uniformly through load_scalars.
Append-style writers get immutable, atomically-committed generations:
let gen = storage.scoped_generation; // artifacts at {logical}__g1_{key}.lance
Concurrent writers and metadata safety
Every save_* call runs its metadata registry update through a per-path
commit actor, so concurrent tasks inside one process never lose updates.
Downstream code that performs its own metadata read-modify-write cycles
must use the same serialization. Two levels are public:
- In-process —
commit::with_commit_actor(metadata_path, cycle)serializes your cycle against the registry paths of the same metadata file. - Cross-process — the commit actor is per-process only. Independent
processes (e.g. separate CLI invocations) take an advisory lock file
around the whole cycle, resolved through the blessed convention
commit::lock_file_for_metadata(metadata_path)({metadata-stem}.locknext to the metadata file). The composed recipe — file lock held across the awaited actor cycle — iscommit::with_metadata_file_lock(metadata_path, cycle):
use with_metadata_file_lock;
let metadata_path = new;
with_metadata_file_lock
.await
.unwrap;
For consumers whose whole cycle is synchronous,
commit::with_file_lock takes the same lock file around a sync closure —
it serializes across processes but does not compose with the in-process
actor. Every writer of a given metadata file must take the same lock file
before mutating it — arbitration is only as strong as the convention, and
it is advisory: only cooperating writers are excluded.
Fail-fast contention (#105). When the contract is to fail on a
concurrent writer rather than wait, use the non-blocking variants
commit::try_with_file_lock (sync closure, same lock file) and
commit::try_with_metadata_file_lock (composed with the commit actor,
lock resolved via lock_file_for_metadata). Acquisition is
flock(LOCK_EX | LOCK_NB): on contention the call returns immediately
with StorageError::LockWouldBlock { path } naming the lock file, which
consumers match on to map contention into their own taxonomy (e.g. CLI
exit code 1). The lock file is still created on demand and left in place.
Operational note. The flock wait is unbounded and runs through
spawn_blocking: a parked waiter cannot be aborted, and many long-lived
waiters can exhaust the runtime's blocking-thread capacity. That is a
sound trade for metadata commits if cycles stay short, contention is
normally brief, nothing under the lock does lengthy compute/network I/O
or waits indefinitely, and you understand shutdown behavior with a stuck
holder (blocked spawn_blocking tasks are abandoned by
Runtime::shutdown_timeout, awaited by shutdown_background; process
exit always releases the flock). If waits could be prolonged or numerous,
prefer a dedicated lock-management thread, an explicit timeout or
cancellation strategy, or a storage system with transactional
coordination.
Lance format
The default build runs the in-house Lance v2.1 implementation (lancefmt) for all StorageBackend I/O: manifest with inline Overwrite transactions, txn files, version hints, MiniBlock pages with Flat / InlineBitpacking / FixedSizeList value compression. Encodings outside the supported subset are rejected with StorageError::UnsupportedFormat (never guessed). Interop conformance is fixture-based: golden fixtures written by the official lance crate are read back by the in-house reader's suite.
Extending and traits
Every custom definition of a Lance database (store or manifold or data-cube) should implement the Metadata trait (or reuse GeneMetadata) and the StorageBackend trait like LanceStorageGraph does with GeneMetadata and LanceStorage. Collections additionally surface through the Catalog trait (LocalRegistry over GeneMetadata).
Traits in traits module can also be reused to implement other formats. Other formats can use StorageBackend to implement similar child-traits alike to LanceStorage. Then if matched with a custom Metadata instance can make a database, so every database is simply a StorageBackend + Metadata.
Contributing
See .github/ directory.