| description | Run CoordiNode inside your Rust process with coordinode-embed: graph, vector and full-text queries without a separate database server. |
|---|
coordinode-embed runs CoordiNode in-process, as a library: no separate
database server and no network hop between your code and the engine. The
same directory format and Cypher dialect as the server, opened by your
process.
The library is on crates.io as
coordinode-embed, with the CE
packages it builds on (coordinode-core, coordinode-storage and the rest of
its closure), all released together under one version:
[dependencies]
coordinode-embed = "0.9"
# Query parameters and result values are coordinode_core types.
coordinode-core = "0.9"
# Only for Database::open_with_config (storage endpoints, limits, retention).
coordinode-storage = "0.9"- Compiler and targets. Build with current stable Rust. Every release is installed from the registry and run on Linux with stable Rust before it is called ready; the workspace itself builds and tests on Linux, macOS and Windows with stable Rust. The declared minimum moves up when the code needs a newer compiler; older compilers are not tested.
- Features. The default build is a standalone database (see
Build features).
replicationadds membership in a replicated group;io-uringadds the io_uring storage backend on Linux. - What the registry checks run. A registry release passes when the
quickstart example and a broader consumer build from the
published packages and run standalone, and the same consumer compiles with
replication. The broader consumer covers unique identity under concurrent writers, revision and lease-epoch fencing, an atomic audit and outbox, rollback and reopen, an increment retried after a process exit and across a checkpoint and repair, a physical backup that settles it in the same history, a logical restore into a new domain, and the hard limit. A process exit is not a power loss. Compiling with a feature is not the same as running it: run your own scenario with the features you enable on the build you ship.
Other artifacts are separate: the Python package coordinode-embedded
(pip install coordinode-embedded) embeds the same engine for Python, and
the server binary runs it as a network service. A source build from the
public repository is a
different artifact from the registry packages: pin an exact reviewed
revision, never follow main, and qualify it separately. The workspace builds
with stable Rust; a source build with replication also uses the pinned proto
submodule and its generation tooling, which the registry packages carry
already generated.
The application owns its Cargo.lock and build profile. Check the features
your dependency graph actually selects: another dependency can unify features
the engine itself does not request.
The CE license is AGPL-3.0-only. See the repository's LICENSE, COPYRIGHT
and NOTICES; dependency licenses remain their own. Releases after 0.9.0
attach to their GitHub release a license inventory and a CycloneDX SBOM of
the graph the registry check installed, per toolchain, feature set and target.
examples/embedded-quickstart
is a complete application: its own Cargo.toml with the registry packages
and a src/main.rs. It opens a database on disk, runs parameterized
statements, commits a two-statement transaction, rolls another back, handles a
duplicate as a typed error, closes the database and reopens the directory to
check that the committed rows are there and the rolled-back ones are not.
cargo run -- my-quickstart-dataThe directory belongs to the program: the first run creates the database there, each later run adds its own rows under the next run number, and nothing is ever deleted. Give it a directory of its own.
Database::open(path) opens the database in path, creating it when the
directory is empty or absent: one durable endpoint with the default storage
settings. Statements run with execute_cypher and, with parameters,
execute_cypher_with_params; parameters are coordinode_core values, never
text spliced into the statement.
use std::collections::HashMap;
use std::path::Path;
use coordinode_core::graph::types::Value;
use coordinode_embed::{Database, DatabaseError};
fn find_person(dir: &Path, name: &str) -> Result<usize, DatabaseError> {
let mut db = Database::open(dir)?;
// Safe to repeat on every open: an equal constraint already in place is kept.
db.execute_cypher(
"CREATE CONSTRAINT person_name IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE",
)?;
let params = HashMap::from([("name".to_string(), Value::String(name.to_string()))]);
db.execute_cypher_with_params("MERGE (:Person {name: $name})", params.clone())?;
let rows = db.execute_cypher_with_params(
"MATCH (p:Person {name: $name}) RETURN p.name AS name",
params,
)?;
Ok(rows.len())
}Database::open_with_config(config) opens from an explicit
coordinode_storage StorageConfig: several endpoints (one per disk, with
media, durability and tier), each endpoint's hard limit, the time-travel
retention window, the free-space reserve. The first endpoint's directory is
the database's primary directory.
use std::path::Path;
use coordinode_embed::Database;
use coordinode_storage::engine::config::{
Durability, EndpointConfig, Media, StorageConfig, Tier,
};
fn open_capped(dir: &Path) -> Result<Database, Box<dyn std::error::Error>> {
let endpoint = EndpointConfig::new("data", dir, Media::Ssd, Durability::Durable, Tier::Warm)
// Every file of the database under `dir` stays within 1 GiB.
.with_hard_limit_bytes(1 << 30);
// Refuses an inconsistent topology (a reserve outside its limit, two
// endpoints on one directory) by name.
let mut config = StorageConfig::try_with_endpoints(vec![endpoint])?;
// Time-travel reads reach one day back.
config.retention_window_secs = 24 * 3600;
// Writes stop while the file system has less than 512 MiB free.
config.min_free_bytes = 512 << 20;
config.resume_free_bytes = 1 << 30;
Ok(Database::open_with_config(config)?)
}The storage settings and their defaults are listed in
Configuration; the embedded host sets the
same fields on StorageConfig that the server reads from its config file.
Database::open_in_memory() opens a database that lives in memory only, for
tests and demos: nothing reaches the disk and nothing survives the process.
A statement run on its own commits on its own. An interactive transaction runs several statements against one snapshot and commits them atomically, or rolls them all back:
use coordinode_embed::{Database, DatabaseError};
fn transfer(db: &Database, keep: bool) -> Result<(), DatabaseError> {
let tx = db.begin_transaction()?;
db.execute_in_transaction(tx, "CREATE (:Account {id: 'a', balance: 90})", None)?;
db.execute_in_transaction(tx, "CREATE (:Account {id: 'b', balance: 10})", None)?;
if keep {
// Both accounts land at one commit timestamp, or neither does.
let receipt = db.commit_transaction(tx)?;
println!("committed at {}", receipt.commit_ts.as_raw());
} else {
// Nothing the transaction wrote is kept.
db.rollback_transaction(tx)?;
}
Ok(())
}Every call fails with a DatabaseError whose variant says what happened and
whose fields carry what a caller decides on: the transaction and why it
ended, the versions a conditioned write expected and found, the constraint
and node a write broke, the leader to retry at, the oldest readable
timestamp, an operation's outcome. Match on the variant; the rendered text is
for people. A refused write changes nothing.
use coordinode_embed::{Database, DatabaseError};
/// Creates the account, or reports that one with this email exists.
fn sign_up(db: &mut Database, email: &str) -> Result<bool, DatabaseError> {
db.execute_cypher(
"CREATE CONSTRAINT account_email IF NOT EXISTS \
FOR (a:Account) REQUIRE a.email IS UNIQUE",
)?;
let params = std::collections::HashMap::from([(
"email".to_string(),
coordinode_core::graph::types::Value::String(email.to_string()),
)]);
match db.execute_cypher_with_params("CREATE (:Account {email: $email})", params) {
Ok(_) => Ok(true),
Err(DatabaseError::UniqueViolation { .. }) => Ok(false),
Err(other) => Err(other),
}
}A refusal is one variant wherever it was found: a conflict is
TransactionConflict for an interactive commit and for a statement that
commits on its own. A storage failure no variant classifies is
DatabaseError::Storage: its message, and storage_cause() returns the
engine's error. A failure the caller cannot act on beyond reporting it is
Other. Transactions covers the full interactive
transaction contract, conflicts, retrying a write safely under an operation
identity, and resolving a write whose outcome is unknown.
A Database is closed by dropping it. Dropping it stops and joins its
background work (index builds, the journal flusher, flushes and compactions),
syncs what was written under a volatile write concern, flushes the memtables
and releases the directory. It blocks while it does: from async code, drop it
in tokio::task::spawn_blocking, not on a runtime worker.
- One handle per directory. A directory a live
Databaseholds is not opened a second time: the second open fails withDatabaseError::Storagenaming the locked store, and the first handle goes on working. Share oneDatabasewithin the process (execute_cypher_sharedand the transaction methods take&self). - Open transactions. A transaction that was not committed when its database was dropped is gone: nothing it wrote was durable, so nothing is undone.
- Acknowledged writes. The default write concern of
Database::openisjournal: journal: a write, or a transaction's commit, is answered only after its journal entry was synced to the medium (F_FULLFSYNCon macOS,fdatasyncon Linux,FlushFileBufferson Windows), and only then applied to the memtables. The directory of a new journal segment is synced before the first entry in it is (not on Windows, which has no directory sync). A transaction's effects are one journal entry, so they land together or not at all. It survives a process crash and a power failure. Weaker journal settings trade that for latency; see Durability. - Crash recovery. After a kill or power loss the next open recovers by itself: every acknowledged entry is replayed, a write cut off before it was acknowledged is dropped, and the database opens. Nothing is deleted by hand.
Reopen the directory with Database::open (or open_with_config with the
configuration it was opened with) once the earlier handle is dropped. A
member of a replicated group closes differently: ReplicatedDatabase::shutdown
hands leadership over, stops consensus and the listener, and releases the
store before it returns (see Replication).
Two independent controls bound what the database writes, and they answer different questions:
| Control | Set with | Measures | What it guarantees |
|---|---|---|---|
| Free-space reserve | StorageConfig::min_free_bytes / resume_free_bytes; live with db.engine().space().set_reserve(min, resume) |
Free space of the whole file system, other programs' files included | Writes stop before the disk itself fills, so the store keeps room to recover |
| Hard limit | EndpointConfig::with_hard_limit_bytes (and with_recovery_reserve_bytes) per endpoint, at least 8 MiB; live with Database::set_hard_limit |
Space every file of the database under the endpoint's directory takes, as the file system allocates it | The database's own files never take more than the limit, whatever else is on the disk |
Under either control a write that does not fit is refused before it reaches
the journal (CapacityExhausted for the hard limit, a "no space" storage
error for the reserve), reads go on, and writes are admitted again once room
returns. The hard limit counts the journal, tables and indexes, full-text
segments, metadata, memtables waiting to be flushed, temporary files of
flushes and compactions, and local checkpoints; before refusing, it flushes
and retires the journal the tables cover. A limit below what the database
holds deletes nothing: the database opens or keeps running, serves reads and
refuses writes until it fits again. A replicated member is held to its own
limit the same way: a full group refuses writes through the leader and through
a follower alike, keeps its consensus running, and every member keeps
answering reads, at read concern majority too. What each control counts, the reserve
inside the hard limit, and its limits (files outside the endpoint directory,
backups written elsewhere, a store received from a replicated group) are in
Hard limit: total database footprint.
The engine cannot hold unrelated processes to a size: a host that needs a
system-wide cap also sets a file-system quota.
Large binary objects are stored by id beside the graph. Each upload is its own blob, and identical chunks of different blobs are stored once:
use coordinode_embed::{Database, DatabaseError};
fn store_and_read(db: &Database, bytes: &[u8]) -> Result<Vec<u8>, DatabaseError> {
let uploaded = db.upload_blob(bytes, None)?;
let Some(reader) = db.open_blob(&uploaded.id)? else {
return Ok(Vec::new());
};
// The reader holds the state it opened at until it is dropped: a delete
// meanwhile does not take the chunks from under it.
let mut read = Vec::new();
for index in 0..reader.record().blob.chunk_count() {
read.extend(reader.chunk(index)?);
}
db.delete_blob(&uploaded.id)?;
Ok(read)
}With a client operation (Some(&descriptor)), an upload takes effect at most
once and names the same blob on every attempt (BlobId::for_operation); a
retry of an operation that already stored its blob is refused with
DatabaseError::OperationSettled. A replicated member whose group holds
other members refuses blob writes with DatabaseError::Unsupported: blob
bytes are held only on the member that writes them.
Deleting a blob ends its hold on its chunks. The database collects the chunks nothing holds any more on its own thread, woken by the deletion: after a grace period (24 hours by default, never less than the retention window), and once no reader, retained history or upload in progress needs them, their bytes are deleted and the storage compacted, which frees their space. A chunk another blob shares stays. An upload never published, such as one interrupted by a crash, is collected an hour after it began. Nothing has to be called for any of this; the same collector also answers on demand:
use std::time::Duration;
use coordinode_embed::{Database, DatabaseError};
use coordinode_embed::db::GcPolicy;
fn collect_now(db: &Database) -> Result<u64, DatabaseError> {
// Keep released chunks for two hours instead of a day.
db.set_blob_gc_policy(GcPolicy {
grace: Duration::from_secs(2 * 60 * 60),
..db.blob_gc_policy()
});
// A pass run now is the one the collector runs by itself: it never
// shortens the grace or passes a reader, history or an upload.
let pass = db.run_blob_gc(false)?;
let status = db.blob_gc_status()?;
println!(
"{} retired this pass; {} candidates waiting, {} bytes on disk",
pass.retired, status.candidates, status.blob_disk_bytes
);
Ok(status.reclaimed_bytes)
}blob_gc_status reports what waits (released chunks, unpublished uploads,
retired copies awaiting deletion), when the oldest of each began, whether a
compaction still owes space back, what has been reclaimed (payload bytes
deleted, and bytes the storage shrank by), and why collection is stopped if it
is: a record that does not decode makes ownership unknown, and nothing is
collected until it is gone. A member of a group that holds other members
collects nothing. A hard-linked checkpoint keeps the files it shares until it
is pruned.
There are two kinds of backup, and they recover different things.
| Physical backup | Logical export and restore | |
|---|---|---|
| Made with | Database::physical_backup(target) |
coordinode_embed::backup::export (binary, JSON or Cypher), then Database::restore into an empty database |
| Holds | The whole database as of the call: data, schema, indexes, operation records | Nodes, edges and schema at one snapshot, with their node ids |
| Opened as | Database::open(target), the same database |
A new database, a different domain |
| An operation committed before the backup | Settled: its retry runs nothing | Refused with ForeignOperation: never run again, never answered |
use std::path::Path;
use coordinode_embed::{Database, DatabaseError};
fn back_up(db: &Database, backups: &Path) -> Result<(), DatabaseError> {
// `target` must not exist yet; put it on another device than the database.
db.physical_backup(&backups.join("2026-10-26"))?;
Ok(())
}A physical backup is taken while writes go on. On the same file system its
files are hard links to the database's own, so it costs little space but
shares the device; elsewhere they are copies. Backups outside the endpoint
directory are not counted against the hard limit. The backup directory is a
complete database in one directory, whatever endpoint layout the original
had, and opens with plain Database::open.
Recovering from a physical backup means running it in place of the lost
database. Operations the backup holds are settled there exactly as in the
original. An operation committed after the backup is unknown to it and runs
once when retried: the backup does not have that write. Never run a backup
beside the database it was taken from: both would answer the same descriptors
from histories that have diverged. Results acknowledged after the backup
(acknowledge_operation) are back to settled in it; what the application does
about effects it already performed outside the database after the backup is
its own recovery policy.
A logical export is for moving data into a new database, on another version or with another layout. The restored database issues descriptors of its own and refuses the old ones. See Transactions for operation identity and resolving a write whose outcome is unknown.
Database::checkpoint is different again: it keeps repair bases under the
database's own directory, for verify_and_repair to rebuild a damaged
partition or STORAGE COLUMNAR table from, and protects against local
corruption only, not the loss of the device. A tree whose open fails
structurally (a lost manifest) is repaired from its tables at open instead of
failing the database; when that repair had to drop damaged data, the next
verify_and_repair rebuilds the tree from the latest checkpoint plus the
journal, and reports it as corrupt_partitions / corrupt_tables with
nothing repaired when no checkpoint exists.
A partition or table is rebuilt with the history its checkpoint copy holds
(older versions and deletions keep their timestamps, and the journal is
replayed at each entry's commit timestamp), and only when the checkpoint and
the journal prove its state. Otherwise it is left as the open repair left it
and listed in refused_partitions or refused_tables with the reason: part of
the journal after the checkpoint is gone, the checkpoint holds no copy of the
table, or the table was dropped, re-created or altered after the checkpoint.
The report is then not clean, and the data is restored from an off-device
backup.
- Default (standalone). No consensus, inter-node transport, listener or
async runtime is compiled. The
Databasecalls are synchronous. replication. A database that is a member of a replicated group, incoordinode_embed::replication. The host supplies a multi-threaded Tokio runtime; the member serves its peers on a listener of its own (consensus, snapshot and segment transfer, under mutual TLS node identity), never a client protocol. A build with the feature still runs standalone until it is given peers.io-uring. The storage engine opens on one shared io_uring ring instead of synchronouspread/pwrite, on Linux 5.6 and later. Elsewhere it is a no-op; a kernel without io_uring makes the engine log a warning and use the default backend. The registry checks do not run it; it is built and tested inside the workspace on Linux.
Enabling a feature changes the build, not the store format: a store written by a standalone build becomes the member a group is formed around in place (see below).
This release pins structured-stemmers 1.2.0, generated from Snowball main
at 6389c79f.
Its algorithms differ from the previous Git dependency: for example, English
added now stems to add instead of ad. When upgrading existing data, rebuild
full-text indexes and regenerate encrypted stem tokens from their source text
before serving them with this analyzer. Do not mix analyzer revisions within an
index generation or assume that old postings become compatible by reopening
the database. Package installation does not perform this data migration.
SQL execution and SQL fingerprinting share the same admission path. The host thread must provide at least a 2 MiB stack. Before constructing an AST, the frontend rejects input larger than 1 MiB, more than 4096 non-whitespace tokens, or more than 64 structural tokens. Structural tokens are keywords and punctuation other than commas and semicolons; non-keyword identifiers and scalar string/number literals consume width without adding structural cost. Quoted literal and comment contents are interpreted by the SQL lexer, not counted as SQL operators. The parser additionally enforces 16 recursive entries.
These are independent safety ceilings, not a promise that every expression within the token count is supported. Long flat operator chains need the structural bound even without parentheses, because formatting and destruction also traverse the AST. A wide simple projection has a different shape. Exceeding a ceiling returns an explicit error before execution, without applying a prefix of a statement. Both source and registry packages use these same bounds, without native stack switching or an extra thread per request.
use coordinode_embed::Database;
use coordinode_embed::db::StatementOptions;
use coordinode_embed::operation::{IssuerId, Resolve};
fn time_travel_and_safe_retry(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
// Time travel is bounded by the MVCC retention window (default five
// minutes, `StorageConfig::retention_window_secs`). Tune it at runtime; a
// read older than `oldest_readable_timestamp()` fails with
// `OutsideRetention` instead of answering from collected history.
db.set_retention_window(std::time::Duration::from_secs(24 * 3600));
let _horizon = db.oldest_readable_timestamp();
// A write that takes effect at most once however it is retried: number
// it with an enrolled issuer, persist the descriptor (`to_bytes`) before
// running it, and after a lost reply or a restart resolve the same
// descriptor. An issuer that ran out of numbers refuses `next` with
// `IssuerExhausted`.
let issuer = db.enroll_issuer(IssuerId(*b"payments-worker1"))?;
let op = issuer.next()?;
db.execute_cypher_shared_with(
"CREATE (:Payment {amount: 10})",
None,
None,
&StatementOptions { operation: Some(op), ..Default::default() },
)?;
let _outcome = db.resolve_operation(&op, Resolve::Lookup)?; // Committed { .. }
db.acknowledge_operation(&op)?; // release the outcome once it is no longer needed
Ok(())
}See Transactions for safe retries with operation identity and what the retention window costs in storage.
An application declares its labels, edge types and constraints as one package, typically at every open. Database::publish_schema checks the whole package against one view of the catalog, with the package itself visible to it (a constraint may name a label the same package defines, in any order), and publishes every change in one catalog commit, or nothing.
use coordinode_core::schema::definition::{
ConstraintKind, EdgeTypeSchema, LabelSchema, PropertyDef, PropertyType, SchemaMode,
};
use coordinode_embed::{
Database, DatabaseError, EdgeTypeDeclaration, Intent, LabelDeclaration, PackageConstraint,
SchemaPackage,
};
fn declare_schema(db: &Database) -> Result<bool, DatabaseError> {
let mut user = LabelSchema::new_node_id("User");
user.set_mode(SchemaMode::Strict);
user.add_property(PropertyDef::new("email", PropertyType::String));
let package = SchemaPackage {
labels: vec![LabelDeclaration { schema: user, intent: Intent::Ensure }],
edge_types: vec![EdgeTypeDeclaration {
schema: EdgeTypeSchema::new("FOLLOWS"),
intent: Intent::Ensure,
}],
constraints: vec![PackageConstraint {
name: Some("user_email".into()),
label: "User".into(),
properties: vec!["email".into()],
kind: ConstraintKind::Unique,
scope: None,
on_duplicate_rename: None,
}],
};
let published = db.publish_schema(package)?;
// Bounded: work outwaited goes on, and the answer says it is pending.
let readiness = db.await_schema(&published, std::time::Duration::from_secs(30))?;
Ok(readiness.all_ready())
}- Ensure, change, omit.
Intent::Ensurecreates an absent object, keeps an equal one and refuses a different one withCatalogObjectDiffers, which carries the revision a change would name.Intent::Change { expected_revision }changes the definition at that revision and is refused withCatalogRevisionMismatchat any other. An object the package does not mention is never dropped. - Nothing to do, nothing done. A package equal to what is in place commits nothing, scans nothing and starts no build; every object comes back
Retained. Opening an application whose schema did not change costs one catalog read. - Acceptance is not readiness. A definition that could refuse a stored node (STRICT, VALIDATED, or a NOT NULL property) is published as a candidate and proved over the stored nodes in the background; the rules in force before stay in force until the proof puts it in force, and every write meanwhile must satisfy both. A uniqueness is proved by its index build.
PublishedObject::operationnames that work;await_schemawaits for it with a bound and reportsReady,PendingorFailed. A failed proof withdraws its own candidate or constraint only; the rest of the package stays published. Both kinds of work survive a restart. - Lost replies.
Database::publish_schema_as(package, &descriptor)publishes as a client operation: a retry of a publication that committed is answered withOperationSettledand its outcome instead of publishing again. - Bounded. A package carries at most
MAX_SCHEMA_PACKAGE_DECLARATIONS(4096) declarations; a larger one is refused, never split into several commits.
define_label, create_label_schema, define_edge_type and create_edge_type_schema publish one object through the same core. A single-object call that publishes a candidate waits for its proof within the statement wait (index_build_statement_wait_secs), as CREATE CONSTRAINT waits for its build. Over gRPC the package is SchemaService.PublishSchema, awaited with AwaitSchema.
The embedded API exposes the full query engine (graph, vector, full-text, time-series) with the same Cypher dialect as the network server. Only network-facing features (gRPC server, REST transcoding, Prometheus scrape endpoint) are absent; replication between embedded members is available behind the replication feature (see below).
The same database can run as a member of a replicated group inside your process. Three things are separate:
- Build capability. Replication is compiled in only with the
replicationfeature. Without it the crate pulls in no consensus, network or async runtime code at all. - Operating mode. A build with the feature still runs a single member until you give it peers. A member alone leads its group at once, takes writes through its own log and serves nothing on the network.
- History. A store written without replication becomes the member a group is formed around, in place: no export, no import. Members that join start empty and receive everything from the leader. A member that already holds data of its own is refused as a joiner. Growth goes one way: once a store is a member, its history is the group's log, and
Database::open(with or without the feature) refuses it, writing nothing, with aDatabaseError::Storagewhosestorage_cause()isStorageError::GroupMemberStore; it opens only as a member, throughReplicatedDatabase.
[dependencies]
coordinode-embed = { version = "0.9", features = ["replication"] }
coordinode-storage = "0.9"use std::net::SocketAddr;
use std::sync::Arc;
use coordinode_embed::replication::{
Founding, MemberOptions, PeerIdentity, PeerTimeouts, Peering, ReplicatedDatabase,
ReplicationError, SecurityContext,
};
use coordinode_storage::engine::config::StorageConfig;
/// One member, no peers: it leads alone and opens no listener. Called on a
/// multi-threaded Tokio runtime.
async fn alone(storage: &StorageConfig) -> Result<(), ReplicationError> {
let db = ReplicatedDatabase::open(storage, MemberOptions::new(1), None).await?;
db.database().write().execute_cypher("CREATE (:Person {name: 'Alice'})")?;
db.shutdown().await
}
/// A member that forms a group with peers: it serves them on `listen`, and
/// its node identity (mutual TLS) comes from `security`: this member's
/// certificate and the group's CA. Member 2 joins with the enrollment this
/// member hands out; the leader adds it as a learner and promotes it to a
/// voter once it caught up.
async fn form_group(
storage: &StorageConfig,
security: Arc<SecurityContext>,
listen: SocketAddr,
identity_of_2: PeerIdentity,
) -> Result<(), ReplicationError> {
let mut options = MemberOptions::new(1);
options.peering = Some(Peering {
security,
advertise: "10.0.0.1:7080".into(),
founding: Founding::Form { deployment: None },
timeouts: PeerTimeouts::default(),
});
let leader = ReplicatedDatabase::open(storage, options, Some(listen)).await?;
// Handed to member 2, which opens with `Founding::Join(enrollment)`.
let _enrollment = leader.enrollment("10.0.0.1:7080");
leader.add_learner(2, "10.0.0.2:7080", identity_of_2).await?;
leader.promote(2).await?;
leader.shutdown().await
}An already open Database becomes a member on the same store with ReplicatedDatabase::from_database: what it committed is all there under the replicated handle.
Behaviour every host can rely on:
- Only the leader applies writes. A write made through
database()on another member, whatever its write concern, is refused withNotLeadernaming the leader when it is known, and lands on no member. A restore is refused the same way: it runs on the leader, and its records, edges, schemas and the indexes built over them reach every member. To have a write run at the leader instead, send it through the member's ingress (below). leadership()streams leadership changes;transfer_leadership(id)hands leadership to a named member (for example before a laptop sleeps) and returns once that member leads.- A member cut off from its group asks the others whether they would vote before it starts an election, so its return does not depose a leader the rest still follow.
shutdown()stops the member, its listener and its background work, and closes the store before it returns, so the same directory can be opened again at once. Dropping the handle stops the background work too, without waiting for it.- A member keeps its vote twice, in its consensus log store and in a second copy (
consensus-vote.Nfiles at the root of its store), both synced before a vote is answered. One lost or damaged copy is restored from the other at open. A member that lost every copy does not open:ReplicatedDatabase::openfails withReplicationError::Consensus(RaftNodeError::PromiseUnqualified(_))and its data stays in its directory, because a member that forgot a vote could vote twice in one term. A store opened under another node id or group than the one its vote was given as is refused before anything opens on it:RaftNodeError::Identitywhen it forms or rejoins a group,RaftNodeError::JoinWithLocalDatawhen it joins one, since a joining member needs an empty store. - Such a member comes back only as a new member: open a new node id, under its own identity, on an empty store as a joiner, and call
replace(old, new, addr, identity)on the leader. The new member joins as a learner; once it has caught up, one membership change makes it a voter and removes the old one, whose identity is retired. A call that failed or was cut short is resumed by calling again with the same arguments, and a repeated call adds no second member. A call that finds the group changed while the new member caught up acts on nothing it was not asked about: it fails withReplicationError::Consensus(RaftNodeError::Identity(_))when the new node id is a member under another identity or the old node id now belongs to another member, and withReplicationError::Consensus(RaftNodeError::Membership(_))when the voters changed under it (calling again resumes). Each refusal comes before the call's next membership change; steps it already committed stay and are not undone. It returnsOkonly once the membership showing the replacement is committed. The old directory is left as it was for the host to inspect and delete. A replacement needs the quorum: a group that lost a quorum of votes at once stays unavailable and is recovered from a backup.
A host can send every command through the member it opened, whichever member leads. ReplicatedDatabase::execute runs a statement here when this member may serve it and at the leader otherwise; the leader evaluates, validates and commits it, and the answer (columns, rows, what it wrote, its commit timestamp) is the one the leader gave. A follower never applies a write it evaluated itself. Commands travel over the member's inter-node listener, under the same node identity as consensus.
use coordinode_embed::DatabaseError;
use coordinode_embed::replication::ReplicatedDatabase;
use coordinode_embed::replication::ingress::{Placement, Statement};
async fn through_any_member(db: &ReplicatedDatabase) -> Result<(), DatabaseError> {
// On any member: runs at the leader if this member does not lead.
let _answer = db.execute(Statement::new("CREATE (:Person {name: 'Bob'})")).await?;
// Leader-local: runs only if this member leads, never routed.
let _refused = db
.execute(Statement {
placement: Placement::LeaderLocal,
..Statement::new("CREATE (:Lease {holder: 1})")
})
.await;
Ok(())
}- Where it ran. Every ingress call answers a
Routedvalue: the answer,forwarded_to(the member that ran it, when not this one) andleader(the member this one knew to lead when the answer came back,Nonewhen it knew of none). The leader is advisory and costs no extra round trip; a host that cares can move its traffic there. - Settings.
Statement::requestednames the read concern, read preference, write concern, causal position and deadline of the statement; an unnamed one is the executing member's. The same combinations a server refuses are refused here before anything runs (SettingsRefused). A read the default preference sends to the leader is answered there; nameReadPreference::Nearestto read this member's copy. - Transactions.
db.ingress()begins, runs, commits and rolls back interactive transactions under the ID the host drew (coordinode_embed::db::draw_transaction_id). The transaction's first statement opens it: set itsstart(TransactionStart: concerns, timeout outcome, operation) and the leader opens the transaction and runs the statement in one call, with no separate begin before it;ingress().beginis for a transaction whose first command is not a statement. Every command of a transaction runs at the leader that holds it, so a transaction begun through one member continues through another. A start naming an ID already open is refused withTransactionIdInUse. When the lead moves the transaction ends: the new leader answersUnknownTransaction, and the old member leading again does not revive it; run the transaction again under a new ID. A transaction left idle pastset_interactive_idle_timeoutends at the leader by itself, with the timeout outcome its start named: rolled back (a later command answersTransactionAbortedwithIdleExpired), or committed under the operation the start carried, which then resolves to that commit through any member. - Operation records.
ingress().operationenrolls issuers and resolves, acknowledges and retires operations at the leader, whose log decides them. - Leader-local.
Placement::LeaderLocalkeeps a command on this member: it runs only while this member leads and otherwise fails withNotLeadernaming the leader. A host whose writes must originate at the leader (a singleton holding a lease there) uses it, so a deposed writer's work cannot reach the new leader by another road. - No leader. While no member is known to lead, a write or a transaction command fails with
NotLeader { leader_id: None }; nothing waits for the election or applies locally. A member that still names a leader it can no longer reach fails the call withPeerUnreachablewhen it could not send it, and otherwise withPeerOutcomeUnknown, since a transport failure cannot prove the command never ran. - Failures on the way.
PeerUnreachablemeans the command was not sent (for example a member that has just joined does not know the leader's address yet), so it did not run and can be sent again; the ingress itself sends such a command again to the leader it knows then, up to three times within the statement's deadline (pauses of 25, 50 and 100 ms), before returning it. A member reached by a routed command it cannot run refuses it withNotLeadernaming the leader it knows, and never passes it on again.PeerOutcomeUnknownmeans it was sent and its answer was lost: a write may have committed, so resolve it by its operation rather than running it again. See Transactions and errors.
A command one member of a replicated group passes to another (replication::routing::CommandRouter) fails with the error the executing member raised, field for field, as if it had run here; only the engine's cause stays on that member. Two variants belong to the hop: PeerUnreachable means nothing was sent, or the member refused the command before reading it, so it did not run; PeerOutcomeUnknown means the command was sent and its answer was lost (PeerFailure::Transport, Deadline or Protocol), so it may have run. Neither is a refusal or an abort: resolve a write of unknown outcome by its operation rather than running it again.
Election timing comes in two profiles: ElectionTiming::RACK (heartbeat 150 ms, failover within a second) for servers next to each other, and ElectionTiming::PERSONAL_FLEET (heartbeat 2 s, election after 20 to 40 s) for members on laptops, phones and home links. Set it in MemberOptions::timing and give every member of a group the same one. MemberOptions::preference = ElectionPreference::Last makes a member lead only when no other can, and hand leadership back once another member has caught up. The server exposes the same choices as election_profile and election_preference; see Configuration.
Use tempfile::tempdir() for isolated per-test databases that clean up automatically on drop:
use coordinode_embed::Database;
#[test]
fn test_find_related_concepts() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(dir.path()).expect("open");
db.execute_cypher(
"CREATE (:Concept {name: 'ML'})-[:RELATED_TO]->(:Concept {name: 'AI'})"
).expect("create");
let rows = db
.execute_cypher("MATCH (a:Concept)-[:RELATED_TO]->(b) RETURN b.name")
.expect("match");
assert_eq!(rows.len(), 1);
}Add tempfile to [dev-dependencies]:
[dev-dependencies]
tempfile = "3"See Quick Start for a full example using the Docker server, or Data Model to understand the graph + vector model.