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:

  1. Set arcadedb.ha.enabled to true.

  2. 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 form host:{raft:2434,http:2480,https:2490,priority:10} (recommended) or the positional form host:raftPort:httpPort[:priority[:httpsPort]]; see Server List Entry Format. The optional name@ prefix is described in Named Peers.

  3. Optionally set the local server name via arcadedb.server.name. Each node must have a unique name. If not specified, the default is ArcadeDB_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

raft

Raft gRPC consensus port. Defaults to arcadedb.ha.raftPort (2434) when omitted.

http

HTTP port, used by replicas to forward non-idempotent requests to the leader. Omit if not needed.

https

HTTPS port, used for encrypted peer-to-peer transfers (e.g. snapshot download) when arcadedb.ssl.enabled=true. See TLS/SSL.

bolt

Client-reachable BOLT port, advertised in the BOLT routing table so neo4j:// drivers find the leader and the followers. See Client Routing Ports.

grpc

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

priority

Leader-election preference (integer, default 0). The node with the highest priority is preferred as leader.

bolt and grpc have 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:httpsPort

Trailing fields are optional, so host, host:raftPort, host:raftPort:httpPort, and host:raftPort:httpPort:priority are all valid. The httpsPort is the fifth field and therefore requires priority to 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.
IPv6 hosts

In both forms an IPv6 host is written in brackets, and keeps them in every address derived from it: [fd00::1]:2434:2480 or [fd00::1]:{raft:2434,http:2480}. An unbracketed IPv6 literal (fd00::1:2434) is refused, because its port cannot be told apart from the address. The Raft peer and the inbound gRPC peer allowlist read the host of an entry the same way, so both resolve fd00::1 from either form. (Since v26.11.1: the positional form used to split a bracketed host on its colons, giving the Raft peer a broken address while the allowlist resolved the right host.)

Port validation

Every port of an entry (raft, http, https, bolt, grpc) must be a number between 1 and 65535, and a node refuses to start on an entry that breaks this, naming the field and the entry: for example Invalid raft port value 'abc' in peer address 'db1:abc'. An empty field, such as a trailing :, is refused too. (Since v26.11.1: a non-numeric or out-of-range port in the positional form was accepted, and failed later, when the peer was dialled, with a message naming the host instead of the port.)

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.

A peer that joined the Raft group at runtime (a Kubernetes scale-up beyond arcadedb.ha.serverList, connect cluster, POST /api/v1/cluster/peer) is part of the routing view like a listed one: the Bolt ROUTE table offers it as a reader, and as the writer while it leads, a gRPC call refused on a follower names it when it leads, and its address is derived the same way from its Raft host. (Since v26.11.1: such a peer was replicated to but never routed to, and the workaround was to pre-size arcadedb.ha.serverList to the largest node count.)

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 presence matrix lists every server the node knows: the server list plus any server that joined at runtime (a Kubernetes scale-up, connect cluster). Each one is either a column of the matrix or listed in unreachable, and a declared server the cluster no longer counts as a member is also listed in notInConfiguration, since a database it lacks is not a replication gap. (Since v26.11.1: before, a pod that joined at runtime was missing from the matrix.)

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: true next to that peer’s httpAddress in GET /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 that still did not answer 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)

Every node also learns the HTTP address of a peer it cannot reach where it resolves it, the same way on every node. (Since v26.10.2) Each node states its own HTTP port and address in the capability check it sends to the others, and each answer carries the addresses the answering node holds for the other members. Neither is trusted on its own: an address is used only after the peer it names answers there under its own id. From then on that address serves every peer-to-peer call (snapshot downloads, verifies, forwarded writes) and is passed on to the other nodes. In practice, a peer admitted without an http port (connect cluster host:raftPort, POST /api/v1/cluster/peer), or declared on one node only, becomes reachable from every node within a capability round or two, whatever the port layout. An address this node’s own arcadedb.ha.serverList declares is never replaced. Only the port is learnt: an offered address is accepted only on the peer’s own Raft host. On a cluster with arcadedb.ssl.enabled, offered addresses are not probed at all, because the probe carries the cluster token and would send it over plain HTTP.

Before v26.10.2, the node that served an admission without an http port wrote a raftPort + offset guess for the new peer. Every other node derived the peer’s Raft host plus its own HTTP port, so two nodes could dial the same member on two different addresses. A declared address reached only the node it was typed on and the leader.

Ask any node when you want to compare what the nodes understand: every node asks the others, so every node can report each peer’s capabilities. This is what lets you check "is the rolling upgrade finished" through a Kubernetes Service or a load balancer, neither of which routes you to the leader. (Since v26.10.1: only the leader asked, so a follower omitted the field for every peer but itself and you had to find the leader first.)
Because every node asks, the probe traffic grows with the square of the cluster size. At normal Raft cluster sizes that is a few small HTTP calls every five seconds; it is worth knowing only if you run an unusually large voter set.

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 of ArcadeDB_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.

ArcadeDB_0 Leader ArcadeDB_1 Replica ArcadeDB_2 Replica Raft Log durable Raft Log durable Raft Log durable append entries (gRPC) append entries (gRPC)

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 RUNNING. Since v26.9.1 the node recovers from that state on its own: the health monitor (arcadedb.ha.healthCheckInterval) restarts the Raft server in place as soon as the volume has room for two log segments again - which the periodic log compaction (arcadedb.ha.snapshotInterval, escalated below arcadedb.ha.raftStorageMinFreeSpacePerc) normally provides - and logs a throttled SEVERE while it is waiting. The in-place restarts are bounded by arcadedb.ha.ratisRestartMaxRetries per incident, so a failure that is not about space (a failing disk, a permission change) is left to the operator instead of restarting the node on every tick. For the same reason, a follower that reinstalls a database from the leader’s snapshot first purges its own Raft log when that volume is under pressure, and refuses the download up front when the volume cannot hold the archive, instead of failing halfway through the extraction.

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.

Client A ArcadeDB_1 Replica (1) read request (2) result Client A ArcadeDB_1 Replica ArcadeDB_0 Leader (1) write request (2) HTTP proxy (3) 3-phase commit on leader (4) replicate via Raft (5) response (6) response

Internally, every committed write follows a replicate-first, commit-after three-phase commit:

  1. Phase 1 (read lock): capture the WAL pages produced by the transaction, validated against the page versions the node holds.

  2. Phase 2 (no lock): append the payload to the Raft log and wait for quorum acknowledgment.

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

Schema Replication

Schema DDL does not travel through the WAL pages described above. A schema change is recorded in a schema recording session, and when the outermost session on the thread closes, the leader replicates it as its own Raft entry type (SCHEMA_ENTRY), carrying the schema document and any bucket or index files the change created. One session is one entry.

Normally each DDL statement is one session. Since v26.10.1 a SQL script made only of schema definition statements runs as a single session when arcadedb.schemaBulkDDLScript is on (the default), and so does a Java Schema.bulkChange() block: the whole batch is one Raft entry (see reference/sql/sql-script.adoc#sql-script-ddl-batching). The trade-off is that the database write lock is held for the whole script.

That matters on the leader. Before appending an ordinary transaction, a commit waits for an active schema session to finish, so the transaction cannot reach the log ahead of the schema entry that creates the files it writes to. The wait lasts at most arcadedb.ha.quorumTimeout (10 s by default); after that the commit goes ahead anyway. A DDL script that runs longer than that leaves concurrent writers racing the schema entry. A long index build already opens the same window, so this is not new, but batching makes long sessions more common. Keep DDL scripts to schema definition, run data loads separately, or set arcadedb.schemaBulkDDLScript=false to go back to one entry per statement.

On the follower, since v26.10.1, a DDL entry is applied incrementally: only the components for the files the entry created are instantiated, instead of every component in the database being rebuilt (arcadedb.ha.schemaIncrementalApply, on by default). Building a large schema used to cost time quadratic in the number of types.

Since v26.10.2 the schema changes that used to rewrite schema.json on the leader alone travel in a SCHEMA_ENTRY too, so every follower, and the node that becomes leader after a failover, enforces the same rules:

  • property constraints and attributes, set with ALTER PROPERTY or the Java Property setters: mandatory, not null, read only, min, max, regexp, custom values, type, hidden, compression and external;

  • the database time zone (Schema.setZoneId()) and the date and date-time formats (arcadedb.dateFormat and arcadedb.dateTimeFormat, changed with ALTER DATABASE), and schema extensions;

  • ALTER TYPE …​ lightweight and ALTER TYPE …​ unique on an edge type;

  • the downsampling policy of a TimeSeries type (ALTER TIMESERIES TYPE …​ DOWNSAMPLING POLICY);

  • the bucket selection strategy of a type (ALTER TYPE …​ BucketSelectionStrategy).

In 26.10.1 and earlier these changed the leader’s schema only: a MANDATORY constraint set on one leader, for example, was not enforced by the next one after a failover.

Calling one of these setters through the Java API on a follower’s database now fails, as every other DDL does, with a ServerIsNotTheLeaderException (Changes to the schema must be executed on the leader server) instead of changing that follower alone. Send the statement through SQL instead, which a follower forwards to the leader. Schema.setZoneId() now also requires the updateDatabaseSettings permission, like the two format setters.

When a follower falls back to a full schema load (from v26.10.2)

An entry the incremental path cannot express makes the follower rebuild every component of the database on its apply thread: an entry that retires a file, adds a dictionary, a compacted index or a bloom filter, writes pages into a compacted index or a bloom filter, or renames component files, and any entry while arcadedb.ha.schemaIncrementalApply is false. On a large schema that is what shows up as an apply-lag spike after a DDL statement, and it re-creates every index instance cold (see the note on vector indexes below).

It is no longer silent. Each fallback is logged at INFO with the database, the entry’s Raft index, the reason, what triggered it and how long the full load took:

Applied schema entry 42 of database 'mydb' with a FULL schema load instead of the incremental refresh, which was declined
because the entry renames component files [file_renamed (4 file(s) renamed)] (filesToAdd=0, filesToRemove=0,
filesToRename=4): every component of the database was re-instantiated on the apply thread in 1830 ms

and published as two metrics, tagged by reason, that are present from the first scrape and restart from 0 with the process:

Metric Meaning

arcadedb.ha.schema.full_load.fallbacks

Full loads started, so one that threw is counted too. Alert on increase().

arcadedb.ha.schema.full_load

A timer of the full loads that finished: its count and its total time give the average cost of a fallback.

The reason tag is one of incremental_disabled, file_renamed, not_loaded, file_retired, non_incremental_new_file, non_incremental_touched_file and touched_file_missing.

A full load also re-creates every vector index with nothing resident, so the first search of each used to read all of its pages and load or rebuild the graph on the query thread. Since v26.10.2 the vector indexes that had a graph resident are warmed in the background, one after the other, after arcadedb.vectorIndex.warmUpAfterSchemaReloadDelayMs (1 s by default; -1 disables it). Indexes nobody searched are not warmed.

Renaming a type on a cluster (from v26.10.2)

ALTER TYPE <name> NAME <new-name> (and the Java DocumentType.rename()) renames the type’s files as well as the type: its buckets, the files of its indexes and, for a vertex type, its edge buckets. In 26.10.1 and earlier only the leader renamed them. Since v26.10.2 the rename replicates with the schema change: the SCHEMA_ENTRY names every file that was renamed, and each follower renames the same bucket, index and edge files before it loads the new schema. That load is a full one (reason file_renamed, see When a follower falls back to a full schema load (from v26.10.2)). Sent to a follower, the statement is forwarded to the leader like any other DDL.

A node running an older build cannot rename the files, and would drop the type’s buckets on its next schema reload. So while any peer of the Raft configuration runs an older build, or cannot be reached to say which build it runs, the rename is refused up front with HTTP 409 Conflict (Cluster is not ready for this operation, with the missing peers in exceptionArgs), and nothing is renamed on any node. Finish the rolling upgrade, or restore contact with the peer, and run the statement again.

No setting overrides this refusal: arcadedb.ha.securityEntryCapabilityGate does not apply to it. To rename a type while a peer is gone for good, remove that peer from the Raft configuration first (DELETE /api/v1/cluster/peer/{peerId}, see Cluster Management REST API).

Once a rename has been replicated, the cluster cannot be downgraded below v26.10.2 on its retained Raft log: an older build replaying the entry cannot apply the rename. Rebuild a node you need to roll back from a snapshot, as described for schema deltas below.

Schema Delta Replication (from v26.10.1)

By default each SCHEMA_ENTRY carries the whole schema document, so on a schema of several megabytes a one-property CREATE PROPERTY costs megabytes on the leader’s CPU, on the wire, in every node’s Raft log and in each follower’s rewrite of schema.json. With arcadedb.ha.schemaDelta (on by default) the leader ships a delta against the schema the followers already hold. It falls back to the whole document whenever a delta cannot be trusted: the first schema entry after it becomes leader, an entry shipped in instalments, a compaction entry, or a change too large for a delta to be worth it.

Upgrades need no ordering. A node running a version that predates the delta cannot see it: it would apply an empty schema change and its schema would diverge from the leader’s without an error. To prevent that, the leader asks every peer in the Raft configuration, through POST /api/v1/cluster/capabilities, whether it can decode a delta, and ships whole documents unless every peer has said yes. A peer that is unknown, unreachable, too old to have the endpoint, or whose answer has gone stale counts as no. During a rolling upgrade, deltas start on their own once the last node runs v26.10.1 or later.

A rolling downgrade below v26.10.1 is not protected. The negotiation controls what the leader writes from now on, not the delta entries already committed to the Raft log. A node restarted on an older build replays those entries with a decoder that cannot see the delta, applies empty schema changes and diverges silently. To roll a node back that far, rebuild it from a snapshot (reinstall its databases from a current leader and discard its Raft storage) instead of restarting it on its retained log.

While the setting is on, the leader keeps one parsed copy of the schema document per replicated database in heap to diff the next change against: tens of MB per database on a multi-MB schema. Turning the setting off releases it and forces whole documents, which is also useful when diagnosing a schema divergence. Turning it off is safe at any time; turning it back on costs one whole-document entry per database to rebuild the diff base.

Whether deltas are actually being shipped is not exposed through metrics yet. The counters are available only from Java, through RaftReplicatedDatabase.getSchemaDeltasShipped() and getSchemaDocumentsShipped(), and with arcadedb.ha.logVerbose=2 a follower logs Applying a <n>-char schema delta to database '<name>' …​ for each one.

Database settings (from v26.10.2)

ALTER DATABASE and the set database setting server command change settings that the database keeps in its configuration.json. In 26.10.1 and earlier they wrote that file on the node that ran the command only, so the followers, and a new leader after a failover, kept the previous value. For arcadedb.dateImplementation and arcadedb.dateTimeImplementation that changed what reads returned, not only how the node was tuned.

Since v26.10.2 a changed setting is replicated as its own Raft entry, and every node, the one that ran the command included, applies exactly the keys the entry names. A setting the command did not change keeps each node’s own value. The command returns once its own node has applied the change.

  • Rolling upgrades. A node running an older build cannot decode the entry. While any peer has not confirmed it can, because it runs an older build or cannot be reached, the change is refused before anything is submitted, with HTTP 409 Conflict or gRPC FAILED_PRECONDITION naming the peers, and the setting is put back on the node that ran the command. Setting arcadedb.ha.securityEntryCapabilityGate=false skips this check too, but a peer that cannot decode the entry then halts when it applies it.

  • Path-valued settings. arcadedb.externalPropertyBucketPath, arcadedb.pageSnapshotSpillPath and arcadedb.opencypher.loadCsv.importDirectory set this way now apply on every node, so the directory must exist, and be writable where the setting needs it, on all of them.

  • Unknown outcome. When the command fails with a replication timeout and it is not known whether the entry committed, the node that ran it settles the setting on what its own configuration.json holds: the new value if the entry was already applied there, otherwise the previous one, which the entry replaces on every node if it commits later. Run the command again to be sure which value is in force.

  • Refusal. When the change is refused outright, the node that ran it puts the previous value back, but only while the setting still holds the value the command stored. If a change to the same setting made on another node was applied in the meantime, that value stands.

  • Overlapping commands. The entry carries the value its own command set, not the value the node holds when the entry is submitted. When two commands change the same setting at the same time, every node applies them in commit order and ends on the value of the one that committed last.

Fixed-width float arrays (from v26.11.1)

Since v26.11.1 an ARRAY_OF_FLOATS or ARRAY_OF_DOUBLES value is stored fixed-width (see Data Types), a form older builds cannot read. It is not a Raft entry but part of the record bytes in the pages a transaction replicates, so a node on an older build would apply them and then fail every read of the record. No node writes the new form until every peer of the Raft configuration advertises the fixed-width-float-arrays capability; until then the old form is written, which every build reads. During a rolling upgrade the new form starts on its own once the last node runs v26.11.1. Both forms are always read, so a cluster holding a mix of them needs no action.

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 none, one, two, three are not supported and will cause a startup error. If you were using any of those values, update your configuration to majority or all before upgrading.

Read Consistency

When a read runs on a replica, ArcadeDB offers three consistency levels, configurable per-server via arcadedb.ha.readConsistency:

Level Behavior

eventual

Read locally without waiting. Fastest, but may return data that was committed on the leader after the replica’s last apply.

read_your_writes (default)

The replica waits until the Raft log index corresponding to the client’s most recent write has been applied locally before serving the read.

linearizable

The replica issues a Raft ReadIndex request to the leader and waits until its local applied index reaches the leader’s committed index for that request. Strongest guarantee, highest latency.

A node that is no longer a member of the Raft configuration (it left with leaveCluster or was removed with DELETE /api/v1/cluster/peer/{id}) receives no more entries. On such a node, linearizable reads and read_your_writes reads whose bookmark it has not applied are refused with a retryable error naming the reason, so the client can retry on a member. This includes reads that were already waiting when the node was removed: they are refused within a second instead of after the quorum timeout. eventual reads, and read_your_writes reads whose bookmark the node already applied, are still served. (Since v26.10.2: such reads waited the whole quorum timeout, and a read_your_writes read then served data missing the write its bookmark named.)

The same node refuses at once, with a retryable NeedRetryException, everything that would submit an entry to the cluster: a commit that carries writes (a read-only transaction still commits), a database drop, a database create or install, an ALTER DATABASE configuration change, and the replication of a security user, group or API token. The message names the reason and the way out, for a database operation Database '<name>' cannot <operation> on this server: it is not a member of the Raft cluster’s configuration any more (it left or was removed), so it receives no entries and could not apply this one. Send the request to a server of the cluster, or add this server back to it and for a security document Cannot replicate the security <document> from this server: it is not a member of the Raft cluster’s configuration any more (it left or was removed), so it receives no entries and the change would apply on every other server but not here. Send the request to a server of the cluster, or add this server back to it. (Since v26.10.2: the leader still accepted the entry and committed it on every member, while the removed node waited arcadedb.ha.quorumTimeout for an apply it never received and then released the transaction unapplied; a drop dropped the database on every member and then failed on the removed node, and a security change was in force everywhere except on the node that answered the request.)

Clients communicate the consistency contract through three HTTP headers:

Header Direction Purpose

X-ArcadeDB-Read-Consistency

request

Overrides the server default for this request. Accepts eventual, read_your_writes, or linearizable.

X-ArcadeDB-Read-After

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.

X-ArcadeDB-Commit-Index

response

Current last-applied commit index, echoed on every response, including error responses and streamed (Accept: application/x-ndjson) query responses. Clients capture this value and send it back as X-ArcadeDB-Read-After on subsequent reads.

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.

Since v26.10.2 a replica relays such a streamed load to the leader in both directions at once, so a client that sends it to a replica receives the leader’s progress lines while it is still uploading, as it does on the leader. A body that grows past arcadedb.server.httpBodyContentMaxSize after the leader started answering ends the stream with an error line carrying status 413 during the upload too. In 26.10.1 and earlier the replica handed back the leader’s answer only once the whole upload had been sent.

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. Since v26.10.2 the snapshot is compressed at DEFLATE level 1 by default (arcadedb.ha.snapshotCompressionLevel), and two followers are served at the same time; see Performance Tuning for when to change either.

A vector index’s proximity graph is built by every node on its own, so it is not part of the data the nodes compare or share. Since v26.10.2 a snapshot ships the graph file (.vecgraph) empty, without its side files, and the follower builds and verifies a graph of its own on the first search of each vector index after the install, the way every node does after CREATE INDEX. That first search pays for one graph build. In 26.10.1 and earlier the follower received the leader’s graph pages without the manifest that vouches for them and reused them unverified: each such reuse is counted in unverifiedGraphReuses in the index’s getStats() and logged at WARNING (Vector index <name> is reusing a persisted graph that carries no manifest …​). A follower that installed a snapshot before upgrading keeps that graph until its next graph persist, a REBUILD INDEX, or its next snapshot install.

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

Replica '<peer>' has NEVER received a single append (matchIndex=-1) …​

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.

Follower '<peer>' unreachable for <n>ms; triggering a replication-channel reset (attempt N/5) …​

WARNING. Step 2. N tells you how much of the retry budget is left. Repeated lines climbing to 5/5 mean the resets are not sticking.

Follower '<peer>' still unreachable after 5 replication-channel resets over <n>ms; escalating to rebuild the appender for it.

SEVERE. The budget is exhausted and escalation is enabled. The leadership transfer follows.

Transferring leadership to '<target>' to rebuild the replication appender for wedged follower '<peer>'.

WARNING. Step 3 firing. A leader change in the logs immediately after this one is intentional, not a failure.

…​ giving up automatic channel recovery - operator intervention (e.g. a leadership transfer) is required.

SEVERE. Escalation is disabled (peerChannelResetEscalation=false), no healthy peer was eligible, or the 30-minute cooldown already covered this follower. Nothing further is automatic: investigate the follower.

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

arcadedb.ha.raftPersistStorage defaults to true. The per-node Raft log under raft-storage-<nodeName> is persisted across restarts, and arcadedb.ha.raftStorageDirectory must live on durable storage (a persistent volume, not an ephemeral or tmpfs mount).

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.

When a replica refuses a write and names the leader

A replica forwards the writes it receives to the leader (see High Availability Concepts for the writes it runs itself, inside a transaction the client opened). Some writes cannot be forwarded and are refused instead: a schema change sent over a protocol with no forward, or any write when the forward itself cannot be made. Such a refusal names the leader, and since v26.10.2 every wire protocol answers it as a redirect, in that protocol’s own terms, with the leader’s address in the text or in a field of its own:

Protocol Answer

HTTP

400 with the leader’s address in exceptionArgs.

gRPC

FAILED_PRECONDITION with the leader in the arcadedb-leader-* trailers; on a streamed insert, the NOT_LEADER code with leader_address. See reference/grpc-api.adoc#grpc-api.

/ws insert session

The NOT_LEADER code with leaderAddress. See reference/http-api/http.adoc#websocket-insert-session.

Bolt

Neo.ClientError.Cluster.NotALeader. The text names the leader’s Bolt address when the routing table knows one, otherwise its HTTP address. A routing (neo4j://) driver drops the node as writer and retries against the new one.

MongoDB

10107 NotWritablePrimary, with not primary. The leader is server '<address>' in errmsg.

Redis

READONLY, the answer Redis gives to a write sent to a replica, naming the leader.

PostgreSQL

40001: the protocol has no redirect code, so the state stays the retryable one and the message ends with The leader is server '<address>'.

Gremlin Server

The error’s status message ends with . The leader is server '<address>' (HTTP address; use its Gremlin Server port). Retry there, following the production placeholder in production mode. The cluster knows its peers by their HTTP addresses, so connect to the Gremlin Server port of that host. See reference/gremlin/gremlin.adoc#gremlin-server.

MCP

The tool error (isError: true, over HTTP 200) ends with . The leader is server '<address>', appended to the message, or to the production placeholder. See reference/mcp/mcp.adoc#mcp-server.

In 26.10.1 and earlier Bolt, MongoDB, PostgreSQL and Redis answered such a refusal as an ordinary write conflict (Neo.TransientError.Transaction.DeadlockDetected, 112 WriteConflict, 40001, TRYAGAIN) without the leader’s address, so a client retried the same replica, which refused again. Gremlin Server and MCP answered it without the leader’s address too, and in production mode with the bare placeholder.

The address is added after the message is concealed in production mode, so it reaches the client in every mode. A refusal raised during an election, when no leader is known yet, names nobody and keeps its retryable answer (503 over HTTP): retry shortly.

(Since v26.10.2.) The PostgreSQL extended query protocol, which most drivers use for prepared statements, forwards a replica’s DDL, and its writes outside an explicit transaction block, to the leader like the simple query protocol does. In 26.10.1 and earlier it ran them on the replica, so the same statement could succeed or fail depending on the driver’s query mode.

Running statements on a cluster

Each server keeps its own list of running statements: the HTTP list queries and terminate query server commands and Cypher SHOW TRANSACTIONS and TERMINATE TRANSACTIONS run on the server that receives them, a replica included, and are never forwarded to the leader. A statement id names a statement on the server that issued it.

Since v26.10.2 a write that a replica forwards to the leader can be followed and stopped from either side:

  • On the replica it is listed with forwardedTo, the name of the leader running it.

  • On the leader it is listed with forwardedFrom, the id it has on the replica, and with the tag the client sent (X-ArcadeDB-Query-Tag), so list queries tag <label> finds it on the leader too.

  • terminate query <replica id> sent to the replica stops the work on the leader as well, and the client receives the termination error of its protocol.

  • terminate query <replica id> sent to the leader finds the statement by the replica’s id too.

The forward carries the statement’s id under the cluster token, which an HA cluster always has configured (see Security). The usual visibility rule applies on both servers: root sees and stops every statement, any other user only their own.

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:

  1. Import the dataset into a single ArcadeDB instance with HA disabled.

  2. Tar the database directory, or take a regular full backup.

  3. Distribute the archive out-of-band to every peer’s filesystem (init container from S3, NFS read-only mount, baked image layer, kubectl cp).

  4. Start every peer with HA enabled (arcadedb.ha.enabled=true) and the usual arcadedb.ha.serverList. No special flag is required; the bootstrap path is on by default via arcadedb.ha.bootstrapFromLocalDatabase=true.

  5. 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 lastTxId is 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 lastTxId than 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.)

While the cluster is deciding which copy to keep, every node - the leader included - answers 503 on /api/v1/ready and refuses client requests on the databases being decided, then serves normally as soon as the decision is applied. This usually takes seconds; if a peer is unreachable it can last up to arcadedb.ha.bootstrapTimeoutMs. (Since v26.10.1 the leader is held too: before, it served its own copy while collecting, and a write accepted there could stop the cluster from ever agreeing on a baseline.)

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.

When a node has stopped for good

Most problems a node reports are temporary: it is catching up, or downloading a snapshot, and it rejoins on its own. Two are not, and since v26.10.1 GET /api/v1/cluster says so directly:

  • criticalHalt - a replicated change could not be applied, so this node has stopped applying changes altogether. Its databases are frozen at that point and stay there until the node is restarted. If the reason mentions an unknown entry type, a newer node is writing a format this one cannot read: upgrade this node first, or it stops again at the same place.

  • raftLogFailure - the node cannot write to its replication log, almost always a full or read-only disk. It recovers by itself once there is room again.

Both are null on a healthy node, and both raise a matching entry in alerts. A third field, crashLoopEscalated, is true once the node has given up restarting itself and needs to be restarted by hand.

alerts also distinguishes two situations that used to look the same. bootstrap-diverged-databases means this node kept a copy that the cluster never adopted - the data is here and intact. bootstrap-database-missing means the opposite: this node is missing a database the cluster has. Do not copy a node’s database directory to the other nodes when it is the one missing the database; run POST /api/v1/cluster/resync/{database} on it instead, transferring leadership first if that node is the leader.

The full text of the error behind criticalHalt and raftLogFailure is shown to the root user only. Other users see the condition, the position and the time, which is what a monitoring rule needs.

A node in either state answers 503 on /api/v1/ready, so Kubernetes takes it out of the Service. (Since v26.10.1 this no longer depends on arcadedb.server.readinessRequiresHA: a node whose replication has stopped is never ready, whatever that setting says.)

Quarantined databases

A node quarantines a database when it cannot apply a committed Raft entry for it: an intermediate transaction never reached the node (a WAL version gap), the entry cannot be decoded from the node’s own log, applying it failed, a snapshot install did not bring the database up to the snapshot’s index, or a schema change ran on this node but was never published to the others. The entry is skipped, so the node’s copy is known to be missing a committed change. While the quarantine stands the node:

  • answers 503 on /api/v1/ready, and GET /api/v1/cluster raises the critical local-resync-in-progress alert, whose details.divergedDatabases and details.divergenceCauses name the database and the cause;

  • refuses reads that need linearizability on that database instead of serving them stale, and does not checkpoint its Raft log;

  • keeps the quarantine across restarts: it is recorded in .raft/applied-index.

The way out is a resync, which reinstalls the database from a peer and lifts the quarantine. The node starts one itself; to force it, run POST /api/v1/cluster/resync/{database} on that node. A leader cannot resync from itself: transfer leadership first (POST /api/v1/cluster/leader).

A node that is the only voter of its cluster has no peer to resync from, so a quarantine restored from disk, or raised while the cluster still had peers, never lifts there. Neither does one that every voter holds on the same database (the critical no-healthy-copy-on-any-voter alert), since no node then has a copy to serve. For those cases only, v26.10.2 adds an operator override, POST /api/v1/cluster/accept-diverged/{database}:

  • It requires the root user. It accepts the node’s local copy as the truth, and lifts the quarantine and the read floor that goes with it, in one persisted write. The response carries database, localServer, appliedIndex and, when they stood, divergenceCause and readFloor.

  • The entry the quarantine skipped is not replayed: if the copy is missing it, it stays missing. Run CHECK DATABASE afterwards, and restore from a backup if the database is damaged.

  • Every use is logged at WARNING, naming the user, the source address, the applied index and the cause: Database '<name>': user 'root' (from <address>) lifted its quarantine WITHOUT a resync, accepting this node’s copy as it is …​

  • On a node with peers that can serve a resync it is refused with 409 Conflict (gRPC FAILED_PRECONDITION), because the resync is the way out there. A database with no quarantine and no read floor answers 404.

The same override is available over gRPC as AcceptDivergedDatabase (see reference/grpc-api.adoc#grpc-api) and from Java through RemoteServer.acceptDivergedDatabase().

Entries held for the next restart (from v26.11.1)

Some committed entries belong to no single database: security users, groups and API tokens, and a database’s configuration.json. When a node cannot write one of them (for example the volume is full or read-only), it cannot quarantine a database for it. Versions before v26.11.1 skipped the entry and went on checkpointing the Raft log, so a restart did not replay it and the node silently kept the old users or settings.

Since v26.11.1 the node holds the entry instead. It does not checkpoint the Raft log past it, so the log keeps growing, and it logs at WARNING, at most every five minutes:

The Raft log is not checkpointed past index <n> because entries are held for the replay (…​)

A hold is lifted when:

  • a later entry for the same user, group or token is written successfully, or a later configuration entry rewrites every setting the held one named;

  • the database is dropped or resynced;

  • the node installs a snapshot from the leader that covers the entry;

  • the node restarts and applies the entry again.

If the warning keeps appearing, fix the cause (free the disk, restore write access) and restart the node.

A committed entry for a single database that fails to apply, whatever the cause, now quarantines that database and resyncs it (see Quarantined databases), instead of being skipped.

Lifting the stale-snapshot read floor. A node that restarts with a Raft snapshot marker ahead of the entries its databases applied (by more than arcadedb.ha.snapshotGapTolerance) is missing the entries in between, and the Raft log no longer holds them. It raises a node-wide read floor: /api/v1/ready answers 503 and every linearizable read is clamped until a full resync from a peer fills the gap. A leader cannot resync from itself, and the only voter of a cluster is always its leader, so there the floor never lifts. For that case only, v26.10.2 adds a second override, POST /api/v1/cluster/accept-stale-snapshot, which names no database because the floor is node-wide:

  • It requires the root user, and the body is ignored. It accepts the node’s databases as they are and records max(marker index, applied index) as the applied position, in one persisted write, so a restart does not raise the floor again. The response carries result, localServer, readFloor, snapshotIndex and appliedIndex.

  • The entries between the floor and the marker are not replayed: a database missing them stays missing them. Run CHECK DATABASE afterwards, and restore any damaged database from a backup.

  • A database quarantined on its own keeps its quarantine and its read floor: lift those with POST /api/v1/cluster/accept-diverged/{database}, one database at a time.

  • Every use is logged at WARNING, naming the user, the source address, the floor and the marker index: <user> lifted the node-wide stale-snapshot read floor WITHOUT a resync (read floor <n>, snapshot marker <n>, applied position now <n>) …​

  • It answers 404 when no floor stands, and 409 Conflict on a node that is not the only voter (a follower resyncs on its own, and a leader hands the leadership to a peer first) or while a snapshot download is running on the node. A change that cannot be written to disk answers 500 (Could not lift the stale-snapshot read floor: <reason>. The floor stays) and lifts nothing.

The same override is available over gRPC as AcceptStaleSnapshot (see reference/grpc-api.adoc#grpc-api) and from Java through RemoteServer.acceptStaleSnapshot().

Accepting the leader’s unverified copy. A database closed on a node while it was a follower, which the last resync could not verify, carries a marker file (.ha-unverified-closed-copy). Before such a node, once leader, reopens the copy and makes it the cluster’s copy, it asks every peer of the configuration about its own, and refuses while a peer does not answer or holds a copy that cannot be ordered against it (the critical unverified-closed-copy-refused alert). A peer that is gone for good but still in the configuration keeps the database closed on every node. Since v26.10.1 the operator override is POST /api/v1/cluster/accept-copy/{database}:

  • It requires the root user, runs on the leader only (a follower answers 400), and the body is ignored. It removes the marker and accepts the leader’s copy without the other servers' confirmation. It does not reopen the copy: the next request that names the database does, and every follower then installs it. The response carries result, database, localServer, appliedIndex and, when one stood, overriddenRefusal.

  • Every use is logged at WARNING, naming the user, the source address, the applied index and the refusal it overrode: Database '<name>': <user> accepted this leader’s copy, which the last resync could not verify, as the cluster’s copy WITHOUT the other servers' confirmation …​

  • It answers 404 when this server holds no closed copy of the database marked unverified, and 500 when the marker cannot be removed, in which case the copy stays closed.

Raft layer restarted in place

A node can restart its Raft layer without restarting the process: for example to recover once a full log volume has room again (see the note on sizing the Raft storage volume under Architecture), or to reformat its Raft storage and reinstall a snapshot when it is stuck at a stale term. Since v26.10.2 GET /api/v1/cluster reports both, and what happened after:

  • localInPlaceRestarts holds two counts since the process started: recovered, restarts that kept the Raft log, and reformatted, restarts that reformatted it. The same counts are published as metrics (see how-to/operations/monitoring.adoc#howto-monitoring).

  • localReplicationPathUnproven is true while the node has been restarted in place and has taken no replicated entry since, nor seen a newer term with a known leader. On an idle cluster that is normal for a while.

  • localLeaderUnreachableSinceRestart is true when that state outlasts the time a healthy path takes to deliver entries, while no leader has made itself known or the leader reports entries this node does not hold. It raises the follower-leader-unreachable-since-restart alert: critical when a leader is known, warning when none is, since a cluster that lost its quorum looks the same from every node. raftState reads RUNNING throughout, but the node does not count toward the quorum. If the alert outlasts the leader’s channel reset and escalation (see Replication-Channel Self-Healing), restart the node by hand.

Since v26.10.2 a follower restarted in place is no longer reformatted automatically when it looks stuck at a stale term while its replication path is still unproven: that state also matches a node the leader’s appends do not reach, and a reformat would turn it into a voter with an empty log. The reformat is held, a WARNING is logged every 5 minutes, and the follower-stuck-at-stale-term alert carries details.replicationPathUnproven: true with a recommendation to restart the node by hand.

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

/api/v1/cluster

Return the current cluster status: current leader, peers and their roles, current term, commit and applied indices, per-follower replication lag, and the alerts array. Every peer carries inConfiguration: true for a member of the live Raft configuration, false for a peer that arcadedb.ha.serverList declares but the cluster no longer contains (removed with DELETE /api/v1/cluster/peer/{peerId}, or dropped by a build predating v26.9.1 while it was down). Such a peer has role NOT_IN_CONFIGURATION instead of FOLLOWER, and the divergence raises the peers-not-in-configuration alert (critical when the missing peer is the node answering, warning otherwise); a member added at runtime but absent from the server list raises the informational peers-not-in-server-list alert, and is listed in peers[] with its httpAddress like any other peer (since v26.11.1: it was listed without one). Two peer ids on one Raft address raise the peers-share-address warning: only one process answers there but both ids vote, so remove the extra id with DELETE /api/v1/cluster/peer/{peerId}. (Since v26.10.1: two concurrent add-peer requests for one address under different ids could both commit; the add now refuses the second.) (Since v26.9.1: previously every declared peer was reported as a healthy FOLLOWER regardless of the live configuration.) Since v26.10.2 the answer also carries localInPlaceRestarts ({"recovered": <n>, "reformatted": <n>}), localReplicationPathUnproven and localLeaderUnreachableSinceRestart, see Raft layer restarted in place.

POST

/api/v1/cluster/peer

Add a peer to the running cluster. Body: {"peerId":"<id>","address":"host:raftPort:httpPort","name":"<friendlyName>","priority":0}. The optional name is the human-readable display name shown in logs and Studio. The optional priority (non-negative integer, default 0) sets the new peer’s leader-election priority the same way a priority in arcadedb.ha.serverList does - use 0 for a witness that must never become leader, a higher value for a preferred one. The new peer is seeded with the current user list so authentication works immediately. This call adds the named peer to this server’s cluster; it does not make this server join another one, and an address that resolves to this server is refused with 400. (Since v26.10.1: priority can be set here too, and adding this server’s own address is refused instead of silently doing nothing.) A peer that cannot join - it answers a connection but never catches up - is reported after arcadedb.ha.membershipChangeTimeout at the latest, and a peer that turns out to belong to a different cluster is refused at once, naming arcadedb.ha.clusterName as the thing to check. (Since v26.10.1: the call could run about half a minute past the budget.)

DELETE

/api/v1/cluster/peer/{peerId}

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

/api/v1/cluster/leader

Transfer leadership. Body: {"peerId":"<target>","timeoutMs":30000}. If peerId is omitted, Raft picks a new leader automatically. Must be sent to the leader: any other server answers 409 and names the current leader.

POST

/api/v1/cluster/stepdown

Make the current leader step down. The cluster elects a new leader. Must be sent to the leader: any other server answers 409 and names the current leader.

POST

/api/v1/cluster/leave

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 preStop hook. Like DELETE /api/v1/cluster/peer/{peerId}, it is computed against the committed Raft configuration: while this server’s Raft server is starting or restarting and cannot read it, both answer at once that the configuration cannot be read and to retry in a moment. (Since v26.11.1: during that window both used the declared arcadedb.ha.serverList instead, and waited out arcadedb.ha.membershipChangeTimeout (90 s) before reporting a missing leader.)

POST

/api/v1/cluster/verify/{database}

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 overallStatus can take.

POST

/api/v1/cluster/resync/{database}

Discard this server’s copy of the database and install a fresh snapshot from the leader. This lifts a quarantine (see Quarantined databases) and reinstalls a database the node is missing or whose snapshot install was interrupted. Refused on the leader itself: transfer leadership first. Answers 503 when no leader is reachable. The body is ignored.

POST

/api/v1/cluster/accept-diverged/{database}

(Since v26.10.2.) Accept this node’s copy of the database as it is, lifting its quarantine and read floor without a resync. Only where no peer can serve a resync: on the only voter of the cluster, or when every voter holds the database quarantined; anywhere else it answers 409. Answers 404 when nothing stands on the database. Every use is logged at WARNING. See Lifting a quarantine without a resync.

POST

/api/v1/cluster/accept-stale-snapshot

(Since v26.10.2.) Lift this node’s node-wide stale-snapshot read floor without a resync, accepting its databases as they are; the entries between the floor and the snapshot marker are not replayed. Only on the only voter of the cluster, and not while a snapshot download is running; otherwise it answers 409. Answers 404 when no floor stands, and 500 when the change cannot be persisted, leaving the floor in place. A database quarantined on its own keeps its quarantine. The body is ignored. Every use is logged at WARNING. See Lifting the stale-snapshot read floor.

POST

/api/v1/cluster/accept-copy/{database}

(Since v26.10.1.) Leader only: accept the leader’s closed copy of the database, which the last resync could not verify, as the cluster’s copy without the other servers' confirmation. The next request that names the database reopens it. A follower answers 400; a server with no such copy answers 404. The body is ignored. Every use is logged at WARNING. See Accepting the leader’s unverified copy.

GET

/api/v1/ha/snapshot/{database}

Leader-only: stream a ZIP of the database for follower catch-up. Requires the cluster token and is used internally by snapshot recovery. root’s Basic credentials are also accepted, without a cluster token: since v26.11.1 a request that carries a cluster token that does not match is answered `401 "Invalid cluster token" whatever credentials come with it, as every other cluster route does.

POST

/api/v1/cluster/security-seed

Leader-only: replicate the cluster security documents to the calling node. Requires the cluster token and is called by peers, not by operators. The caller may send digests of the copies it already holds, in which case a node already in step is answered without anything being written. Any other server answers 409 and names the current leader. (Since v26.10.1.) See Cluster security documents.

Send leadership changes to the leader. Since 26.10.1, /api/v1/cluster/leader and /api/v1/cluster/stepdown answer 409 Conflict when the server receiving them is not the leader, and the message names the leader to retry against.

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 200 OK.

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"}'

Adding a peer as a non-voting witness, which will never become leader:

$ 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","priority":0}'

Verify Outcomes

POST /api/v1/cluster/verify/{database} reports one status per peer and rolls them up into a single result.overallStatus:

overallStatus Meaning

ALL_CONSISTENT

Every peer was contacted and every peer’s checksums match this node’s.

INCONSISTENCY_DETECTED

At least one peer was contacted and its checksums differ - a divergence somebody has actually observed.

VERIFICATION_INCOMPLETE

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 status: ERROR and an error naming what to fix. (Since v26.9.1) It is also reported when the TimeSeries sealed stores could not be fully compared - see below. (Since v26.10.1)

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.

Files left out of the comparison

Some files in a database directory belong to the node that holds the copy, not to the data: two healthy nodes at the same commit can hold different ones, or one can have none. Since v26.10.2 they are left out of the checksums each node reports (GET /api/v1/ha/snapshot/{database}/checksums), so the verify no longer reports them as a divergence on a healthy cluster:

  • statistics.json, and cached-count.json from releases before 25.2.1: cached record counts and free-space hints, written when the node last closed the database;

  • database.lck, the exclusive-open marker;

  • a Graph Analytical View’s persisted CSR (gav-<view>.csr) and its contraction-hierarchy orders (.cchorder), a cache written only on the node that was leader when it closed;

  • a vector index’s proximity graph and the files derived from it (.vecgraph, .vecgraphfp, .vecordmap and .vecpq), which every node builds on its own. The vectors themselves live in the index pages, which are compared;

  • last-tx-id.bin, the highest transaction id the node assigned when it last rotated its WAL or closed.

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

peers[].uncomparedSealedStores

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.

result.incompleteSealedStores, peers[].incompleteSealedStores

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 overallStatus becomes VERIFICATION_INCOMPLETE.

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-Token HTTP header. If arcadedb.ha.clusterToken is not set explicitly, the token is derived deterministically from arcadedb.ha.clusterName and the root password 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. Set arcadedb.ha.clusterToken (or HA_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 of ps//proc/<pid>/cmdline), set arcadedb.ha.clusterTokenPath to a file the server reads at startup, e.g. a Kubernetes Secret mounted on tmpfs. It is read only when arcadedb.ha.clusterToken is 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.grpcAllowlistRefreshMs interval.

  • 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 saw Rejecting Raft gRPC connection from non-peer address storms with peers failing to form a cluster on startup, this hardening removes that failure mode.

Cluster security documents

The server users, groups and API tokens are held in server-users.jsonl, server-groups.json and server-api-tokens.json, in the server’s config/ directory. They live outside the databases, so a database snapshot does not carry them: they are replicated to the other nodes as their own Raft entries.

A node is seeded with the current set when it is admitted to the cluster, and, since v26.10.1, also re-seeds itself when it rejoins one it never left. That second case is the one that used to bite on Kubernetes: a pod whose data volume is persistent but whose config/ directory is not comes back as an existing cluster member, so it is never re-admitted, and before v26.10.1 it was never re-seeded either. It came back holding only the users created from its own environment at bootstrap, and rejected logins that worked on every other node. A node now asks the leader for the current documents shortly after it first sees one, and again after it receives a snapshot, sending digests of the copies it already holds so that a node already in step costs one HTTP request and no Raft entries.

(Since v26.10.1: a rare internal scheduling hiccup while queuing that request could silently drop it, leaving the node believing it had already asked when it never had, so it would not try again on its own until it saw another leader change or snapshot. This is now fixed, and the request is retried instead of dropped.)

With arcadedb.server.readinessRequiresHA on, a node that joined at runtime or was caught up by a snapshot is held NOT READY until its documents converge, for at most arcadedb.ha.securityConvergenceReadinessTimeout (30 s by default), so a load balancer does not route logins to it while it still enforces stale credentials. See security-convergence readiness.

A node that is already leading the cluster by the time its catch-up runs has no peer to ask, so its own copy becomes the cluster’s reference. It says so once, at INFO, starting with This node leads the cluster by the time its security catch-up ran. If that node’s config/ directory was restored out of band, re-apply the security changes made while it was away.

TLS/SSL for peer-to-peer transfers

This section covers the HTTP side channels only — every node-to-node call that is not Raft itself. 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.

The side channels covered here are the snapshot download, the cross-node verify, the follower-to-leader command forward, the peer capability and auth-session probes, the stalled-replica resync, the bootstrap-state probe, the security-document seed and the remote shutdown command. Every one of them carries the cluster token, so on a cluster with arcadedb.ssl.enabled they should all travel encrypted. 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), and its name is verified against the name the peer was dialled by.

The HTTPS listener is configured with a single keystore (arcadedb.ssl.keyStore), so it serves one identity, and the name a dial advertises in SNI selects nothing: every name is answered with the same certificate. The name a peer is dialled by is the one in arcadedb.ha.serverList, or the peer’s Raft host when the entry omits an explicit endpoint. So every name any node can be reached by has to be a subject alternative name of that one certificate. On Kubernetes that usually means listing the pod’s DNS name, the headless-service name and, if you declare peers by address, the pod IP. A name that is missing fails the handshake at the first peer-to-peer call rather than at startup.

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. The plain listener is the one that is always bound, so falling back keeps a cluster that never declared its https ports able to bootstrap, resync and be administered — but it does put the cluster token on the wire in clear text, so it is announced once per JVM:

WARNING: SSL is enabled but no HTTPS address is known for a peer; probing its bootstrap-state over plain HTTP,
which puts the cluster token on the wire in clear text. Declare each node's 'https' port in
arcadedb.ha.serverList to avoid it.

One such line is logged per JVM for the whole family of side channels, naming whichever happened to fall back first; the remedy is the same for all of them. Seeing it on a cluster you believe is encrypted end to end means at least one peer has no resolvable HTTPS endpoint — check that the https field is declared for every entry, and see Peer-to-Peer Endpoints for the case where it is declared but shared between two nodes.

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 serverAuth and clientAuth extended key usages

Every node both accepts and initiates Raft connections. With arcadedb.ha.tls.mutualAuth=true (the default), the certificate in certChainFile is 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:

db1.ext
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 Secret in 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 a duration that 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.

This includes a swap interrupted by a release older than v26.10.1, which recorded no phase: recovery works out from the files whether the new copy was already going in (it finishes the install) or the old copy was still being moved aside (it restores the old database). In the rare case a file of the incoming copy has to be set aside to let the old database open, it is moved to databases/<name>/.snapshot-orphans/ and a WARNING names it; it is safe to delete once the database is verified. (Since v26.10.1)

The marker survives startup in two cases:

  • The node is not running HA — arcadedb.ha.enabled=false, or the arcadedb-ha-raft module 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.

(Since v26.10.1.) Deleting databases/<name>/ and restarting now reliably brings the database back on a node that had it before. Earlier versions could restart without it: the node kept a record, stored next to the database directories rather than inside them, saying the database had already been checked, so deleting the directory left that record behind and the node concluded there was nothing to do. It logged that the check had completed and carried on without the database.

A node that cannot complete the reinstall — no leader reachable yet, for example — now reports the database in GET /api/v1/cluster and retries periodically until it succeeds, instead of running short of it silently.

Writes on a replica fail with a retry error during a schema change

While the leader runs a schema change (for example CREATE INDEX on a large type), or drops or reinstalls a database, it refuses write transactions that other nodes send for that database with a retryable error, and accepts them again as soon as the operation ends. Retry the write; a long index build means retries for as long as it runs. (Since v26.10.1: those writes were previously accepted and could be applied inconsistently around the schema change.)

A write on a replica is also refused with a retryable error when its transaction began before the replica had applied a schema change the leader has already applied (for example a new index), so that it cannot leave a record out of that index. The retry waits for the schema change to be applied locally and then succeeds. Callers that do not retry see a ConcurrentModificationException during DDL bursts; set transaction retries accordingly. One peer that is older, not yet probed or offline turns this protection off for every writer, and it is best-effort across a leader restart. Set arcadedb.ha.txSchemaCheck=false on the writing nodes to switch it off. (Since v26.10.1.)

A node whose Raft state machine is closed, or is being restarted in place, refuses every commit at once with a retryable NeedRetryException: Database '<name>' cannot commit on this server: its Raft state machine is closed and has not been restarted yet, so a committed entry could not be applied here. Retry shortly, or send the request to another server of the cluster. Retry on the same node once the restart is done, or on another node. (Since v26.10.2: such a commit, a leader’s included, waited for the whole quorum timeout before it failed, every commit for as long as the state machine stayed closed.)

A node logs that it sent a Raft request "to itself", or refused one "addressed to" another peer

A node answers a Raft request only when it is the peer the request was addressed to, and refuses it otherwise. The refusal is logged once a minute per peer, in one of two forms:

This node sent a Raft appendEntriesAsync meant for peer 'arcade2_2434' to itself and refused it (issue #10016):
the address of 'arcade2_2434' resolves to arcade2=[127.0.0.1], which leads back to this node. ...

Refused a Raft requestVote from peer 'arcade1_2434' addressed to peer 'arcade2_2434' (issue #10016):
the address 'arcade1_2434' holds for 'arcade2_2434' leads to this node instead. ...

Both mean the same thing: the host name of a peer in arcadedb.ha.serverList resolves to the wrong node. The usual cause is a container that was stopped or disconnected: on some container platforms its name stops resolving to its old address and resolves to the loopback address or to another container instead. The first form names the node that holds the wrong address (the one logging it). The second names it in the message, and that node is the one to inspect.

Nothing needs to be done for the cluster to keep working. The surviving majority keeps its leader, and a node that dialled itself rebuilds its connection to that peer every few seconds, so the peer is reached again as soon as its name resolves correctly. If the message persists while the peer is running, check what its name resolves to from inside the node that logs it (for example getent hosts <name> in the container), and the DNS configuration or the server list.

The same refusal applies when the nodes disagree about a peer’s id, which is derived from its host:raftPort entry in the server list: a node whose own entry differs from the one the other nodes hold for it is unreachable to them, and the message names the id they used. Make every node’s server list spell each peer the same way.

(Since v26.10.2.) Earlier versions answered such a request in the name of the peer it was meant for. A leader that received its own heartbeat stepped down, and a candidate that received its own vote request failed every election with java.lang.IllegalStateException at RaftConfigurationImpl.hasMajority. The cluster then had no leader until the surviving nodes were restarted.

Verbose HA logging

Enable detailed HA logging for diagnostics via arcadedb.ha.logVerbose:

  • 0 — off (default).

  • 1 — basic: elections, leader changes, peer membership.

  • 2 — detailed: commands, WAL replication, schema.

  • 3 — trace: every state machine apply.

Ratis warning: snapshot "has missing MD5 file"

Versions before 26.10.1 log this WARNING on every checkpoint and every restart, more often the longer a node runs:

Snapshot file SingleFileSnapshotInfo(t:1, i:7293):[.../sm/snapshot.1_7293] has missing MD5 file.

It never meant corruption. ArcadeDB’s real snapshot is the set of database files the transaction manager has already flushed to disk. The file Ratis inspects is a zero-byte marker whose name carries the (term, index) pair used to decide which Raft log segments can be purged, and it deliberately has no .md5 companion. Since 26.10.1 the warning is filtered out (see Per-package verbosity); other messages from the same Ratis logger are still logged.

HA Settings

The following settings control HA behavior. A complete list of all HA parameters is available in Server Settings.

Setting Description Default Value

arcadedb.ha.enabled

Enables HA for this server

false

arcadedb.ha.clusterName

Cluster name. Useful when running multiple clusters in the same network

arcadedb

arcadedb.ha.serverList

Comma-separated list of peers. Each entry uses the readable object form [name@]host:{raft:2434,http:2480,https:2490,priority:10} (recommended) or the positional form [name@]host:raftPort:httpPort[:priority[:httpsPort]]; see Server List Entry Format. The optional name@ prefix gives the peer a human-readable name used in logs and Studio; see Named Peers. The optional priority (integer, default 0) prefers higher-valued nodes during elections. The optional https port encrypts peer-to-peer transfers when arcadedb.ssl.enabled=true; see TLS/SSL. Example: frankfurt@db1:{raft:2434,http:2480,https:2490,priority:10},london@db2:{raft:2434,http:2480,https:2490}

(empty)

arcadedb.ha.txSchemaCheck

Replicated transactions state the Raft index their node had applied when they began, so the leader refuses (retryably) a replica transaction prepared before a schema change it already applied. See the Troubleshooting section

true

arcadedb.ha.raftPort

Default Raft gRPC port used when an entry in serverList does not specify one

2434

arcadedb.ha.quorum

Write quorum: majority or all

majority

arcadedb.ha.quorumTimeout

Timeout in ms waiting for the quorum acknowledgment

10000

arcadedb.ha.schemaDelta

Ship schema changes as a delta instead of the whole schema document, once every peer has confirmed it can decode one. See Schema Delta Replication (from v26.10.1)

true

arcadedb.ha.schemaIncrementalApply

Followers apply a DDL entry by creating only the components for the files it added, instead of rebuilding all of them. See Schema Replication

true

arcadedb.ha.readConsistency

Default read consistency for follower reads: eventual, read_your_writes, linearizable

read_your_writes

arcadedb.ha.electionTimeoutMin / arcadedb.ha.electionTimeoutMax

Minimum and maximum Raft election timeout in ms. Increase for WAN clusters

2000 / 5000

arcadedb.ha.serverRole

Node role: any (default) or replica (pinned follower, never elected leader)

any

arcadedb.ha.snapshotThreshold

Number of Raft log entries after which the leader takes a snapshot

100000

arcadedb.ha.bootstrapFromLocalDatabase

When true (default), at first cluster formation peers exchange (fingerprint, lastTxId) per database and the highest-lastTxId peer is elected as the bootstrap source. See Offline Cluster Bootstrap.

true

arcadedb.ha.bootstrapTimeoutMs

Maximum time in ms the bootstrap leader waits for every peer in serverList to report its bootstrap state before proceeding.

120000

arcadedb.ha.groupCommitQueueSize / arcadedb.ha.groupCommitOfferTimeout

Bounded queue size and offer timeout (ms) for Raft group-commit backpressure

10000 / 100

arcadedb.ha.raftPersistStorage

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; ensure raftStorageDirectory lives on durable storage. See Durable Raft Storage

true

arcadedb.ha.raftStorageDirectory

Parent directory for the per-node raft-storage-<nodeName> sub-folder. Empty (default) uses the server root path. With durable storage enabled by default, point this at a persistent volume. See Durable Raft Storage

(empty)

arcadedb.ha.peerUnreachableThreshold

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

arcadedb.ha.peerChannelResetDuration

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

arcadedb.ha.peerChannelResetEscalation

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. false logs only. See Replication-Channel Self-Healing

true

arcadedb.ha.stalledReplicaResyncDurationMs

How long in ms a replica must stay STALLED (its matchIndex not advancing while the leader commits) before the leader forces it to resync. A follower still at matchIndex=-1 counts as STALLED regardless of the numeric lag. 0 disables leader-driven recovery; the condition is still logged

60000

arcadedb.ha.stopServerOnReplicationFailure

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

arcadedb.ha.clusterToken

Shared secret for inter-node authentication. If empty, auto-generated at first startup and persisted under raft-storage/

(empty)

arcadedb.ha.clusterTokenPath

Path to a file containing the cluster token. Read only when arcadedb.ha.clusterToken is not set; the content is trimmed. Keeps the secret off the command line (e.g. a Kubernetes Secret mounted on tmpfs)

null

arcadedb.ha.tls.enabled

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 arcadedb.ssl.enabled, which covers the HTTP side channels. See mTLS for the Raft gRPC transport. Since v26.9.1

false

arcadedb.ha.tls.certChainFile

PEM file with this node’s Raft certificate followed by any intermediate CAs. The certificate needs a SAN matching the address peers dial in arcadedb.ha.serverList, and both the serverAuth and clientAuth extended key usages. Required when arcadedb.ha.tls.enabled is true. Since v26.9.1

(empty)

arcadedb.ha.tls.privateKeyFile

PEM file with the PKCS#8 private key matching certChainFile. Required when arcadedb.ha.tls.enabled is true. A WARNING is logged if the file is readable beyond its owner. Since v26.9.1

(empty)

arcadedb.ha.tls.trustCertCollectionFile

PEM file with the cluster CA certificate(s) signing every node certificate. Required when arcadedb.ha.tls.enabled is true, including with mutualAuth=false. Since v26.9.1

(empty)

arcadedb.ha.tls.mutualAuth

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. false gives server-only TLS, which encrypts the traffic but leaves the Raft port open to any client trusting the cluster CA. See Server-only TLS. Since v26.9.1

true

arcadedb.ha.logVerbose

Verbose HA logging: 0=off, 1=basic, 2=detailed, 3=trace

0

arcadedb.ha.k8s

The server is running inside Kubernetes (enables auto-join)

false

arcadedb.ha.k8sSuffix

DNS suffix used to reach the other servers in Kubernetes, e.g. .arcadedb.default.svc.cluster.local

(empty)