https://github.com/ydb-platform/ydb-janusgraph-storage-backend
https://github.com/ydb-platform/ydb-janusgraph-storage-backend
Last synced: 10 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/ydb-platform/ydb-janusgraph-storage-backend
- Owner: ydb-platform
- Created: 2026-07-18T10:20:23.000Z (12 days ago)
- Default Branch: main
- Last Pushed: 2026-07-18T23:00:49.000Z (12 days ago)
- Last Synced: 2026-07-19T01:04:51.545Z (12 days ago)
- Language: Java
- Size: 88.9 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# JanusGraph YDB Storage Backend
A [JanusGraph](https://janusgraph.org) storage backend that keeps graph data in
[YDB](https://ydb.tech) — a distributed, strongly consistent NewSQL database.
The result is a horizontally scalable, transactional TinkerPop/Gremlin graph.
The backend implements JanusGraph's **ordered key-value SPI**
(`OrderedKeyValueStoreManager`), the same integration path used by the
BerkeleyDB and FoundationDB backends. YDB is a natural fit for this SPI: it is
an ordered (range-sharded by primary key), transactional, distributed store —
architecturally the closest open-source relative of FoundationDB.
## Compatibility
| Component | Version |
|---|---|
| JanusGraph | 1.1.0 |
| YDB Java SDK | 2.4.7 |
| YDB server | any version supported by SDK 2.4.x (tested with `ydbplatform/local-ydb` 26.x) |
| Java | 8+ (module is built with target 1.8) |
## Quick start
Run a local YDB:
```bash
docker run -d --rm --name ydb-local -h localhost \
-p 2136:2136 -p 8765:8765 \
-e GRPC_PORT=2136 -e MON_PORT=8765 -e YDB_USE_IN_MEMORY_PDISKS=true \
ydbplatform/local-ydb:latest
```
Put the `janusgraph-ydb` jar and its dependencies on the JanusGraph classpath
(e.g. into `lib/` or `ext/` of a JanusGraph distribution) and configure:
```properties
gremlin.graph=org.janusgraph.core.JanusGraphFactory
storage.backend=org.janusgraph.diskstorage.ydb.YdbStoreManager
storage.ydb.endpoint=grpc://localhost:2136
storage.ydb.database=/local
storage.ydb.directory=janusgraph
# activates batched slice reads (recommended)
query.batch.enabled=true
```
> **Why the full class name?** JanusGraph resolves `storage.backend` against a
> hard-coded enum of built-in backends and otherwise treats the value as a fully
> qualified class name. There is no registration mechanism (ServiceLoader or
> similar) that would let an external module claim a shorthand like `ydb`, so
> the FQCN is the only way to wire in any third-party backend — the
> FoundationDB backend is configured the same way.
Then use JanusGraph as usual:
```java
JanusGraph graph = JanusGraphFactory.open("janusgraph-ydb.properties");
GraphTraversalSource g = graph.traversal();
```
## Configuration reference (`storage.ydb.*`)
| Option | Default | Description |
|---|---|---|
| `storage.ydb.endpoint` | `grpc://localhost:2136` | YDB endpoint; use `grpcs://` for TLS |
| `storage.ydb.database` | `/local` | YDB database path |
| `storage.ydb.directory` | `janusgraph` | Directory inside the database holding all store tables; falls back to `graph.graphname` |
| `storage.ydb.read-tx-mode` | `snapshot_ro` | YDB transaction mode for reads: `snapshot_ro`, `stale_ro`, `online_ro` (lock-free pooled reads, buffered writes) or `serializable_rw` (one interactive transaction) |
| `storage.ydb.auth-mode` | `anonymous` | `anonymous`, `token`, `static` or `environ` (standard `YDB_*` env vars) |
| `storage.ydb.auth-token` | — | Access token for `auth-mode=token` |
| `storage.ydb.auth-username` / `auth-password` | — | Credentials for `auth-mode=static` |
| `storage.ydb.session-pool-max` | `50` | Max YDB sessions; bounds the number of concurrently open JanusGraph transactions |
| `storage.ydb.session-acquire-timeout` | `10 s` | Wait for a free session before failing (retryably) |
| `storage.ydb.scan-page-size` | `10000` | Max rows per round trip during range scans |
| `storage.ydb.slice-batch-size` | `10` | Max ranges combined into one multi-statement query by batched slice reads |
| `storage.ydb.auto-partitioning` | `true` | Enable auto-partitioning by size and load for store tables |
| `storage.ydb.presplit-partitions` | `0` | Pre-split new store tables into N shards at uniform key boundaries (aligned with JanusGraph's virtual partitions); avoids the single-hot-shard warm-up phase under ingest |
## Data model
Each JanusGraph store (`edgestore`, `graphindex`, `janusgraph_ids`,
`system_properties`, `txlog`, `systemlog`) becomes one YDB row table under
`//`:
```sql
CREATE TABLE (
key String NOT NULL, -- raw bytes
value String, -- raw bytes
PRIMARY KEY (key)
);
```
YDB orders `String` primary keys by unsigned lexicographic byte comparison
(`memcmp`, shorter prefix first) — exactly the order JanusGraph requires from an
ordered key-value store; a dedicated test (`YdbKeyOrderTest`) guards this
property. Tables are created idempotently on startup (`CREATE TABLE IF NOT
EXISTS`); existing tables are never touched.
## Transactions and consistency
The backend has two shapes, selected by `storage.ydb.read-tx-mode`.
**Read-only read modes** (`snapshot_ro`, default; also `stale_ro` / `online_ro`
for lower-latency, less-fresh reads):
* Reads execute as independent **lock-free** read-only queries through the
session pool: no session pinning, no read locks, no transaction aborts for
long traversals, fully parallel batched slice reads.
* Writes are buffered client-side; a transaction always observes its own writes
(reads merge the buffer). On commit the whole buffer is applied atomically in
one `SERIALIZABLE_RW` batch driven through YDB's retry machinery.
* There is **no locking at all, by design** (last-write-wins): concurrent
writers to the same element race, and unique-index constraints are not
enforced under concurrent insertion of the same key. This is the intended
trade-off for read-heavy workloads such as agent memory; use
`serializable_rw` when you need enforced invariants.
* Visibility is read-committed-style: each read sees the latest committed data
(`snapshot_ro` — a consistent per-query snapshot).
**`serializable_rw`**: one interactive serializable YDB transaction per
JanusGraph transaction, pinned to a pooled session. Reads take optimistic
locks; conflicting commits fail with `ABORTED` (surfaced as retryable
`TemporaryBackendException`). The transaction keeps one consistent snapshot for
its whole lifetime. See `YdbReadModeSemanticsTest` for both semantics.
**Common to both modes**:
* Retryable YDB statuses map to `TemporaryBackendException`, which JanusGraph's
own machinery retries (e.g. id-block allocation — safe in both modes: it
relies on consistent reads, not locking).
* Mutations of one flush are applied as a **single multi-statement query**
(`DELETE … ON SELECT FROM AS_TABLE($keys)` + `UPSERT … SELECT FROM
AS_TABLE($rows)` per store) — one round trip regardless of batch size.
* Batched slice reads (`getSlices`, activated by `query.batch.enabled=true`)
combine up to `slice-batch-size` range reads into one multi-statement query;
in the read-only read modes the batches additionally execute **in parallel**.
* With `storage.batch-loading=true` writes bypass transactions entirely:
additions stream through YDB's **BulkUpsert** API, deletions run as
auto-committed batches — the fastest ingestion path (not atomic, not
rollbackable, as usual for batch loading).
## Vector search (mixed indexes)
The module ships a mixed-index backend (`IndexProvider`) that keeps index
documents — including **vector embeddings** — in the same YDB database:
```properties
index.vec.backend=org.janusgraph.diskstorage.ydb.index.YdbIndexProvider
index.vec.ydb.endpoint=grpc://localhost:2136
index.vec.ydb.database=/local
```
```java
// schema: float[] is a built-in JanusGraph property type
PropertyKey embedding = mgmt.makePropertyKey("embedding").dataType(float[].class).make();
mgmt.buildIndex("memoryVectors", Vertex.class)
.addKey(embedding, Parameter.of(ParameterType.customParameterName("dimension"), 1536))
.addKey(mgmt.makePropertyKey("kind").dataType(String.class).make(), Mapping.STRING.asParameter())
.buildMixedIndex("vec");
// write: the index follows graph commits automatically (updates and deletions too)
graph.addVertex("kind", "fact", "embedding", vector);
// kNN: nearest neighbours with scores, no Gremlin required
graph.indexQuery("memoryVectors", YdbVectors.nearest("embedding", queryVector))
.limit(20).vertexStream();
```
Capabilities and behavior:
* Scalar fields (string/number/date/boolean/UUID) support exact and range
predicates (`Cmp`), so `has()` lookups route through the mixed index.
* kNN runs as an **exact scan** (always correct, O(n)) until
`YdbIndexProvider#buildVectorIndex(store, field)` builds a
`vector_kmeans_tree` index — built online under a temporary name and swapped
in atomically; after that queries use ANN through the index. The index is
mutable (inserts/updates/deletes are immediately visible), but YDB never
recalculates centroids — rebuild periodically as the data drifts.
* Distance strategies: `cosine` (default), `euclidean`, `manhattan`,
`inner_product` — via `index.[X].ydb.vector-distance` or the per-key custom
parameter `distance`. Scores returned to JanusGraph are "higher is closer".
* `REINDEX` (`mgmt.updateIndex(...)`) is supported and rebuilds documents
through the provider's restore path.
* Not supported: full-text and geo predicates (YDB has neither), LIST/SET
cardinality. The standard `IndexProviderTest` TCK hard-requires full-text and
geo, so coverage lives in dedicated tests (`YdbIndexProviderVectorTest`,
`YdbMixedIndexGraphTest`).
## Limitations
* `storage.backend=ydb` shorthand is impossible (see above) — use the FQCN.
* No TTL support (`cellTTL`/`storeTTL` are disabled); JanusGraph TTL features
are unavailable with this backend.
* In the read-only read modes there is no locking: concurrent writes to the
same element are last-write-wins and unique indexes are not enforced against
concurrent duplicate insertion. Switch to `read-tx-mode=serializable_rw` for
enforced invariants.
* In `serializable_rw` mode every open transaction pins one pooled session
(size `storage.ydb.session-pool-max` accordingly) and a single transaction
must not be used from multiple threads concurrently.
* Very large single transactions are bounded by YDB's transaction limits;
prefer `storage.batch-loading=true` (BulkUpsert) for bulk ingestion.
## Development and testing
A [devcontainer](.devcontainer/) ships a JDK 11 + Maven toolchain and a
`ydbplatform/local-ydb` sidecar — nothing needs to be installed on the host:
```bash
docker compose -f .devcontainer/docker-compose.yml up -d
docker compose -f .devcontainer/docker-compose.yml exec dev mvn test
```
Integration tests resolve YDB as follows:
1. If `YDB_ENDPOINT` and `YDB_DATABASE` env vars are set (the devcontainer sets
them to the sidecar), that instance is used directly.
2. Otherwise a `ydbplatform/local-ydb` container is started via Testcontainers.
The suite includes the standard JanusGraph TCK: `KeyValueStoreTest` (both read
modes), `KeyColumnValueStoreTest` (fixed- and variable-length key packing),
`MultiWriteKeyColumnValueStoreTest`, `IDAuthorityTest`, `KCVSLogTest`,
`JanusGraphTest`, plus a byte-order guard test, a read-mode semantics test
(`YdbReadModeSemanticsTest`), a batch-loading (BulkUpsert) test and a
10k-vertex traversal smoke test. The TCK runs against the default
`snapshot_ro` read mode.
Three TCK methods are disabled, each with an in-code justification:
* `testConcurrentGetSliceAndMutate` — the TCK helper races rollback/replace of
a shared transaction handle across 64 threads while writing through it,
which is inherently racy for any backend with stateful transactions;
BerkeleyJE and FoundationDB disable it too. The read-only variant
(`testConcurrentGetSlice`) stays enabled: pooled reads are stateless.
* `testConsistencyEnforcement` / `testConcurrentConsistencyEnforcement` —
assert the exception type of JanusGraph's built-in locker, which this
backend never installs: it is deliberately lock-free in the read-only read
modes and relies on YDB's own conflict detection in `serializable_rw`;
BerkeleyJE and FoundationDB disable the same pair.