The live graph is stored entirely in process memory by
lora_store::InMemoryGraph. The implementation is slot-indexed rather than
map-backed: node and relationship IDs are direct indexes into vectors of
optional records. Deletes leave tombstones, IDs are never reused, and a compact
live_*_count is maintained for catalog reads.
lora-database wraps this store in an ArcSwap snapshot holder. Read-only
auto-commit queries load an Arc<InMemoryGraph> and run without a store lock.
Mutating auto-commit queries stage changes against a cloned snapshot, append WAL
records when configured, and publish the new Arc atomically. Explicit
read-write transactions still serialize through the database writer mutex.
InMemoryGraph
├── nodes: Vec<Option<Arc<NodeRecord>>>
├── relationships: Vec<Option<Arc<RelationshipRecord>>>
├── outgoing: Vec<Vec<RelationshipId>>
├── incoming: Vec<Vec<RelationshipId>>
├── nodes_by_label: BTreeMap<String, Vec<NodeId>>
├── relationships_by_type: BTreeMap<String, Vec<RelationshipId>>
├── indexes: RwLock<PropertyIndexRegistry>
├── index_catalog: RwLock<IndexCatalog>
├── text_indexes: RwLock<TextIndexRegistry>
├── sorted_indexes: RwLock<SortedPropertyIndexRegistry>
├── point_indexes: RwLock<PointIndexRegistry>
├── next_node_id: u64
├── next_rel_id: u64
├── live_node_count: usize
├── live_rel_count: usize
└── recorder: Option<Arc<dyn MutationRecorder>>
Records are held behind Arc so a staged writer can share unchanged records
with the current published snapshot. Property, label, and relationship changes
use Arc::make_mut, so only touched records are cloned.
struct NodeRecord {
id: NodeId, // u64, auto-incremented
labels: Vec<String>, // trimmed, empty labels removed, duplicates removed
properties: BTreeMap<String, PropertyValue>,
}struct RelationshipRecord {
id: RelationshipId, // u64, auto-incremented
src: NodeId, // source node
dst: NodeId, // destination node
rel_type: String, // trimmed, non-empty, immutable
properties: BTreeMap<String, PropertyValue>,
}Relationship creation fails if either endpoint is missing or the trimmed type is empty.
enum PropertyValue {
Null,
Bool(bool),
Int(i64),
Float(f64),
String(String),
Binary(LoraBinary),
List(Vec<PropertyValue>),
Map(BTreeMap<String, PropertyValue>),
Date(LoraDate),
Time(LoraTime),
LocalTime(LoraLocalTime),
DateTime(LoraDateTime),
LocalDateTime(LoraLocalDateTime),
Duration(LoraDuration),
Point(LoraPoint),
Vector(LoraVector),
}Temporal, spatial, binary, and vector types are first-class property values.
Definitions live under crates/lora-store/src/types/.
Labels and relationship types map to vectors of IDs:
"User" -> [0, 1, 3, 5]
"Admin" -> [0]
"FOLLOWS" -> [0, 1, 2]
The indexes are maintained on create, label add/remove, relationship create,
relationship delete, node delete, snapshot load, and WAL replay. They preserve
deterministic key ordering through BTreeMap; the ID lists may contain gaps only
when the corresponding records have been deleted and filtered out by the read
helpers.
InMemoryGraph has lazy exact-match property indexes for nodes and
relationships. A call to find_nodes_by_property or
find_relationships_by_property builds the index for that property key the
first time it can be indexed, then keeps the active index current on future
mutations.
Indexed values:
null, booleans, integers, strings, binary values- finite floats (
NaNis not indexed;-0.0and+0.0normalize together) - lists and maps whose nested values are all indexable
Scan fallback:
- temporal values
- spatial points
- vectors
NaNfloats- nested lists/maps containing any non-indexable value
The explicit index catalog is separate from the lazy hash registry. CREATE INDEX / CREATE RANGE INDEX records a RANGE definition and activates the
matching equality and sorted-property scopes. CREATE TEXT INDEX activates a
trigram candidate index. CREATE POINT INDEX activates a grid-bucket spatial
index. CREATE LOOKUP INDEX is catalog-only because label and relationship
type token indexes are always maintained.
Catalog entries are user-visible through SHOW INDEXES, participate in the
optimizer's cost model, and are durable through snapshots and WAL/archive
mutation events. Dropping a TEXT/RANGE/POINT catalog entry releases its
catalog-backed scope; the lazy equality buckets may remain available for
ordinary exact-match lookups.
Outgoing and incoming relationship IDs are stored in two per-node vectors:
outgoing[node_id]— relationships leaving the nodeincoming[node_id]— relationships arriving at the node
Deleting a relationship removes its ID from both endpoint vectors. Deleting a node clears the node's adjacency vectors; the outer adjacency vectors are not shrunk.
Node and relationship IDs are allocated sequentially from monotonic counters and are never reused after deletion.
next_node_id: 0 -> 1 -> 2 -> ...
next_rel_id: 0 -> 1 -> 2 -> ...
This avoids stale-reference reuse but means IDs are not contiguous after deletions and slot vectors may contain tombstones.
The core traversal primitive takes a source node, a direction, and an optional relationship type filter:
- Read relationship IDs from
outgoing,incoming, or both. - Filter by relationship type when types were supplied.
- Resolve each relationship and the other endpoint node.
- Return
Vec<(RelationshipRecord, NodeRecord)>for the compatibility API, or use borrow hooks on hot executor paths to avoid record clones.
Direction::Right -> outgoing adjacency
Direction::Left -> incoming adjacency
Direction::Undirected -> outgoing + incoming
- Allocate
NodeId. - Normalize labels: trim, drop empty strings, deduplicate while preserving first occurrence.
- Insert
NodeRecordat the ID slot. - Update active label and property indexes.
- Initialize empty adjacency vectors for that slot.
- Validate both endpoints exist.
- Validate type is non-empty after trimming.
- Allocate
RelationshipId. - Insert
RelationshipRecordat the ID slot. - Update outgoing, incoming, type, and active property indexes.
delete_nodefails if the node has any incident relationships.detach_delete_nodedeletes all incident relationships first, then deletes the node.
set_node_property/set_relationship_property: insert or update one key.remove_node_property/remove_relationship_property: remove one key.replace_node_properties: replace the complete property map.merge_node_properties: merge keys without removing existing properties.add_node_label/remove_node_label/set_node_labels: modify labels with index maintenance.
Each primitive mutation emits a MutationEvent when a recorder is installed.
The storage API is split into read, catalog, borrow, and mutation traits:
GraphStorage— point lookups, ID scans, label/type scans, expansion, and default helpers.GraphCatalog— a narrow analyzer-facing slice for counts, labels, types, and property-key existence.BorrowedGraphStorage— optional&NodeRecord/&RelationshipRecordaccess for backends that can hand out references.GraphStorageMut— create, mutate, delete,clear, and property/label helper methods.
InMemoryGraph implements all four traits and overrides the hot paths. Bulk
record-returning APIs such as all_nodes() still allocate owned record vectors;
the executor uses with_node / with_relationship closures where possible.
- Single-process memory store — there is no disk-backed buffer pool or remote storage engine.
- Tombstones, no compaction — deleted IDs leave gaps in the slot vectors.
- Scoped constraint/index surface — uniqueness, existence, type, key, RANGE, TEXT, POINT, LOOKUP, VECTOR, and FULLTEXT surfaces exist. Composite RANGE definitions are cataloged, but current optimizer rewrites target one property at a time, and vector procedures still use flat scans rather than ANN execution.
- Clone compatibility APIs — bulk read helpers allocate owned records even though executor hot paths avoid many clones.
- Vectors cannot be stored inside list properties — a vector can be a direct property or a value inside a top-level map property, but list-of-vector properties are rejected to preserve future indexing options.
Snapshots are encoded by the lora-snapshot columnar codec. The current file
magic is LORACOL1; the envelope contains an explicit binary manifest, a
BLAKE3 checksum, and an optional compressed/encrypted body. lora-database writes snapshots via
an atomic <path>.tmp + rename protocol and publishes loaded snapshots by
swapping the database's ArcSwap store pointer.
The WAL is built on MutationEvent. When WAL is enabled, InMemoryGraph has a
MutationRecorder; writes are buffered into committed batches and replayed on
recovery. Named databases use the same WAL events with a .loradb container
mirror.
See Snapshots and WAL for operator-facing details.
- How reads and writes flow through the engine: Data Flow
- Value representation and property types: Value Model
- Known performance trade-offs: Performance Notes
- Broader limitations and mitigations: Known Risks
- Durability, snapshots, WAL, and admin routes: Snapshots