Clustering
XERJ ships an embedded Raft implementation — no etcd, no ZooKeeper, no Consul. One binary is still one binary; cluster mode is a config switch, not a separate process. Where the ring stands today, plainly: leader election, heartbeats, and the authenticated inter-node transport are real. Index metadata (create, delete, mapping, and settings) and the primary shard plan for that index are proposed through the Raft log and applied on every member. A document written with PUT, POST /{index}/_doc, PUT /{index}/_create, POST /_bulk, POST /{index}/_bulk, or POST /{index}/_update/{id} is stored on the member that owns its shard, whichever node accepted the call. POST /{index}/_update_by_query runs on every primary of that index and each primary copies the documents it updated. POST /{index}/_delete_by_query runs on every primary and each primary copies the deletes. POST /v1/indices/{name}/docs, DELETE /v1/indices/{name}/docs/{id}, and POST /v1/indices/{name}/docs/_bulk run on the primary for that id and copy the document or the delete. GET and DELETE /{index}/_doc/{id} follow the same owner. GET or POST /{index}/_search fans out to every owner and merges hits. index.number_of_replicas set at create places that many copies. The write is acknowledged when a quorum of those copies, counting the primary, has confirmed; the default is 0, which keeps a single copy. A restarted member, and a member that starts with an empty log or behind a compacted prefix, installs committed metadata from the on-disk Raft log or from InstallSnapshot. A replica store that is missing the primary's live documents then copies them and rejoins the in-sync set (public tracking: xerj-org/xerj#1170).
What cluster mode does not give you yet
index.number_of_replicas on PUT /{index} places that many copies of each primary, capped at one fewer than the member count. The default is 0. A later PUT /{index}/_settings stores a new number and does not move copies. There is no wait_for_active_shards. A write is acknowledged when a quorum of the copies has confirmed: copies are the primary plus the replicas placed at create, and a quorum is copies / 2 + 1. The primary already counts, so with two replicas one confirmation is enough and the caller still succeeds. The replica that did not confirm is removed from the in-sync set by a Raft entry, and later reads do not use it until it has copied the primary's live documents and that enlarged set has committed. If fewer replicas confirm than that quorum, the response is 503 replica_not_acknowledged: the primary has the write, and a retry of the index copies it to the replicas still in sync. number_of_replicas 0 or 1 does not loosen this: there is no replica to wait for, or that single replica must confirm.POST /v1/indices/{name}/docs, DELETE /v1/indices/{name}/docs/{id}, and POST /v1/indices/{name}/docs/_bulk run on the shard primary and copy to the in-sync replicas. The response stays the native envelope. Logs, OTLP, syslog, turbo, and pipeline ingest still write on the node that received them. POST /{index}/_delete_by_query and POST /{index}/_update_by_query run on every primary. Each primary deletes or updates only the documents in its own shard and copies that change to the in-sync replicas. max_docs counts across those primaries, in node-id order. A primary that does not answer refuses the request before any document is written. A copy that misses quorum is 503 replica_not_acknowledged: the primary has the delete or the update. ?pipeline= on an update-by-query runs on the primary. Pipelines are not replicated, so a pipeline that is not on that primary is 400 and writes nothing.match_all, is run on each owner and the hits are merged by the per-shard score (not a global IDF), then from/size is applied. Aggregations, suggest, collapse, rescore, profile, search_after, and scroll are refused with 400 when the index has more than one owner — the response is not a partial merge. /_search with no index, and a comma-separated or wildcard index list, still search only the node that received them. The per-shard window is capped at 10,000 hits.{data_dir}/_raft and applies the snapshot plus the log suffix before it serves, so the shard plan and the in-sync set come back. After 64 applied metadata entries the prefix is snapshotted and a member whose log does not overlap receives InstallSnapshot. The snapshot is the metadata commands, not the documents. A placed replica that is missing those documents copies them from the primary and is published back into the in-sync set; a write still needs the primary, and there is no promotion. A metadata history bigger than 8 MiB stays in the log instead of being snapshotted. Changing membership does not move copies.With number_of_replicas at 0, a document has one home and losing that member loses it. With a replica placed at create, a GET or a search of that shard falls back to an in-sync copy when the primary does not answer. A copy that missed an acknowledged write is not in that set until it has copied the primary's live documents. A write still needs the primary: there is no promotion. Index metadata is on every member that applied the log. A restart replays the prefix that member had already stored. A new metadata change still needs a leader who can commit.
What you get
- Leader election — exactly one leader per term, randomized 150–300 ms election timeouts, automatic failover. Measured on a 3-node loopback ring (tick 50 ms): a new leader within ~250 ms of killing the leader, then election-stable for minutes. Leader death used to cause perpetual election churn — fixed by #1168.
- Authenticated transport — every inter-node connection is HMAC-SHA256 over a per-connection challenge using the cluster secret. There is no unauthenticated mode; the node refuses to start without a secret.
- Dead-peer isolation — a hung or dead peer costs one
WARNon the transition and a retry ladder backing off 100 ms → 6.4 s; it never delays messages to live peers. - Index metadata replication —
PUT /{index},DELETE /{index},PUT /{index}/_mapping, andPUT /{index}/_settingsare proposed on the member that received them. A follower forwards to the leader. The HTTP call succeeds only after the entry commits on a majority and this node has applied it. No elected leader, or a commit that does not land in time, is503and the change is not applied on the caller. With[cluster] enabled = falsethese calls stay local, as before. - Primary shard plan — the create entry names one primary per member (member ids sorted, shard
ion theith) and, whenindex.number_of_replicasis set, the next members in that ring as replicas.Engine::route_writereturns the primary, or nothing when this node is the primary or the index has no plan. Document id is the routing key (jump consistent hash). - Document routing —
PUT/POST /{index}/_doc,PUT /{index}/_create/{id},POST /_bulk,POST /{index}/_bulk,POST /{index}/_update/{id},POST /{index}/_update_by_query,POST /{index}/_delete_by_query,GETandDELETE /{index}/_doc/{id},POST /v1/indices/{name}/docs,DELETE /v1/indices/{name}/docs/{id}, andPOST /v1/indices/{name}/docs/_bulkrun on the primary. A bulk body is split by primary; the receiver does not keep another member's actions. An update-by-query or a delete-by-query is sent to every primary, and each primary changes only the documents in its shard. The primary copies an index, a delete (one document or a delete-by-query), or the document an update or update-by-query stored to each in-sync replica and answers when a quorum of the copies has confirmed. A search of one concrete index fans out to every primary and merges hits, and reads an in-sync replica of a primary that does not answer. The copy is a hidden.xerj_replica_{index}_{shard}index on the replica node, not a second document in the user index, so a live primary search does not count it twice. Replica_seq_noand_versionare that node's own. The owner is reached on the cluster port (authenticated, one request and one response per connection), not by writing the document into the Raft log. - On-disk Raft log — cluster mode opens
{data_dir}/_raft(raft.log,snapshot.metaonce the prefix has been compacted, plus the commit index, term, and vote). The directory name is not a valid index name, so deleting an index cannot delete the log. On startup the node replays the snapshot and then the committed suffix into the shard plan and the in-sync set before it serves. Indices and documents already in that data directory stay; the plan is what tells a later write where they live. A member whose log does not overlap the leader's copies the log, or installs the snapshot when the leader has compacted the prefix, and applies it.
Bootstrapping a 3-node cluster
Start three nodes, each with its own data dir, and point them at each other. On first startup they hold a Raft election and one becomes leader:
# /etc/xerj/node-a.toml (run on 10.0.0.11) [server] data_dir = "/var/lib/xerj" bind_address = "10.0.0.11" [tls] # required off loopback (or terminate TLS in front enabled = true # and set server.allow_insecure_network_bind = true) cert_path = "/etc/xerj/certs/node-a.crt" key_path = "/etc/xerj/certs/node-a.key" allow_insecure_grpc_h2c = true # gRPC is cleartext h2c; required off loopback with TLS on [cluster] enabled = true port = 9300 # intra-cluster Raft + search port auth_secret = "<the same secret, 16+ chars, on every node>" # or XERJ_CLUSTER_AUTH_SECRET peers = [ "10.0.0.11:9300=10.0.0.11:9300", # this node "10.0.0.12:9300=10.0.0.12:9300", "10.0.0.13:9300=10.0.0.13:9300", ] tick_ms = 50
# /etc/xerj/node-b.toml (run on 10.0.0.12) [server] data_dir = "/var/lib/xerj" bind_address = "10.0.0.12" [tls] # required off loopback (or terminate TLS in front enabled = true # and set server.allow_insecure_network_bind = true) cert_path = "/etc/xerj/certs/node-b.crt" key_path = "/etc/xerj/certs/node-b.key" allow_insecure_grpc_h2c = true # gRPC is cleartext h2c; required off loopback with TLS on [cluster] enabled = true port = 9300 # intra-cluster Raft + search port auth_secret = "<the same secret, 16+ chars, on every node>" # or XERJ_CLUSTER_AUTH_SECRET peers = [ "10.0.0.11:9300=10.0.0.11:9300", "10.0.0.12:9300=10.0.0.12:9300", # this node "10.0.0.13:9300=10.0.0.13:9300", ] tick_ms = 50
Each peers entry is "<host>:<port>=<host>:<port>": node ids are socket addresses, and the node refuses to start on an entry whose label differs from its address (#1171 — a friendly label made that node's vote replies undeliverable). The current node is the entry whose address matches its own bind_address and cluster.port. Three more startup checks apply to this example and each refuses to boot when unmet: cluster.auth_secret (or XERJ_CLUSTER_AUTH_SECRET) must be set; a non-loopback bind_address needs [tls] or server.allow_insecure_network_bind = true; and with TLS on off loopback, the cleartext gRPC listener needs tls.allow_insecure_grpc_h2c = true. Start all three boxes:
$ xerj --config /etc/xerj/node-a.toml # on 10.0.0.11 $ xerj --config /etc/xerj/node-b.toml # on 10.0.0.12 $ xerj --config /etc/xerj/node-c.toml # on 10.0.0.13
Within a second the ring elects a leader. The log is still the place to see who won. GET /_cluster/health reports the configured membership (this node's address plus distinct peers), not a liveness probe and not a count of nodes holding your documents:
$ journalctl -u xerj -f | grep xerj_cluster INFO xerj_cluster::transport: TCP transport listening (authenticated) node=10.0.0.11:9300 INFO xerj_cluster::raft: Starting election node=10.0.0.12:9300 term=1 INFO xerj_cluster::raft: Became leader node=10.0.0.12:9300 term=1
$ curl -sH "Authorization: ApiKey $XERJ_API_KEY" \
http://10.0.0.11:9200/_cluster/health | jq '.status, .number_of_nodes'
"green"
3
What replicates, and what stays on one node
PUT /orders on any member — leader or follower — creates orders on every member, including its mapping, the settings and aliases in that request, and a primary on each member. GET /orders and GET /orders/_mapping on another member succeed after the call returns. DELETE /orders removes it on every member. A settings or mapping change is one log entry per concrete index. A follower that cannot reach a leader returns 503; it does not create the index locally and report success.
PUT /orders/_doc/1, POST /orders/_update/1, and each action in POST /orders/_bulk on any member stores the document on the primary for that id and on enough in-sync replicas to make a quorum of the copies. POST /orders/_update_by_query does the same for every document it changes. POST /orders/_delete_by_query deletes on every primary and copies each delete to the in-sync replicas. POST /v1/indices/orders/docs, DELETE /v1/indices/orders/docs/1, and POST /v1/indices/orders/docs/_bulk do the same for one document: the primary stores it, or deletes it, and copies that to the in-sync replicas. The caller still receives the native envelope. An action whose primary is another member is not written into the accepting node's index. With number_of_replicas 1 that is the one replica. With 2, one of the two is enough; the other is left out of the in-sync set until it copies the primary's live documents and the enlarged set commits, and a later read does not use it before that. GET /orders/_doc/1 from any other member returns it, from the primary while that member answers and from an in-sync replica when it does not. POST /orders/_search from any member returns documents stored on the primaries, using an in-sync replica only for a primary that does not answer. An auto-created index (a document write that names an index which does not exist yet) proposes the create with an empty schema and number_of_replicas 0, then routes. With no elected leader that write is 503 and the index is not created. [cluster] enabled = false does not place replicas: the setting is stored, and the document stays on the node that received it.
Several things around that path stay local, on purpose for this step:
- Native logs, OTLP, syslog, turbo, and pipeline ingest still write on the node that received them.
POST /v1/indices/{name}/docs,DELETE /v1/indices/{name}/docs/{id}, andPOST /v1/indices/{name}/docs/_bulkrun on the primary, and the primary copies the document or the delete._delete_by_queryand_update_by_queryrun on every primary. For a copy of the paths that still stay on the receiving node, point the WAL tap at another XERJ or any ES-compatible target. - Index templates are not replicated. The accepting node applies its templates once and the effective schema is what the log carries.
- ILM/ISM attach from
index.lifecycle.nameruns on the accepting node only. - Console system indices created at boot, before the ring is attached, have no shard plan and stay local.
Failure modes
When not to cluster
When you need a replica to accept writes after the primary is gone, or a search that aggregates across shards. Cluster mode keeps index metadata in step, places each document on one primary, acknowledges a document write (PUT/POST _doc, _bulk, _update, _update_by_query, _delete_by_query, or native /v1 document index, delete, and _bulk) once a quorum of its copies has it, copies the primary's live documents onto a replica that joined late or missed a write, and reloads metadata when a member restarts. It does not promote a replica, and adding or removing a member does not move copies. One node avoids the forwarding hop and is trivial to back up. The default config ships with [cluster] enabled = false.
Source · engine/crates/xerj-cluster/src/raft.rs · engine/crates/xerj-cluster/src/transport.rs · engine/crates/xerj-cluster/src/runner.rs