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) - 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>
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.
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.