High Availability
ArcadeDB supports a High Availability (HA) mode where multiple servers share the same databases via Raft-based replication. All servers of a cluster serve the same set of databases.
| HA is powered by Apache Ratis, a production-grade implementation of the Raft consensus protocol. The old custom replication protocol has been removed. For the underlying concepts, see High Availability Concepts. |
Quick Start
To start an ArcadeDB server with HA enabled, at minimum you need to:
-
Set
arcadedb.ha.enabledtotrue. -
Define the list of peers in the cluster via
arcadedb.ha.serverList(if you are deploying on Kubernetes, see Kubernetes). The value is a comma-separated list of peer entries. Each entry can use the readable object formhost:{raft:2434,http:2480,https:2490,priority:10}(recommended) or the positional formhost:raftPort:httpPort[:priority[:httpsPort]]; see Server List Entry Format. The optionalname@prefix is described in Named Peers. -
Optionally set the local server name via
arcadedb.server.name. Each node must have a unique name. If not specified, the default isArcadeDB_0.
Example starting a 3-node cluster on a single host (different ports):
$ bin/server.sh -Darcadedb.ha.enabled=true \
-Darcadedb.server.name=node1 \
-Darcadedb.ha.serverList=localhost:2434:2480,localhost:2435:2481,localhost:2436:2482
The Raft gRPC port is 2434 by default and is configurable via arcadedb.ha.raftPort.
The HTTP port in each entry is required so that replicas can forward non-idempotent requests to the leader over HTTP.
The cluster name is arcadedb by default; set arcadedb.ha.clusterName=<name> to run multiple independent clusters on the same network.
|
Server List Entry Format
Each comma-separated entry in arcadedb.ha.serverList can be written in either of two interchangeable forms. The two forms may be freely mixed in the same list, and both accept the optional name@ prefix (see Named Peers).
- Object form (recommended)
-
A brace-delimited set of named fields, which avoids the positional ambiguity of long colon-separated entries:
[name@]host:{raft:2434,http:2480,https:2490,priority:10}Fields are unordered and all optional, with these defaults:
Field Meaning raftRaft gRPC consensus port. Defaults to
arcadedb.ha.raftPort(2434) when omitted.httpHTTP port, used by replicas to forward non-idempotent requests to the leader. Omit if not needed.
httpsHTTPS port, used for encrypted peer-to-peer transfers (e.g. snapshot download) when
arcadedb.ssl.enabled=true. See TLS/SSL.boltClient-reachable BOLT port, advertised in the BOLT routing table so
neo4j://drivers find the leader and the followers. See Client Routing Ports.grpcClient-reachable gRPC port, advertised when a gRPC call refused on a follower names the leader to retry against. See Client Routing Ports. (Since v26.9.1)
priorityLeader-election preference (integer, default
0). The node with the highest priority is preferred as leader.boltandgrpchave no positional equivalent - an entry declaring either must use the object form. - Positional form
-
A colon-separated entry where each field is identified by position:
[name@]host:raftPort:httpPort:priority:httpsPortTrailing fields are optional, so
host,host:raftPort,host:raftPort:httpPort, andhost:raftPort:httpPort:priorityare all valid. ThehttpsPortis the fifth field and therefore requirespriorityto be present.
Prefer the object form when an entry specifies a priority or an HTTPS port - it is far easier to read db1:{raft:2434,http:2480,https:2490,priority:10} than db1:2434:2480:10:2490.
|
The following two entries are equivalent:
# Object form (recommended)
-Darcadedb.ha.serverList=db1:{raft:2434,http:2480,https:2490,priority:10}
# Positional form
-Darcadedb.ha.serverList=db1:2434:2480:10:2490
Client Routing Ports (bolt, grpc)
A client protocol’s routing view tells a driver which node to send writes to and which nodes it may read from, so it deals in the ports clients dial - never the Raft port the cluster uses to talk to itself, and not necessarily the HTTP port either.
Declare bolt and grpc whenever your nodes do not all listen on the same port for that protocol.
With the field omitted, a peer’s address for that protocol is derived as that peer’s host plus this node’s own port. That is correct only for a homogeneous deployment where every node listens on the same port - a Kubernetes StatefulSet, for example - and a one-time WARNING per protocol is logged whenever the fallback is used.
# Three nodes on one host: same host, different ports - what the fallback cannot express
-Darcadedb.ha.serverList=n0@localhost:{raft:2434,http:2480,bolt:7687,grpc:50051},n1@localhost:{raft:2435,http:2481,bolt:7688,grpc:50052},n2@localhost:{raft:2436,http:2482,bolt:7689,grpc:50053}
On a cluster like that one, leaving the ports undeclared makes every peer derive to the same address - and an address two peers both claim identifies neither of them, since two listening sockets cannot share one host:port.
Rather than advertise a leader address that is really the address of the node answering (which would send a self-redirecting client straight back to the node that just refused it), ArcadeDB advertises nothing for that protocol: BOLT then offers this node as READ and ROUTE but never as writer, and a refused gRPC call falls back to naming the leader’s HTTP address.
A WARNING naming the field to declare is logged once per protocol. (Since v26.9.1)
| A declared address always wins over a derived one. Declaring the ports of the nodes that differ is enough; the rest may keep deriving. |
Peer-to-Peer Endpoints (http, https)
The same derive-from-this-node’s-port fallback applies to the endpoints the nodes use to reach each other: a follower downloading a snapshot from the leader, a cluster verify comparing checksums with a peer, and the database presence matrix (GET /api/v1/cluster?presence=true).
When two peers resolve to one host:port, that address identifies at most one of them and nothing can say which, so ArcadeDB withholds it rather than guessing: the operation is refused and reported as refused - a peer comes back ERROR / "not verified" from a verify, and unreachable from a presence matrix - instead of quietly answering for the wrong node. (Since v26.9.1)
The refusal is visible in three places:
-
a one-time WARNING per protocol, naming the peers that could not be told apart, the address they share, and the field to declare;
-
httpAddressAmbiguous: truenext to that peer’shttpAddressinGET /api/v1/cluster, and a warning line on the node’s card in the Studio Cluster dashboard; -
the error text of whichever operation was refused.
http and https are checked independently, because they are read from independent fields with independent fallbacks. A cluster that declares distinct http ports and omits the https ones passes the HTTP check with every peer’s HTTPS endpoint still collapsed onto this node’s own; the encrypted transfer then falls back to the (guarded) plain-HTTP endpoint.
|
One request is exempt: the capability check, in which the leader asks each peer which replication formats its build understands so it can use a compact one only when every node can read it.
That call changes nothing on the peer and its reply says who answered, so ArcadeDB dials the shared address anyway and credits the answer to the node that identified itself.
A cluster that has never declared its http ports therefore still gets the compact formats, instead of falling back to the verbose ones forever with nothing to explain why. (Since v26.10.1)
On the common single-host layout where every node shares an address and the Raft and HTTP ports move in step (2434/2480, 2435/2481, …), the capability check derives each peer’s HTTP port from that same offset, so it reaches the peers the shared-address fallback cannot name.
The derived port is used for the capability check only - never for a snapshot download, a cluster verify or a forwarded write, which keep refusing a shared address. (Since v26.10.1)
A peer the leader still could not get an answer for carries capabilitiesUnknownReason next to its entry in GET /api/v1/cluster, saying whether it is unreachable, running an older build, or not identified by any address of its own - the last being the one you fix by declaring its http port. (Since v26.10.1)
| Ask the leader when you want to compare what the nodes understand, because the leader is the only node that asks the others. A follower answers for itself and omits the field for every other peer. (Since v26.10.1: a follower reported its own capabilities under the leader’s entry, which during a rolling upgrade pointed at the wrong node.) |
Named Peers
By default, each node identifies its slot in arcadedb.ha.serverList from the numeric suffix of arcadedb.server.name (for example, ArcadeDB_0 maps to the first entry, ArcadeDB_1 to the second, and so on).
Display names shown in logs and Studio are then synthesized from the local node’s prefix.
This positional convention works well for Kubernetes StatefulSets but is awkward when nodes have human-readable names such as frankfurt, london, nyc.
You can add an optional name@ prefix to any entry in arcadedb.ha.serverList to give each peer a stable, human-readable name:
$ bin/server.sh -Darcadedb.ha.enabled=true \
-Darcadedb.server.name=frankfurt \
[email protected]:2434:2480,[email protected]:2434:2480,[email protected]:2434:2480
When peer names are configured, each node identifies its slot by matching arcadedb.server.name against the configured peer names first; if no match is found, it falls back to the legacy prefix_N / prefix-N suffix resolution.
This means:
-
Server names no longer require a numeric suffix when peer names are used.
-
Display names shown in logs and Studio reflect the configured peer name (e.g.
frankfurt (10.0.0.1:2480)instead ofArcadeDB_0 (10.0.0.1:2480)). -
Mixed clusters work: entries with
name@get their explicit name; entries without it fall back to the existing positional synthesis. -
Peer names must be unique within the cluster.
The Raft peer ID is still derived from the address (e.g. 10.0.0.1_2434), so changing or omitting peer names does not affect Raft identity stability.
Architecture
ArcadeDB uses a leader/replica model with Raft consensus. At any time one server holds leadership and accepts writes; the others are replicas that serve reads and stand by for failover.
Each server persists its own Raft log segments under <rootPath>/raft-storage/.
The log is used for recovery after restart and to replicate state to peers that fall behind.
|
Size the Raft storage volume for the log, not only for the databases. If the volume fills, Ratis marks the log as failed at the first entry it could not write and the node rejects every following append while still reporting itself |
Any read (query) can execute on any server in the cluster. All writes must go through the leader: a replica that receives a write transparently forwards it to the current leader via HTTP.
Internally, every committed write follows a replicate-first, commit-after three-phase commit:
-
Phase 1 (read lock): capture the WAL pages produced by the transaction, validated against the page versions the node holds.
-
Phase 2 (no lock): append the payload to the Raft log and wait for quorum acknowledgment.
-
Phase 3: apply the pages to the local database and return to the client.
If Phase 2 times out or fails, Phase 3 never runs: no local writes, no divergence.
Multiple concurrent transactions are batched into fewer Raft round-trips via group commit (HA_RAFT_GROUP_COMMIT_BATCH_SIZE).
Phase 3 runs on the Raft apply thread of every node, the leader included, when the entry reaches its position in the log: the leader publishes the pages its transaction prepared, a replica applies the entry’s WAL bytes. Every node therefore writes its pages in log order, and the leader’s own commit can never overtake or trail the apply of a neighbouring entry.
Two transactions validated on different nodes against the same version of a page (for example two documents that share a page, each written by a different node) are both shipped to the leader with the same next version. The leader validates every entry, page by page, against the versions the log has already assigned, at the point that decides the log order, and refuses the second one before appending it: the originator receives a retryable ConcurrentModificationException, exactly as it would for a conflict on a single node, re-reads the page and retries. An entry is never merged onto another one region by region, so a node can neither lose an acknowledged write nor drift from its peers. While such an entry is in flight, a transaction on the leader that touches one of its pages fails its own Phase 1 with the same retryable error, without a round trip.
Write Quorum
A quorum is the number of peers that must acknowledge a write before it commits.
Configure it via arcadedb.ha.quorum:
-
majority(default) — a majority of peers must acknowledge (standard Raft). -
all— every configured peer must acknowledge.
If the configured quorum is not met within arcadedb.ha.quorumTimeout milliseconds, the transaction is rolled back and an error is returned to the client.
|
The legacy quorum values |
Read Consistency
When a read runs on a replica, ArcadeDB offers three consistency levels, configurable per-server via arcadedb.ha.readConsistency:
| Level | Behavior |
|---|---|
|
Read locally without waiting. Fastest, but may return data that was committed on the leader after the replica’s last apply. |
|
The replica waits until the Raft log index corresponding to the client’s most recent write has been applied locally before serving the read. |
|
The replica issues a Raft |
Clients communicate the consistency contract through three HTTP headers:
| Header | Direction | Purpose |
|---|---|---|
|
request |
Overrides the server default for this request. Accepts |
|
request |
Client-supplied bookmark: the Raft commit index the replica must have applied before serving the read. Used to implement read-your-writes across connections. |
|
response |
Current last-applied commit index, echoed on every response, including error responses and streamed ( |
Older clients that sent X-ArcadeDB-Commit-Index on requests are still accepted as a backward-compatible fallback.
|
The bookmark is a lower bound: it says the server had applied at least that index when it answered. That holds on a refused request too, so a client can keep its read-your-writes barrier after an error instead of falling back to a stale one.
The one endpoint where it is not a header is the streaming bulk load, POST /api/v1/batch/{database} with Accept: application/x-ndjson.
There the response has already started by the time the commit index is known, and a header set at that point would be dropped silently, so the value travels in the body instead, as commitIndex on the terminal summary or error line.
The Java driver handles this transparently: RemoteDatabase.getLastCommitIndex() advances the same way whichever encoding was used.
Load Shedding and Backpressure
The Raft group-commit queue is bounded (arcadedb.ha.groupCommitQueueSize, default 10000 pending transactions).
When a write arrives but the queue is full, the server waits up to arcadedb.ha.groupCommitOfferTimeout ms (default 100) for a slot, and if still full throws ReplicationQueueFullException — a NeedRetryException that clients automatically retry with backoff.
This protects the server from OOM under sustained write overload and lets clients back off gracefully.
Witness / Read-Scale Nodes
Set arcadedb.ha.serverRole=replica to pin a node as a permanent replica.
The node’s Raft priority is set to 0 at startup so it is never elected leader, while still receiving all writes and serving reads.
Useful for read-scale deployments or for cross-DC witnesses that should not take over as leader.
Automatic Failover
If the leader becomes unreachable, replicas start a new Raft election. A replica with an up-to-date log is elected as the new leader and the cluster resumes serving writes. Pre-vote prevents partitioned nodes from triggering disruptive elections.
Common causes of leader unavailability include:
-
The ArcadeDB server process has been terminated.
-
The physical or virtual host has been shut down or rebooted.
-
Network issues prevent the leader from reaching a majority of peers.
When a replica rejoins after being offline, it catches up via the Raft log automatically; if it has fallen behind past the log purge boundary, the leader streams a full database snapshot over HTTP and the replica installs it atomically.
The leader also auto-recovers a follower’s replication channel that is wedged on a stale peer address — for example after a Kubernetes pod is rescheduled onto a new IP: it re-resolves the peer and re-establishes the channel on its own, instead of leaving the follower silently disconnected and the cluster at bare quorum until an operator forces a leadership transfer. See Replication-Channel Self-Healing.
Replication-Channel Self-Healing
A follower can be up, healthy, and reachable by every other node, and still receive nothing from the leader. The leader keeps one long-lived gRPC channel per follower. If that channel is wedged on a peer address that is no longer valid (the classic case is a Kubernetes pod restarting onto a new IP inside the JVM’s positive DNS cache window), the leader’s appender for that follower keeps writing into a socket that goes nowhere. The follower’s matchIndex stops advancing, or never advances at all (matchIndex=-1), while the rest of the cluster commits normally. Quorum is unaffected as long as a majority is still healthy, so the cluster keeps serving writes at reduced redundancy and nothing fails loudly.
ArcadeDB detects and repairs this on its own, in three escalating steps.
1. Detect: the follower is reported unreachable.
The leader’s cluster monitor tracks the time since the last successful RPC to each follower. Once that exceeds arcadedb.ha.peerUnreachableThreshold (default 10s), the follower is reported unreachable. This is a diagnostic signal only: it does not change Raft membership, quorum, or the write path. It is, however, the input every step below depends on, so setting it to 0 disables the whole self-healing sequence.
2. Reset: rebuild that one follower’s channel.
Once a follower has stayed continuously unreachable for arcadedb.ha.peerChannelResetDuration (default 60s), the leader closes that follower’s replication channel so the next send re-resolves DNS and reconnects. Only the unreachable peer’s channel is touched; the other followers, and leadership itself, are left alone, so there is no flapping risk. A follower that reconnects inside the interval clears the streak and is never reset, so an in-progress reconnection is not disrupted.
A single reset does not always stick, because the rebuilt channel may still resolve the stale address if the JVM DNS cache has not expired. The reset is therefore retried once per interval, up to 5 attempts. The counter re-arms as soon as the follower becomes reachable again. Set the duration to 0 to disable the automatic reset and keep only the manual leadership transfer.
3. Escalate: transfer leadership to rebuild the appender.
The retry budget alone leaves a gap: the streak only re-arms when the follower becomes reachable, so a permanently wedged channel would sit in the given-up state until an operator restarted the leader process. arcadedb.ha.peerChannelResetEscalation (default true) closes it. When the budget is exhausted and the channel is still dead, the leader transfers leadership to a healthy peer, so the new leader builds a brand-new appender to the stuck follower.
The target is selected with the same rules as a manual step-down and is never the wedged follower itself. If no healthy peer is eligible, the leader logs at SEVERE and leaves the follower for the operator.
The escalation is bounded, not perpetual. Leadership transfer is exactly what makes a new leader inherit the problem, so each healthy peer escalates a given follower at most once per 30-minute cooldown. If the follower is unreachable for a reason a fresh appender cannot fix (it is genuinely down, or partitioned away), the cluster passes leadership around at most once per peer and then settles on the operator-intervention path instead of churning. Set the flag to false to keep the detection and logging but never move leadership automatically.
Log lines to look for
The whole sequence is visible in the leader’s log. Searching for these strings tells you which step the cluster reached:
| Log line | Meaning |
|---|---|
|
SEVERE. The follower’s replication path is dead: it has not received one entry since the leader took office. This is the symptom, logged before any repair is attempted. |
|
WARNING. Step 2. |
|
SEVERE. The budget is exhausted and escalation is enabled. The leadership transfer follows. |
|
WARNING. Step 3 firing. A leader change in the logs immediately after this one is intentional, not a failure. |
|
SEVERE. Escalation is disabled ( |
If you reach the last line, the follower is not suffering from a stale address, and rebuilding the channel will not help. Check that the follower process is alive, that its Raft port is reachable from the leader, and that its address resolves to an entry in arcadedb.ha.serverList. A peer that moved to an address outside the list is rejected by the peer allowlist described under Security.
Durable Raft Storage
|
|
With ephemeral Raft storage, wiping the log on restart could leave the cluster in a broken state:
-
a lagging follower that came back after a full-cluster cold restart could permanently diverge and fail to rejoin, surfacing as a
WALVersionGapException; or -
the cluster could silently re-form as a fresh single-node cluster, dropping committed history.
Durable storage lets a restarted node rejoin by replaying its own persisted log rather than always requiring a full snapshot resync from the leader.
Migration: the per-node directory is named raft-storage-<nodeName> dynamically, which container runtimes and Kubernetes cannot mount as a volume (a wildcard like raft-storage- is created as a literal directory, not expanded). Set arcadedb.ha.raftStorageDirectory to a *static path (e.g. /home/arcadedb/raft-storage) and mount that path on durable storage before upgrading. On Kubernetes, back it with a PersistentVolumeClaim — see Kubernetes. On a plain filesystem you can instead copy the existing raft-storage-<nodeName> directory across the upgrade. A throwaway or test cluster that intentionally wants ephemeral behavior can opt out with arcadedb.ha.raftPersistStorage=false.
Committed-remotely response contract
When a write is forwarded from a replica to the leader (or issued directly to the leader), it is possible for the transaction to be committed cluster-wide by quorum and then fail to apply on the node that is answering the client. This is a distinct outcome from an ordinary failure: the data is durably committed, so retrying would create a duplicate.
This case has its own contract. The server raises a TransactionCommittedRemotelyException, mapped over HTTP to status 409 Conflict. A client that receives it must not retry the transaction — the write already succeeded cluster-wide; it should instead re-read to observe the committed state. This is different from a transient conflict (a genuine write-write conflict or DeadlockException), which remains safe to retry.
Offline Cluster Bootstrap
By default, when an HA cluster forms for the first time and one or more peers already hold a non-empty database on local disk, the peers do not download the database over HTTP from a single source.
Instead, every peer reports a (fingerprint, lastTxId) tuple per database, the cluster elects the peer with the highest lastTxId as the source via leadership transfer, and any peer whose fingerprint matches the source bootstraps locally in seconds with zero bytes transferred.
This is the recommended path for scaling a single-instance ArcadeDB deployment up to a multi-node HA cluster after a large dataset has already been imported. Operator workflow:
-
Import the dataset into a single ArcadeDB instance with HA disabled.
-
Tar the database directory, or take a regular full backup.
-
Distribute the archive out-of-band to every peer’s filesystem (init container from S3, NFS read-only mount, baked image layer,
kubectl cp). -
Start every peer with HA enabled (
arcadedb.ha.enabled=true) and the usualarcadedb.ha.serverList. No special flag is required; the bootstrap path is on by default viaarcadedb.ha.bootstrapFromLocalDatabase=true. -
The peers form the Raft group with everyone already at the same state.
Time-to-cluster goes from "minutes-to-hours of HTTP snapshot transfer per replica" to "seconds, because everyone already has the bytes". For the Kubernetes recipe, see Pre-staging the database on every pod.
The bootstrap protocol handles the following cases automatically:
-
Identical pre-staged databases: every peer’s fingerprint matches the elected source, all peers bootstrap locally with zero bytes transferred.
-
Different-age peers: the peer with the highest
lastTxIdis elected as the source. Older peers reinstall the leader-shipped full snapshot; subsequent transactions are picked up by native Raft replication. -
Late newer joiner: a peer with a strictly newer
lastTxIdthan the cluster’s chosen baseline refuses to start and logs a SEVERE with the recovery procedure. This prevents a misconfigured rolling deploy from silently overwriting newer data with older data. -
Cluster restart: the bootstrap path is gated on every peer’s Raft log being empty. After the cluster has committed at least one Raft entry, restarts use the regular Raft log replay or leader-shipped snapshot path; the bootstrap path does not engage, and stale local data on a single pod cannot cause divergence.
-
Databases created while the cluster forms: an application that creates and seeds its databases right after the first leader election is not baselined against them. The peer that sourced the baseline is never refused for its own copy advancing past the sampled
lastTxId, and a baseline committed for a database that already has Raft history on a node is ignored on every node alike, because replication - not bootstrap - keeps those copies in step. (Since v26.9.1: before, the election could sample a database the leader had just created and then refuse the leader’s own copy as "fresher", and the cluster never converged.)
The committed bootstrap baseline (fingerprint and lastTxId per database) is visible in the response to GET /api/v1/cluster and in the Studio cluster dashboard.
Cluster Management REST API
Cluster membership and leadership can be changed at runtime through dedicated REST endpoints.
All endpoints require authentication as the root user.
| Method | Path | Description |
|---|---|---|
GET |
|
Return the current cluster status: current leader, peers and their roles, current term, commit and applied indices, per-follower replication lag, and the |
POST |
|
Add a peer to the running cluster. Body: |
DELETE |
|
Remove a peer from the cluster. The removed node also loses its access to the Raft port, including on Kubernetes, where the headless service keeps publishing a pod’s address until the pod itself terminates. (Since v26.10.1: on Kubernetes the peer was removed from the cluster but its address kept being admitted while its pod was running.) |
POST |
|
Transfer leadership. Body: |
POST |
|
Make the current leader step down. The cluster elects a new leader. Must be sent to the leader: any other server answers |
POST |
|
Gracefully remove this server from the cluster. If the local server is the leader, leadership is transferred first. This endpoint is used by the Kubernetes |
POST |
|
Compare component file checksums for the given database across all peers. Useful to confirm that all nodes have converged. See Verify Outcomes for the values |
GET |
|
Leader-only: stream a ZIP of the database for follower catch-up. Requires the cluster token and is used internally by snapshot recovery. |
|
Send leadership changes to the leader. Since 26.10.1, This matters if you call them through a Kubernetes Service or any load balancer, which spreads requests over
every ready pod: previously such a request reached a follower, was routed on to the leader anyway, and caused
an election you did not intend - while the follower replied |
Example: add a new peer at runtime.
$ curl -u root:<password> \
-X POST http://leader:2480/api/v1/cluster/peer \
-H 'Content-Type: application/json' \
-d '{"peerId":"node4","address":"10.0.0.4:2434:2480"}'
Adding a peer with a human-readable name:
$ curl -u root:<password> \
-X POST http://leader:2480/api/v1/cluster/peer \
-H 'Content-Type: application/json' \
-d '{"peerId":"10.0.0.4_2434","address":"10.0.0.4:2434:2480","name":"frankfurt"}'
Verify Outcomes
POST /api/v1/cluster/verify/{database} reports one status per peer and rolls them up into a single result.overallStatus:
overallStatus |
Meaning |
|---|---|
|
Every peer was contacted and every peer’s checksums match this node’s. |
|
At least one peer was contacted and its checksums differ - a divergence somebody has actually observed. |
|
No divergence was observed, but at least one peer could not be verified: it was unreachable, or its address does not identify it (see Peer-to-Peer Endpoints). Such a peer is reported with |
ALL_CONSISTENT therefore means what it says: it is not reachable while any peer is unverified.
An unverified peer is deliberately not rolled up as an observed divergence, which would send an operator hunting for something nobody saw.
Alerting that keys on overallStatus != "ALL_CONSISTENT" is unaffected by the third value. Alerting that keys on overallStatus == "INCONSISTENCY_DETECTED" must add VERIFICATION_INCOMPLETE to keep catching an unreachable or unidentifiable node.
|
TimeSeries Sealed Stores
(Since v26.10.1) The verify also compares the TimeSeries sealed stores (.ts.sealed), the files holding compacted historical samples.
They were previously left out, which mattered because they are replicated by their own path rather than with the data pages: a node whose sealed store failed to install, or was repaired by hand, reported a perfect match while holding different history from the leader.
Two fields report how far that comparison got:
| Field | Meaning |
|---|---|
|
The peer runs a build older than 26.10.1 and does not report these files, so they were left out of the comparison with it. Present only during a rolling upgrade; the value is how many were skipped. |
|
A sealed store could not be read, so that side’s answer is short of one. The peer is not rolled up as agreeing, and |
Both mean the same thing for alerting: a CONSISTENT that did not compare every sealed store is a weaker statement than one that did, so neither is reported as ALL_CONSISTENT.
Studio Cluster Dashboard
ArcadeDB Studio exposes a Cluster tab (visible when HA is enabled) that displays the current leader, peer roles, Raft term and commit index, per-follower replication lag, and provides buttons for leadership transfer, peer add/remove, and database verification. See Studio.
Security
- Cluster token
-
Inter-node HTTP forwarding (replica → leader proxy, snapshot downloads) is authenticated with a shared cluster token sent as the
X-ArcadeDB-Cluster-TokenHTTP header. Ifarcadedb.ha.clusterTokenis not set explicitly, the token is derived deterministically fromarcadedb.ha.clusterNameand therootpassword via PBKDF2-HMAC-SHA256 — it is not persisted on disk. Every node that starts with the same cluster name and the same root password ends up with the same token automatically. Setarcadedb.ha.clusterToken(orHA_CLUSTER_TOKEN) explicitly when you need to rotate the token without changing the root password, or when each peer has a different root password. To keep the secret off the command line (and out ofps//proc/<pid>/cmdline), setarcadedb.ha.clusterTokenPathto a file the server reads at startup, e.g. a Kubernetes Secret mounted ontmpfs. It is read only whenarcadedb.ha.clusterTokenis not set, and its content is trimmed of surrounding whitespace so a trailing newline does not break the token comparison between nodes. - Peer allowlist
-
Inbound Raft gRPC connections are filtered against the DNS-resolved hosts in
arcadedb.ha.serverList; connections from other addresses are rejected. This closes the "any host that knows the port can inject log entries" vector. It is, however, IP-based, so a spoofed source address or a compromised peer defeats it. Treat it as a best-effort default rather than a substitute for TLS: on an untrusted network, enable mTLS on the Raft gRPC transport, which binds peer identity to a certificate instead of to an address.On Kubernetes a pod’s headless-service DNS record is only published once the pod is
Ready, so peers routinely come up before each other’s names resolve and the allowlist starts out incomplete. To avoid a self-inflicted partition during that window, the filter is hardened in three ways:-
Fast convergence: while the allowlist has never been complete, a connection from an unknown address re-resolves DNS on a short floor (~1s) instead of waiting the full
arcadedb.ha.grpcAllowlistRefreshMsinterval. -
Sticky entries: when a host that resolved before temporarily stops resolving (transient DNS outage, pod-IP churn mid-restart), its last-known-good IPs are retained for
arcadedb.ha.peerAllowlistStickyTtlMs(default 5 min) rather than being evicted immediately. -
Startup fail-open grace: until every peer host has resolved at least once, and for at most
arcadedb.ha.peerAllowlistStartupGraceMs(default 60s) from startup, an unmatched address is accepted with a warning instead of rejected. Once the peer set has fully resolved once, or the window elapses, the filter enforces strictly and does not fail open again.For a security-strict deployment that should reject from the very first connection, set
arcadedb.ha.peerAllowlistStartupGraceMs=0. If you previously sawRejecting Raft gRPC connection from non-peer addressstorms with peers failing to form a cluster on startup, this hardening removes that failure mode.
-
TLS/SSL for peer-to-peer transfers
This section covers the HTTP side channels only — snapshot download and cross-node verify. It does not encrypt the Raft gRPC port, which carries AppendEntries, RequestVote and the Ratis admin/client calls. That is a separate axis, configured with arcadedb.ha.tls.*; see mTLS for the Raft gRPC transport. Enabling one does nothing for the other.
|
When arcadedb.ssl.enabled=true, the server binds an HTTPS listener on arcadedb.server.httpsIncomingPort (default 2490) in addition to the plain HTTP listener on arcadedb.server.httpIncomingPort (default 2480). The two listeners use different ports.
Peer-to-peer transfers such as snapshot download and cross-node verify travel over HTTP. To send them encrypted, declare each peer’s HTTPS port in arcadedb.ha.serverList using the https field (object form) or the fifth positional field:
$ bin/server.sh -Darcadedb.ha.enabled=true \
-Darcadedb.ssl.enabled=true \
-Darcadedb.ssl.keyStore=/etc/arcadedb/keystore.p12 \
-Darcadedb.ssl.keyStorePassword=... \
-Darcadedb.ssl.trustStore=/etc/arcadedb/truststore.jks \
-Darcadedb.ssl.trustStorePassword=... \
-Darcadedb.ha.serverList=db1:{raft:2434,http:2480,https:2490},db2:{raft:2434,http:2480,https:2490},db3:{raft:2434,http:2480,https:2490}
The peer certificate is validated against the configured trust store (arcadedb.ssl.trustStore).
If the https port is omitted on a homogeneous cluster (every node listens on the same HTTPS port, e.g. a Kubernetes StatefulSet), each peer’s HTTPS endpoint is derived automatically from the peer host plus this node’s local HTTPS port, so no extra configuration is needed. Only when no HTTPS endpoint can be resolved does the transfer fall back to plain HTTP, logging a one-time warning.
Declaring an HTTPS port that points at the plain HTTP port (e.g. https:2480) causes a TLS handshake failure (Unsupported or unrecognized SSL message). Always point https at the HTTPS listener’s port.
|
mTLS for the Raft gRPC transport (from v26.9.1)
The previous section covers the HTTP side channels. This one covers the Raft gRPC port (arcadedb.ha.raftPort, default 2434), which carries AppendEntries, RequestVote, InstallSnapshot and the Ratis admin/client calls.
The two are independent axes. arcadedb.ssl.* does nothing for the gRPC port, and arcadedb.ha.tls.* does nothing for the HTTP listeners; a hardened cluster wants both. Nor does gRPC TLS replace the X-ArcadeDB-Cluster-Token check described in Security — that check still authenticates the HTTP forwarding and snapshot-download traffic.
TLS on the Raft port is off by default, so a development or test cluster still starts with no configuration at all. Turn it on with four settings:
$ bin/server.sh -Darcadedb.ha.enabled=true \
-Darcadedb.ha.tls.enabled=true \
-Darcadedb.ha.tls.certChainFile=/etc/arcadedb/tls/tls.crt \
-Darcadedb.ha.tls.privateKeyFile=/etc/arcadedb/tls/tls.key \
-Darcadedb.ha.tls.trustCertCollectionFile=/etc/arcadedb/tls/ca.crt \
-Darcadedb.ha.serverList=db1:2434:2480,db2:2434:2480,db3:2434:2480
One setting covers all three gRPC endpoints (admin, client, and server-to-server), so there is nothing further to switch on per message type. Mutual authentication is on by default; see Server-only TLS to relax it.
When arcadedb.ha.tls.enabled=true, all three file settings must name a readable PEM file or the node refuses to start, with a ConfigurationException naming the offending setting:
arcadedb.ha.tls.certChainFile must be set when arcadedb.ha.tls.enabled is true
arcadedb.ha.tls.privateKeyFile (/etc/arcadedb/tls/tls.key) is not a readable file: Raft gRPC TLS cannot be initialized
The paths are validated at startup rather than left to the TLS layer, so a typo or an unmounted Secret volume fails once and clearly, instead of surfacing later as an opaque handshake error on every peer connection.
The private key must be PKCS#8 — the PEM whose armor labels it simply PRIVATE KEY. A PKCS#1 key, which the armor labels RSA PRIVATE KEY instead and which several toolchains emit by default, is not accepted. Convert one with openssl pkcs8 -topk8 -nocrypt.
What the certificate has to look like
The startup checks confirm the three files are readable. They cannot confirm the certificate is the right kind of certificate. Both mistakes below produce a TLS handshake failure at the first peer connection, not a startup error, so they are worth getting right up front.
- Both the
serverAuthandclientAuthextended key usages -
Every node both accepts and initiates Raft connections. With
arcadedb.ha.tls.mutualAuth=true(the default), the certificate incertChainFileis presented as the TLS server certificate to peers that dial in and as the client certificate when this node dials out — one certificate, two roles. A CA configured to issue single-purpose certificates therefore produces a file that is perfectly readable and simply the wrong kind. Ask for both EKUs explicitly; do not assume the issuer’s default profile includes them. - A subject alternative name matching the address peers dial
-
The SAN must match the host as it appears in the other nodes'
arcadedb.ha.serverList, not whatever the node calls itself locally. On Kubernetes that is the pod’s stable headless-service DNS name (my-arcadedb-0.my-arcadedb.arcadedb.svc.cluster.local), never the pod IP, which changes on every restart.
certChainFile holds this node’s certificate followed by any intermediate CA certificates, in that order. trustCertCollectionFile holds the cluster CA certificate(s) that sign every node certificate: a peer presenting a certificate that does not chain to this collection is rejected during the handshake, before any Raft message is read.
Server-only TLS
Setting arcadedb.ha.tls.mutualAuth=false encrypts the traffic but stops requiring a client certificate from peers that dial in. That leaves the Raft port open to any client that trusts the cluster CA, which is the back door mutual authentication exists to close, so the server logs a WARNING at startup:
Raft gRPC TLS is enabled with arcadedb.ha.tls.mutualAuth=false: the transport is encrypted but the
dialling peer is NOT authenticated, so any client trusting the cluster CA can still open a Raft stream
Turning mutual authentication off does not make arcadedb.ha.tls.trustCertCollectionFile optional. It only governs what this node demands of peers dialing in; when this node dials out it still needs the cluster CA to validate the certificate the other node presents. All three file settings stay mandatory whenever arcadedb.ha.tls.enabled=true.
|
Private key permissions
The server logs a WARNING when the private key file is readable by the group or by others, because any local account that can read it can present this node’s identity to the cluster:
The Raft gRPC private key arcadedb.ha.tls.privateKeyFile (/etc/arcadedb/tls/tls.key) is readable
beyond its owner: any local account that can read it can present this node's identity to the
cluster. Restrict it to owner-only (chmod 600)
It is deliberately a warning and not a refusal: a container image or a mounted Secret may arrive with permissions the node cannot change, and refusing to start would be the worse outcome. The check is a no-op on filesystems with no POSIX permission view.
Issuing a cluster CA and node certificates with openssl
For a fixed-size cluster outside Kubernetes, one self-signed CA plus one certificate per node is enough. Create the CA once, on a host that is not a cluster node:
$ openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout cluster-ca.pem -out ca.crt \
-subj "/CN=arcadedb-cluster-ca"
Then, for each node (db1 here), generate a key and a certificate signing request:
$ openssl req -newkey rsa:2048 -nodes \
-keyout db1-pkcs1.pem -out db1.csr \
-subj "/CN=db1"
Sign it with an extension file that carries both EKUs and the SAN peers will dial. This file is the part that is easy to get wrong:
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth,clientAuth
subjectAltName = DNS:db1,DNS:db1.internal.example.com
$ openssl x509 -req -in db1.csr -CA ca.crt -CAkey cluster-ca.pem -CAcreateserial \
-days 365 -sha256 -extfile db1.ext -out db1.crt
Finally, convert the key to PKCS#8 and lock it down:
$ openssl pkcs8 -topk8 -nocrypt -in db1-pkcs1.pem -out db1-key.pem
$ chmod 600 db1-key.pem
Node db1 is then configured with certChainFile=db1.crt, privateKeyFile=db1-key.pem and trustCertCollectionFile=ca.crt. Every node in the cluster gets the same ca.crt and its own certificate and key.
Confirm a certificate before rolling it out — the two lines that matter are the SAN and the EKU:
$ openssl x509 -in db1.crt -noout -ext subjectAltName,extendedKeyUsage
$ openssl verify -CAfile ca.crt db1.crt
Issuing certificates with cert-manager
On Kubernetes, cert-manager is the natural issuer: it writes a Secret with exactly the three files these settings need (tls.crt, tls.key, ca.crt) and renews it automatically.
Start from a CA Issuer backed by a self-signed root, or by your existing PKI:
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: arcadedb-cluster-ca
namespace: arcadedb
spec:
ca:
secretName: arcadedb-cluster-ca-tls
Then one Certificate per pod, named for the pod’s headless-service DNS record:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: arcadedb-raft-0
namespace: arcadedb
spec:
secretName: arcadedb-raft-0-tls
duration: 2160h # 90 days
renewBefore: 360h # 15 days
privateKey:
algorithm: RSA
size: 2048
encoding: PKCS8 # cert-manager defaults to PKCS1, which is not accepted
usages:
- server auth # both are required: every node dials and is dialled
- client auth
- digital signature
- key encipherment
dnsNames:
- my-arcadedb-0.my-arcadedb.arcadedb.svc.cluster.local
issuerRef:
name: arcadedb-cluster-ca
kind: Issuer
privateKey.encoding must be set to PKCS8 explicitly. cert-manager’s default is PKCS1, which the Raft transport does not parse.
|
Two usages entries do the work of the extendedKeyUsage line in the openssl recipe: server auth and client auth. Omitting either one is the single-purpose-certificate mistake, and it will only show up as a handshake failure between two running nodes.
Per-pod Certificate objects give each node its own identity, which is what makes a compromised node’s certificate revocable on its own. The shortcut of one shared Certificate listing every pod’s DNS name works, and is simpler for a fixed-size cluster, but every node then holds an identity valid for every other node.
With HashiCorp Vault as the issuer instead, the equivalent of the two EKUs is a PKI role created with both flags set:
$ vault write pki/roles/arcadedb-raft \
allowed_domains="my-arcadedb.arcadedb.svc.cluster.local" \
allow_subdomains=true \
server_flag=true \
client_flag=true \
key_type=rsa key_bits=2048 \
max_ttl=2160h
Mounting the Secret
Mount the certificate Secret read-only and point the three settings at the mount path. Using the Helm chart, append to volumes and volumeMounts, and set the values via arcadedb.extraCommands:
volumes:
- name: raft-tls
secret:
secretName: arcadedb-raft-0-tls
defaultMode: 0400
volumeMounts:
- name: raft-tls
mountPath: /etc/arcadedb/tls
readOnly: true
arcadedb:
extraCommands:
- -Darcadedb.ha.tls.enabled=true
- -Darcadedb.ha.tls.certChainFile=/etc/arcadedb/tls/tls.crt
- -Darcadedb.ha.tls.privateKeyFile=/etc/arcadedb/tls/tls.key
- -Darcadedb.ha.tls.trustCertCollectionFile=/etc/arcadedb/tls/ca.crt
defaultMode: 0400 keeps the private-key permission warning quiet. This is the same pattern as arcadedb.ha.clusterTokenPath in Security: the secret stays off the command line and out of ps.
Because each pod needs the certificate issued for its own DNS name, a per-pod Secret cannot be mounted from a single chart-wide value. Either render one StatefulSet-per-replica, or use cert-manager’s csi-driver, which issues a short-lived certificate into an ephemeral volume at pod start and needs no Secret at all.
No extra configuration is needed for Kubernetes auto-join: the join probe is built from the same transport parameters as the local server, so it speaks TLS exactly when the cluster does. See High Availability on Kubernetes.
Known limitations
- No certificate rotation without a restart
-
The PEM files are read once, when the Raft transport is built. Renewing a certificate — including a cert-manager renewal that rewrites the mounted
Secretin place — takes effect only after the node is restarted. Roll the nodes one at a time and wait for each to rejoin, so the cluster keeps its quorum throughout. Choose adurationthat makes the restart cadence acceptable. - No KMS integration
-
The private key is read from a file on disk. There is no HSM, PKCS#11 or cloud-KMS path; protecting the key is the deployment’s responsibility (owner-only permissions, a
tmpfs-backed mount, or a csi-driver volume that never touches durable storage).
Kubernetes
For Kubernetes deployments using the official Helm chart — including the StatefulSet + headless Service pattern, auto-join on scale-up, and the preStop auto-leave hook — see High Availability on Kubernetes in the Kubernetes guide.
Troubleshooting
Performance: insertion is slow
ArcadeDB uses an optimistic concurrency model: if two threads try to update the same page, the first wins, the second throws a ConcurrentModificationException and the client retries (configurable number of times).
In HA mode the retry window is wider because file locks are held during the Raft round-trip, and the same rule applies across nodes: two nodes writing the same page concurrently are ordered by the leader, and the second one is refused with the same retryable error.
If you are inserting many records in parallel, allocate one bucket per thread to eliminate contention.
Example for the vertex type User:
ALTER TYPE User BucketSelectionStrategy `thread`
With enough buckets, parallel insertions avoid page contention entirely and do not hit the retry path.
A database is missing after a restart, and the log names .snapshot-pending
A follower that is killed while it is installing a snapshot from the leader — a liveness-probe kill, an OOM kill, a node drain — leaves databases/<name>/ holding a mix of the previous database and the incoming one. It is neither, and opening it would register and serve that mix. The installer marks the directory with a .snapshot-pending file before it touches a single byte and removes the file only once the installed copy has been reopened successfully, so the marker’s presence is the node’s own record that the install did not finish.
While that marker exists, the database is not opened by anything: not the startup scan, not the default-database configuration (arcadedb.server.defaultDatabases), and not a client request naming it. A request for it is answered with Database '<name>' is not available: an interrupted HA snapshot install left '.snapshot-pending' in its directory. The refusal lasts as long as the marker does — it is a statement about the directory, not about a phase of startup. (Since v26.10.1)
The node still starts. HA snapshot recovery runs during startup and either completes the interrupted swap (the download had finished) or rolls it back to the previous copy (it had not); either way it removes the marker, and the database is opened before the server reports itself online. In the normal case the only trace is one INFO line naming the deferred database.
The marker survives startup in two cases:
-
The node is not running HA —
arcadedb.ha.enabled=false, or thearcadedb-ha-raftmodule is absent from an embedded deployment. Nothing runs the recovery pass, so nothing reconciles the directory. -
Recovery ran and deliberately declined to act. When there is neither a completed download nor a backup to restore from, and the directory does not hold a loadable schema, recovery leaves it and the marker untouched for inspection rather than blessing an incomplete directory as a healthy database.
Both are reported at SEVERE, naming the database:
Database '<name>' was NOT opened: snapshot recovery did not clear its '.snapshot-pending' marker, so its directory is still neither the previous database nor the installed snapshot.
To resolve it, let the cluster replace the copy: on a follower joined to a healthy cluster, POST /api/v1/cluster/resync/{database} reinstalls the database from the leader, reconciling the interrupted install before it downloads anything. It works on a deferred database — the resync never needs to open the local copy first. If the node cannot reach a leader, or it is running standalone, the directory has to be dealt with on the volume: databases/<name>/.snapshot-backup/ holds the previous copy when one was taken, and removing databases/<name>/ entirely lets the node re-acquire the database from the leader on the next reconcile. Inspect before deleting — when no backup was taken, that directory is the only copy the node has.
HA Settings
The following settings control HA behavior. A complete list of all HA parameters is available in Server Settings.
| Setting | Description | Default Value |
|---|---|---|
|
Enables HA for this server |
false |
|
Cluster name. Useful when running multiple clusters in the same network |
arcadedb |
|
Comma-separated list of peers. Each entry uses the readable object form |
(empty) |
|
Default Raft gRPC port used when an entry in |
2434 |
|
Write quorum: |
majority |
|
Timeout in ms waiting for the quorum acknowledgment |
10000 |
|
Default read consistency for follower reads: |
read_your_writes |
|
Minimum and maximum Raft election timeout in ms. Increase for WAN clusters |
2000 / 5000 |
|
Node role: |
any |
|
Number of Raft log entries after which the leader takes a snapshot |
100000 |
|
When |
true |
|
Maximum time in ms the bootstrap leader waits for every peer in |
120000 |
|
Bounded queue size and offer timeout (ms) for Raft group-commit backpressure |
10000 / 100 |
|
If true, the Raft storage directory is preserved across restarts, enabling rejoin by replaying the persisted log instead of a full snapshot resync. Defaults to |
true |
|
Parent directory for the per-node |
(empty) |
|
Time in ms since the last successful RPC to a follower before the leader reports it as unreachable. Diagnostic only — it does not change membership or quorum — but it is the signal the channel reset below depends on. 0 disables it, and with it the whole self-healing sequence. See Replication-Channel Self-Healing |
10000 |
|
Time in ms a follower must stay continuously unreachable before the leader resets that one follower’s replication channel, forcing a fresh DNS resolution and reconnect. Retried once per interval, up to 5 attempts. 0 disables. See Replication-Channel Self-Healing |
60000 |
|
When the reset retry budget is exhausted and the channel is still dead, transfer leadership to a healthy peer so the new leader builds a fresh appender to that follower. Bounded by a 30-minute per-follower cooldown on each peer. |
true |
|
How long in ms a replica must stay STALLED (its |
60000 |
|
If true, the JVM exits after exhausting step-down retries on a phase-2 replication failure. Default false keeps the server up and logs CRITICAL |
false |
|
Shared secret for inter-node authentication. If empty, auto-generated at first startup and persisted under |
(empty) |
|
Path to a file containing the cluster token. Read only when |
null |
|
Negotiate TLS on the Raft gRPC transport (AppendEntries, RequestVote, and the Ratis admin/client calls). When true, the three file settings below must all name a readable PEM file or the node refuses to start. Independent of |
false |
|
PEM file with this node’s Raft certificate followed by any intermediate CAs. The certificate needs a SAN matching the address peers dial in |
(empty) |
|
PEM file with the PKCS#8 private key matching |
(empty) |
|
PEM file with the cluster CA certificate(s) signing every node certificate. Required when |
(empty) |
|
Require the dialing peer to present a client certificate signed by the cluster CA, binding peer identity to a certificate rather than a source IP. |
true |
|
Verbose HA logging: 0=off, 1=basic, 2=detailed, 3=trace |
0 |
|
The server is running inside Kubernetes (enables auto-join) |
false |
|
DNS suffix used to reach the other servers in Kubernetes, e.g. |
(empty) |