Stateful clusters
SoliDB, SoliES and SoliKV. Pinned members, one machine each, and a seed that starts alone.
one datastore plan prints the steps and one datastore
apply carries out the ones belonging to the machine it runs on. A
three-member SoliDB cluster was brought up from nothing on one workstation
with it: the seed reporting one, then two, then three healthy nodes, each
confirmed before the next started, with the keyfile arriving on tmpfs at
mode 0400 and the memory ceiling reaching every cgroup.
SoliKV shards now too. Slot ownership propagates, and the plan assigns an even split as its last step — verified against two real nodes: a half each, both agreeing on the split, and a redis-cluster client writing eight keys through either node and reading them all back through the other, with the data genuinely split four and four.
SoliES clusters now too, through the same two commands. Its log replication was always there — this page claimed otherwise and was wrong; see the correction below. What was missing was the configuration surface to reach it, and a start shape for a service that votes.
Nothing on this page is refused any more. The two refusals that remain are about the shape of a request — fewer machines than members, and no keyfile — not about a service being unable to do what it claims.
apply is per-machine, and deliberately so for a database. A
SoliDB member is pinned: it owns a local data directory,
and a scheduler that moved it would move a name and leave the bytes. So
each machine carries out its own steps and the command for the others is
printed rather than guessed at.
The scheduler places mobile workloads
across the cluster; it is not the right tool for this one.
A database cluster is not an app with three replicas
Planning it that way gives three databases that each think they are the cluster.
- It is pinned. Each member owns a local data directory.
onestarts, stops and restarts instances; it never moves one, because moving it would move a name and leave the data.one node drainmust refuse rather than relocate. - Its availability comes from itself. SoliDB replicates between its own nodes. The cluster manager keeps the right instances running on the right machines with the right peer list; it fails nothing over.
- It cannot start unauthenticated. See Security.
Three services, three different constraints
| Service | Agreement | An even member count | Clusterable today |
|---|---|---|---|
| SoliDB | replication, no vote | wasteful | yes |
| SoliES | its own Raft — votes | two members survive nothing | yes — every member started with the whole membership |
| SoliKV | gossip, authenticated | — | yes — slots assigned explicitly |
The voting distinction is not pedantry. Two SoliDB nodes are two copies and cost more than they are worth; two SoliES nodes have the availability of one, because a majority of two is two and losing either stops the cluster. Saying the same thing about both would make the stronger warning false, and a false warning is one people learn to ignore.
SoliKV gossip, fixed. Starting with
--cluster-enabled used to build a GossipState,
call cluster.enable(), log “Cluster mode
enabled” and never start the server — three nodes were
three independent nodes, all reporting healthy. Four separate defects sat
behind that: the server had no caller, it bound 127.0.0.1
unconditionally, it had no line framing (a message split across two TCP
segments was dropped silently), and its access control was the peer's IP
against a known-node list that a new peer could never be on.
All four are fixed, and the security notes landed with the server rather than after it. Verified against real processes: two nodes discover each other and stay alive, and a third with a different secret is refused with “frame signature does not verify” and never enters the membership.
| SEC-016 | Status |
|---|---|
| HMAC-SHA-256 on every frame | done |
| Verify on receive, drop what fails | done |
| Reject a frame whose node id does not match the connection | done |
| Authenticate before accepting bus traffic | done — per frame, which beats a per-connection handshake: a handshake authenticates once and trusts every later byte |
| TLS on the bus and replica links | not done |
The HMAC authenticates and detects tampering; it does not encrypt. Anyone on the path reads the topology, the node ids and the addresses. On a provider with no private network — OVH VPS has none — that is the cluster map visible to the path. Not a way in, and not nothing.
SoliKV slot ownership, fixed. Four faults sat behind it,
each enough alone. The constructor claimed all 16384 slots — right for
one node, wrong the moment there are several. No gossip message carried a
slot range. Nothing ever built an UPDATE, so the frame
was decodable and never sent. And CLUSTER NODES printed a
literal - for every peer, so ownership could be known and stay
invisible — which matters beyond cosmetics, because that line is what a
Redis-cluster client reads to build its slot map.
A node entering cluster mode releases its blanket claim, so a cluster with nothing assigned serves nothing. That is Redis's behaviour and the only honest one: answering for an unassigned slot means answering from a node that may not hold the data.
And a lie to the client is gone. An unassigned slot used to answer
MOVED <slot> 127.0.0.1:7000 — a port nothing in the
cluster is required to be on. A client follows a MOVED, so it connected
there and reported an error against an address the operator never
configured. It answers CLUSTERDOWN now, with a different
sentence for “owned but unlocatable” than for “nobody
claims it”.
SoliES replicates its log. This page said it did not, and
that was wrong. The claim — that AppendEntries carried an
always-empty entries list and that try_propose had no caller
— came from a doc comment at the top of messages.rs that
still described an earlier step. The code had moved on:
try_propose is called from the node loop, and
build_append_for slices real entries out of the log.
Measured against two real broker processes, because reading more code was not going to settle a claim that came from reading code: the follower refuses a write and names the leader, twenty-one records written through the leader arrive on the follower in the same order, and with the follower killed the leader stops committing — a majority of two cannot be one. That last check is the one that separates consensus from two logs that happen to agree.
What genuinely did not exist was any way to tell a broker it was a
cluster member. Three config fields for the node id, bind address and
peers were declared and never read; resolve_raft handed back an
empty address map; and connect_raft_transports had no caller.
Its port scheme was raft_bind + partition_index, which works
for exactly one topic and silently collides for the second — and a
port per Raft group cannot be configured in advance, because topics are
created at runtime. So the groups are multiplexed now: one listener, one
connection per peer, and a group name in every frame.
es-broker --data-dir /var/lib/solies \
--raft-node-id 1 --raft-bind 10.0.0.1:9300 \
--raft-peer 2=10.0.0.2:9300 --raft-peer 3=10.0.0.3:9300 \
--raft-shared-secret "$SECRET"
The membership is this node plus exactly the peers that have an address. There is deliberately no way to name a member without giving its address: a member that counts toward a quorum but cannot be reached is a vote the node will never collect, and the cluster would stall with every process reporting healthy.
A voting service gets its own start shape. Every other plan here starts a seed alone and points each later member at what is already running. That is right for a service which replicates without voting, and backwards for Raft: a node started alone is a cluster of one that elects itself, and a peer named in a later process does not join it. So the plan starts all members with the whole membership, and confirms each of them only after the last one is up — a quorum cannot form before then, so confirming between starts would wait for something that cannot happen.
start es-1 on rk-01 as raft id 1, with all 3 members
start es-2 on rk-02 as raft id 2, with all 3 members
start es-3 on rk-03 as raft id 3, with all 3 members
confirm es-1 is replicating, not merely running
confirm es-2 is replicating, not merely running
confirm es-3 is replicating, not merely running
publish es-prod at 10.0.0.1:9200
The raft_id is written in the spec, not derived from a member's
position in the file. A Raft node id is durable identity: taken from the
ordering, sorting the members alphabetically would renumber a live cluster,
and two nodes swapping identities is the worst thing that can happen to a
replicated log. The planner refuses a missing id, a duplicate one, id 0
— the “no leader known” sentinel — and two members
sharing a consensus address.
Verified against three real brokers started from the plan's own declared command lines: a record written through the leader arrives on a follower, killing one member of three does not stop writes, and killing two does.
confirm now confirms, for SoliES. It could not
before, because nothing outside a broker could tell a replicating member
from one that merely runs — /healthz answers a static
“healthy”, /readyz a static “ready”, and
/metrics says nothing about consensus. So SoliES grew a
GET /raft, and two things about it were wrong until three
brokers were run rather than the code read again:
-
Every follower reported
replicating: false. A follower keeps nomatch_index— that is leader state — so demanding a quorum from every role called a healthy cluster broken on two nodes out of three. A leader is replicating when a majority of live members have caught up; a follower, when it knows who leads, because that is exactly what it can attest to. -
A leader that had lost two of three members still reported a
quorum of three.
match_indexrecords what a peer once acknowledged and goes on saying so after the peer dies. A quorum is a statement about liveness, not history.
When the spec names no admin token the step is reported as not
performed, with the curl that performs it. Reporting
“confirmed” for a check that was skipped is the one outcome worse
than not checking. And the token is per member, because SoliES keeps its key
store in each broker's own data directory and generates key secrets
server-side — one token cannot be valid on three brokers, which was
found by watching one confirm the local member and collect two 401s.
A SoliES topic is local to the broker that created it. Until the same topic exists on every member, that topic's partitions cannot reach a majority. It fails closed — the write is refused and says why — so the plan says it as a note rather than refusing the cluster. An operator should be told, not left to find out from a timeout.
Two refusals
Both are refusals rather than warnings, for the same reason: the degraded state they describe is indistinguishable from a healthy one until the moment it matters.
- Fewer distinct machines than members. Three replicas on two machines is not “three replicas, slightly less safe” — losing one machine loses two of them. A cluster reporting three copies across two failure domains is worse than one that admits it has two.
- No keyfile. A plan without one would start processes that come up healthy and never form a cluster.
The plan
keyfile generate for soli-db-prod (local, never in Raft)
keyfile deliver to rk-01 as a systemd credential
data rk-01:/var/lib/solidb/db-1 (pinned, never relocated)
keyfile deliver to rk-02 as a systemd credential
data rk-02:/var/lib/solidb/db-2 (pinned, never relocated)
keyfile deliver to rk-03 as a systemd credential
data rk-03:/var/lib/solidb/db-3 (pinned, never relocated)
start db-1 on rk-01 — seed, alone
confirm db-1 is replicating, not merely running
start db-2 on rk-02 -> 1 peer(s)
confirm db-2 is replicating, not merely running
start db-3 on rk-03 -> 2 peer(s)
confirm db-3 is replicating, not merely running
publish soli-db-prod at rk-01:7000
Why the seed starts alone
Starting every member at once with a full peer list lets each one decide it is the founder before it reaches the others: N single-node clusters, every one of them reporting healthy, and no error anywhere. Split brain at birth is the hardest kind to notice, because there was never a moment when it was right.
Why each member joins against only what is already running
A peer list naming a node that has not started sends the joiner to sync from nothing, and the failure reads as a network problem rather than an ordering one.
Advice, not refusals
An even member count and a single node are both real choices with real costs, and the operator may have a reason. Refusing would be the planner deciding something that is not its decision — so it says so and continues:
- “2 members is an even count — it costs a cluster and survives no more failures than 1 would.”
- “one member: this is a database with backups, not a cluster. That is a reasonable choice, but nothing here is redundant.”
Sizing
A SoliDB instance holds roughly 750 MB of RocksDB block cache at rest — measured, not estimated. That is the largest single consumer on a node and the figure that decides whether per-tenant managed databases are viable: acceptable as a shared instance, and expensive at one per customer until the cache is tuned.