Pre-1.0 hoardDB is pre-1.0. Expect breaking changes.
Replication
This page has not been rewritten for ring mode. It describes the consistent hash
ring, a write-ahead log, a replication factor per bucket and a write concern. Ring
mode (hoarddb -ring, with HOARDDB_CLUSTER_ENABLED for a cluster) does not use
any of it, and in ring mode replication, seeding a node that joins and databases
with more than one copy are not usable end to end yet. What does work in ring
mode is join on a cluster that holds no data:
Configuration has the steps and
Limitations has what is missing.
hoardDB replicates data between nodes over TCP+TLS. This document is the design record: the model, the wire path, what is guaranteed, and what is not implemented yet.
Status of the replication this page describes: working and tested for the model below — three nodes at RF=3 and
five nodes at RF=3 (exactly three of the five hold each bucket), data verified
present in each node’s own storage, write concern enforced, and a restarted
replica repaired from the owner’s log. See server/cluster_replication_test.go.
What was here before
The previous implementation reported success without replicating anything.
replicateEntryAsync dialled a peer, logged that it was sending append
entries, and then set peer.lastAckSeq = entry.Seq on its own and signalled a
local acknowledgement. WaitForAcks returned len(peerIDs) after a single
local signal, so even w=all was satisfied by the leader alone. Nothing called
it, either: no handler went through the write path, and storage.OpenWAL was
never called, so the WAL was never open.
Two consequences worth remembering:
- A durability guarantee that is not backed by a network round trip is worse than no guarantee, because a client will trust it.
Opcode.IsMutating()answers “is this unsafe to process from 0-RTT?”, not “does this change stored state?”. It returns false forOpPutandOpBatchPut, which were idempotent overwrites when it was written (an insert now refuses an_id_the bucket holds). Routing by that predicate silently exempts every data write from replication. Route by!IsReadOnly()instead.
Model
Per-bucket owner with ring-placed replicas.
- A bucket’s owner is chosen by hashing the bucket’s database and name
together onto the ring (
ring.Ring.Owner), soapp/usersandanalytics/usersare placed independently, like any two buckets. The owner is the serialisation point for writes to that bucket, and the node that names the replication sequence. - The replica set is the owner plus the next
replication_factor - 1distinct nodes clockwise (ring.Ring.Replicas), wherereplication_factoris the bucket’s own: the factor it declared when it was created, else the node default (HOARDDB_REPLICATION_FACTOR). With RF=3 and three members, every node holds every bucket; a bucket that declared 1 on the same cluster is held by its owner alone. See Replication Factor for the create form.show bucketsanddescribe bucketprint each bucket’s factor and whether it was declared or inherited, on a node that holds the bucket. - Reads are served by any node in the replica set that holds the bucket. A node that is not in the set, or is in the set the ring would compute for a bucket it has never held, asks the owner.
Membership changes at runtime, with no restart. Start the new node configured
as a cluster member (HOARDDB_SEED_NODES, plus HOARDDB_SEED_FINGERPRINTS: see
Seed pinning), then run
join <address> in a CLI session on any existing member. That member proposes
the change in three rounds: a majority of the current members promises to
consider it, then a majority accepts it (an acceptance is recorded and acts on
nothing), and only then does every member adopt the new membership epoch. When
the command returns, every member that answered is on the new epoch; a member
that did not answer learns it from the first node it exchanges with. At most one
member set is ever adopted at an epoch, whatever fails or runs at the same time.
remove-node <node-id> removes a member the same way.
What the command says, and what to do:
- Success. The change is made. If the change was already made, by an earlier run of the same command that stopped, it says so and changes nothing.
- “membership change not made: … nothing was offered and nothing changed”.
Too few members answered, or another change from this member is running. Run
nodesto see which members are down, then run the command again. - "… was offered to the members …, but this command stopped before a
majority confirmed it". The change may still complete: a member that
accepted it finishes it, usually within a minute, and within about five
minutes while members are unreachable. Run
nodesto see whether it did before you run the command again. - “another membership change (…) was in flight and is now committed …;
your change (…) was not made”. A change another member started and did not
finish was finished first. Run
nodes, and run your command again if it is still wanted. - “the change (…) is committed …, but this node could not adopt it”. The change is made; the member you ran it on is behind and catches up by gossip once the cause it names is fixed. The command exits with an error because that member needs attention.
Every member must run the same release before you add or remove a node: a
member on an older release refuses the new rounds, and the members log
member <node> runs a build without this release's membership protocol; upgrade every member before adding or removing nodes. Reads, writes and replication are
not affected by mixed releases. Do not move a member back to an older release
while a change is running: the older release forgets what the member accepted.
hoarddb_membership_changes_total{outcome} on /metrics counts the changes each
member proposed, by outcome. Each node derives the
ring from the epoch’s member list. Because
placement must agree across nodes, the member sets are compared on every gossip
exchange, and a disagreement makes the node refuse mutations rather than
scatter writes across replica sets its peers disagree with.
While a join or remove-node is being learned by every node — normally under
a second — a write can be refused with write_outcome: not_applied and a
message saying the membership is changing. Retry it. Nodes on different membership
epochs do not replicate to each other or hand each other a bucket’s copy until
the one that is behind has caught up. A write whose bucket moves to another
owner while it is in flight is answered with an error saying so, and
write_outcome: unknown: it is stored
on the node that took it and is not replicated from there, so read it back and
retry it if it is missing. A node that has been removed refuses writes and says
which member to connect to: at once when it is reachable as the removal
commits, and after a restart too. Every write verb is refused, including
claim, ack, release, extend, requeue and discard, with
write_outcome: not_applied and nothing changed. It keeps the copies it
holds, and the members
can still copy them from it, until you empty its data directory. While it runs
it asks each bucket’s new owner to take the bucket from it, including a bucket
whose only copy it holds, and logs every bucket this removed node holds is served by its members once they all have: empty its data directory only after
that line. It asks about one bucket and one member at a time, so on a node with
many buckets the line can take a long time to appear. A bucket that was dropped
after the removal is not handed back. A bucket whose name was created again
before the hand-off keeps the new bucket: the removed node warns, naming the
bucket, that its own copy holds writes the cluster does not have, and it does
not print the line above. With auto_rebalance off it moves nothing and logs
how many buckets it holds; run migrate <database> <bucket> on each bucket’s
owner. A removed node
that cannot reach any member does not know it was removed, and the writes it
accepts until it reconnects are not kept, so do not point clients at a removed
node. To add a removed node back, stop it and empty its data directory first:
join refuses a node that still holds the record of its removal, and says so.
join also refuses a node that does not answer: start it first.
A node started to join serves nothing until it has been added. Until join
runs on a member, it answers every request that names a database or bucket with
“not added yet”, naming the join to run, and status on it shows
Membership: NOT ADDED YET - run 'join <address>' on a member. The members
carry on as before while it waits.
Schema changes pause while a node joins
A node that joins owns some buckets before it holds them. Until every member
has handed it the buckets it now holds, a schema change could create an empty
bucket over the real one, so while a join settles every schema change on the
cluster is refused with “unavailable, retry”: create database, drop database, create bucket, drop bucket, alter bucket, restore, and an
ingest that has to create its bucket. Nothing is created, dropped or restored.
Reads and writes to buckets that exist carry on, and a restore --dry-run is
answered, saying that a real restore would be refused now. Data writes carry on
as they always did: pop, and the lease verbs claim, ack, release and
extend and the dead writes requeue and discard, are not schema changes and
are not paused. The pause lifts on
its own when the join settles, normally seconds after the copying finishes.
The refusal says who is joining, how many of the buckets the change moved have been copied, and what the join waits on:
Schema changes are paused while n4 joins the cluster: at least 412 buckets of this membership change have been copied to their new places. ...
Waiting on: n3, unreachable for 5m12s. n1 and n2 cannot finish handing off until n3 answers.
Ways out: start n3 again; or undo the join with 'remove-node n4' run on another member; or if n3 is not coming back, 'remove-node n3' - buckets whose only copy is on n3 then become unreachable, and a bucket of the same name created afterwards is a new, empty bucket.
A member that is down before the join has settled holds the pause: every
bucket whose new set includes it waits for its answer. The ways out, in order:
wait; start the down member again; undo the join with remove-node of the new
node, run on another member; or, if the down member is not coming back, remove
it with remove-node, at the cost the refusal states. After a remove-node the
pause lifts once the remaining members have handed back what the join moved.
With auto_rebalance off nothing is copied for you: the refusal and nodes
list the migrate <database> <bucket> commands to run, each on the node that
lacks the copy or holds one that does not follow the new owner. Run each one on
the node it names, for example on n3:
[h]oardDB> migrate app Orders
That list can grow once. Once the new owner has its copy, a member that took an
older copy of the bucket from the previous owner’s log finds its copy does not
follow the new owner, and is named too, with a migrate of its own. Run nodes
again after each round and run every command it lists, the new ones as well.
The pause lifts when nodes lists no migrate command.
nodes prints the same below its table: Schema changes: allowed (every member settled at epoch 7), or Schema changes: PAUSED and the refusal’s paragraph.
Nodes cannot be rolled back to an earlier build once they have written to their log on this one. An earlier build reads the newer log entries as a damaged tail and discards them, so after a crash the copies of a bucket can silently disagree about writes that were acknowledged. Back up every node’s data directory before you upgrade (stop the node, copy the whole directory; see Backup and Restore), and go back by restoring that copy, never by starting the old build on the new data. The first time a node writes the new format it logs one INFO line saying so. A cluster is upgraded with every node stopped. Before the upgrade, stop writes and wait one minute; an earlier build has no gauge for this.
A node’s log is also written in segment format 2 from its first start on this
build, and an earlier build does not discard it: it refuses to start, with an
error that ends parsing sealed file header: flags must be 0x00, and changes
nothing. Start the newer build, or restore the copy taken before the upgrade.
Changing storage.wal_preallocate is a rolling restart of the same build:
change it on one node and restart it, wait until
hoarddb_replication_peers_not_in_sync is 0 and status shows the expected
WAL row, then do the next.
On its first start this build moves the node’s replication positions from
replication_state.bson in the data directory into the node’s store and deletes
the file. If that file cannot be read, the node does not start: without the
positions in it, entries that peers re-send may be applied twice. The error names
the file. To start without them, move it aside (mv replication_state.bson replication_state.bson.unreadable) and start the node again. On a node whose
store is encrypted, delete the moved file once you no longer need it: it holds
peer ids and bucket names in plaintext.
An earlier build started on such a data directory does not refuse to start and does not warn. It cannot see the replication positions this build keeps in the node’s store, and the state file it reads is gone, so it applies every entry its peers re-send a second time: FIFO and LIFO buckets silently gain duplicate elements. To go back, restore the data directory from a backup taken before the upgrade, or remove the node from the cluster and join it again with an empty data directory.
When a bucket’s owner changes, the new owner takes over before it serves. A
join or a remove-node can move a bucket to another node. The node that owned
it stops accepting writes to it and finishes sending what it had; the new owner
waits until it holds all of it, and only then accepts writes. Every other copy is
brought to the new owner’s, or copied again from it. While that happens a write
to that bucket is refused with write_outcome: not_applied. Retry it. When the
new owner already holds a copy this takes well under a second; when it does not —
every bucket that moves to a node that has just joined — it takes one copy of the
bucket, and a join that moves many buckets copies them one after another. Other
buckets keep accepting writes; while a node’s copy of one of them is being
settled, a write to it can report a copies_confirmed below the replication
factor. A node that leaves a bucket’s replica set keeps its copy until every
member of the set holds everything it holds; until then show buckets on that
node prints leaving beside the bucket.
A write is kept as long as one node that holds it is still in the cluster. A
write’s response says how many copies hold it (copies_confirmed). If the node
that owned a bucket is removed while it cannot be reached, the writes that only it
held are lost: a write acknowledged at one, and any write whose response said
copies_confirmed: 1. Remove a node while it is running whenever you can — it
then hands over everything it holds. A node that was removed while it could not
reach the cluster does not know it was removed. It keeps accepting writes,
answers them copies_confirmed: 1, and those writes are discarded when it
reconnects.
If the old owner is gone, the new owner asks every other node before it
serves. It takes the most complete copy any of them holds. If one of them cannot
be reached, writes to the buckets that were moving are refused until it returns or
you remove it; the error names it. This happens only when the old owner and
another node are both unreachable; one node down is never enough. About one bucket
in n on an n-node cluster is affected (the ones the lost node owned), and reads
are still answered from their copies. remove-node on the unreachable node ends
the wait, needs a majority of the current members to answer, and loses the writes
that only that node held. With three nodes and two of them lost there is no way
out but to bring one back.
With five nodes at a replication factor of three, two lost, the way back is to
remove each lost node. Run remove-node <node-id> for the first lost node on a
surviving member, then for the second. Once both are removed, the three
survivors take over the buckets and hold a full copy of every bucket again, and
majority writes and reads are served from them. A write acknowledged by a copy
on each of the two lost nodes is lost with them; a write at all is on a
survivor. The rebalance is complete when nodes lists three members and each
survivor holds every document of the bucket; the node log and metrics show it
(nodes does not show copies).
A node’s ring identity is the address it is listed under; HOARDDB_NODE_ID is
only needed when you want an identity that is not the address (written as
id@host:port). Node identity is validated at startup: a node whose identity
is not on the ring is a configuration error and startup fails with the member
list rather than running in a state where it would never take ownership of
anything. A node takes the entry listed at its own address; it takes an entry by
node id only when it listens on a wildcard such as 0.0.0.0 and sets
HOARDDB_NODE_ID (the host name default never counts). Give every node the
same HOARDDB_SEED_NODES: members compare where each node id lives, and a node
whose list places an id at another address than a member’s is never confirmed,
so it serves no database, bucket or user change, and its status names the id
and both addresses.
Raising a bucket’s factor
alter bucket Sessions { replication_factor: 5 } raises the number of copies a
bucket keeps (HQL: alter bucket has the answers and
refusals). The command is answered when the bucket’s owner has recorded the new
factor, not when the new copies exist. Each member the new factor places copies
the bucket from the owner by itself; you do not run anything on it. A member
copies one bucket at a time per pull, and the copy is one bucket’s data, once.
Until a copy lands, the member is not sent the bucket’s writes and the bucket
carries on at the copies that hold it. A write’s copies_confirmed counts the
copies that hold it, so for a bucket raised from 1 to 3:
| Moment | one | majority | all | copies_confirmed / replication_factor |
|---|---|---|---|---|
| both copies pending | 1 | 1 | 1 | 1 / 3 |
| one copy landed | 1 | 2 | 2 | 2 / 3 |
| both landed | 1 | 2 | 3 | 3 / 3 |
Every concern is served throughout; none waits for a copy. A write with no
concern named takes the new factor’s default at once: majority for a bucket
whose factor is now above one. consistent get reads the bucket’s whole replica
set, so after a raise it asks the new members too; wait for the owner’s
show buckets row to read N/N before a read that must see every copy.
A full member. A raise onto a member whose disk is above its high-water mark
is accepted. That member’s copy waits: it logs, once an hour for the bucket, why
and what to do (free space, then migrate <database> <bucket> on it, or
remove-node), and retries with a back-off that grows to thirty seconds. The
bucket’s other copies and every other bucket carry on.
With auto_rebalance off, nothing is copied for you. The member logs the
number of buckets waiting for a copy and up to ten names, each with the
migrate <database> <bucket> command to run on that member.
Only one change at a time per bucket. A raise is refused while a join is settling, while the owner is still recording a membership change, and while an earlier raise has copies that have not landed on a member the new factor keeps; each refusal says when to retry.
Lowering a bucket’s factor
A decrease (alter bucket Sessions { replication_factor: 2 }) deletes the
copies it removes once the remaining copies have shown that they hold everything
the removed copies hold; their space is reclaimed in the background after the
delete. When the cluster has fewer members than the factor, no copy is deleted:
every member already holds one at both factors.
Write path
client ──▸ coordinator (any node)
│ not the owner? forward the entry
▼
owner
1. append to WAL (fsync) ← the entry is durable here
2. apply locally
3. ship the entry to the bucket's other replicas
4. wait for the write concern
5. answer the client
replica ──▸ 1. append to WAL (fsync)
2. apply locally
3. acknowledge the sequence number
An entry is the original client opcode plus its original request payload
(ReplEntry). A replica applies an entry through the same Server.dispatch
call a local client request takes, so a mutation has exactly one implementation
and cannot drift between the client path and the replication path.
Write concern is evaluated against confirmed sequence numbers, never against local state, and it counts the copies that are in sync: the owner, and every replica that is in sync. A replica stops being in sync when its stream fails, is refused or falls behind its queue, and counts again only once the owner has sent it the log up to the tail. While it is still being sent what it missed, writes do not wait for it, and the node’s log says “replication to a peer is back in sync” when it counts again.
one— the owner’s own apply. No replica wait.majority— a majority of the copies in sync; the owner counts as one.all— every copy in sync.
A write at majority or all waits for the copies that are in sync —
reachable and caught up. If a replica is down or behind, the write succeeds on
the copies that remain, and the response says how many hold it:
copies_confirmed beside replication_factor. With every replica out,
majority and all are confirmed by the owner alone and answer
copies_confirmed: 1. Check the number if one copy is not enough for you.
Every write’s response carries copies_confirmed and replication_factor.
The CLI prints stored on N of M copies on stderr when the first is below a
majority of the second (1 of 3, 2 of 5); a write a majority holds prints nothing.
Fewer copies than the replication factor
stored on N of M copies means the write is on N of the bucket’s M copies, and
N is below a majority of M (1 of 3, 2 of 5). It is not an error: the write is
durable on the N copies that confirmed it, and the rest catch up. The CLI cannot
know which cause it is, so it points you at status and the node logs. The
common causes, and the check for each:
A write that a majority holds prints no note, so on three copies you never see
2 of 3: at the default majority concern the owner answers as soon as two
copies hold the write, and the third follows within milliseconds. That also means
a write on 2 of 3 copies is silent when one copy is out; status and nodes are
how you see a member that is down, and the write’s copies_confirmed and
replication_factor fields in the response carry the exact count.
- A replica is still connecting just after
create bucket. The replica set is placed when the bucket is created, and a replica may not have opened its stream yet. Wait a moment and checkstatus; the count reaches the factor on its own, with nothing to fix. - A node is down.
statuson the reachable nodes names it, andnodeslists it as unreachable. Start it again; what it missed is sent from the owner’s log when it returns, and the count recovers on its own. - A node is over its disk high-water mark.
statusshows thediskrow red (disk_high_water_pct,HOARDDB_DISK_HIGH_WATER, default85). The node refuses to copy a bucket in and logs the refusal. Free space on that node, or raiseHOARDDB_DISK_HIGH_WATER; replication resumes on its own (Is this node full?). - A node refused it for another reason. The refusal is in that node’s log
and names the bucket and the fix. On a Docker Compose stack,
docker compose logs <node>; otherwise read the node’s log file.
status on each node is the first check: it shows Healthy: false and names
the resource when one is red. The node log carries the full refusal and the step
that clears it.
The first all write after a replica stops answering waits for one send to
it to fail — at once if the node is down, up to half the write-concern timeout
(never under 2 seconds) if it is silent — and later writes do not wait for it.
An error for a write carries write_outcome: not_applied (retry freely),
applied (it is stored; do not retry a push or an $inc), or unknown.
A single node says the same: a write it refuses before anything is stored (a
bucket that does not exist, a permission, a size limit, a duplicate key, a
type refusal) carries write_outcome: not_applied.
A write that is still waiting for its copies when the write-concern timeout
(5s) ends is answered TIMEOUT with write_outcome: applied and the copies
that hold it; it reaches the others when they are reachable. A write the
server refuses (a $inc of a text field, for example) is answered with that
refusal and write_outcome: not_applied, even when a slow replica makes the
wait that follows time out: nothing was written.
A pop that removed an entry is the exception in three cases: its write
concern is not met in time, the bucket’s owner changes while it is in flight,
or one of its replicas is refused for not encrypting its store. A pop’s answer
is the only copy of the entry it removed, so in those three cases the answer is
a success that carries the entry, copies_confirmed below
replication_factor, write_outcome: applied, and a message that says what
fell short. Keep the entry; do not pop again to get it back, because a pop takes
the next entry. Any other error for a pop does not carry its entry, even when
the owner applied the pop: a forwarded pop whose answer is lost between nodes is
UNAVAILABLE with write_outcome: unknown, and its entry may be gone
(Limitations).
A write answered success: false because the owner’s own store failed to
apply it carries write_outcome: unknown too: its replicas apply it, and the
owner applies it when it restarts. Its copies_confirmed counts only the
replicas that hold it. Read it back before you retry a push.
Retry an insert with upsert. An insert whose answer you did not get, or
one answered write_outcome: unknown, may be stored. Sent again as an insert it
is refused DOCUMENT_EXISTS if it was; sent as upsert it is stored either
way, so upsert is the retry that is safe to repeat. An insert is decided once,
by the bucket’s owner: two inserts of one _id_ through different nodes give
one success and one DOCUMENT_EXISTS, and every replica, and every replay
after a restart, keeps the owner’s answer.
This changed in this release. all used to mean every copy of the
bucket’s factor, and a write at majority or all failed when the copies it
named did not confirm in time, or at once when fewer nodes were running than the
concern needed. It now means every copy that can confirm the write, and the
response says how many did. A program that relied on all failing compares
copies_confirmed with replication_factor instead.
A replica that is down does not make writes fail, however long it is down. What it missed is sent from the owner’s log when it returns.
A batch answers write_outcome per item. A BATCH request (HQL
batch { ... }) is many independent writes and reads, not a transaction: each
item is its own entry in its owner’s log, with its own sequence and its own wait
for the write concern, and its answer carries its own write_outcome with the
meaning above. One write concern, on the batch, covers every write item. The
node the client reached sends each bucket owner one forwarded request carrying
that owner’s items, not one per item. A batch refused whole (a malformed batch,
an item the session may not run) is refused before any item runs, with
write_outcome: not_applied when it carries a write. When an owner’s answer to
its part of a batch is lost, its write items are unknown and its read items
changed nothing, as for a lone forwarded request.
Write concern can be set per request ("write_concern": "majority" on an
insert or batch insert, HOARDDB_WRITE_CONCERN for the node default). The
default is per bucket: majority for a bucket whose factor is above one, one
otherwise, resolved by the bucket’s owner. HOARDDB_WRITE_CONCERN sets one
level for every bucket instead.
The accepted values are 1 (or one), majority and all, in any letter case.
Anything else, in a request or in HOARDDB_WRITE_CONCERN, is refused: a request
gets an INVALID_ARGUMENT error that lists the values and nothing is written,
and a node with a mistyped setting does not start. A typo is never served as
one.
Wire protocol
The cluster opcodes in the protocol spec are used, and one was added for membership changes:
GOSSIP(0x90) — announce this node, exchange member lists, verify agreement. Also the join handshake.REPL_PUSH(0x91) — apply these entries. One frame serves both hops: withreturn_responsethe owner’s response is relayed to the client (coordinator → owner), without it the acknowledgement is the applied-through sequence (owner → replica).REPL_ACK(0x92) — the response opcode for a replication push.MEMBERSHIP_CHANGE(0x9E) — one round of a membership change (prepare, accept or commit), named by a field of the request.CLUSTER_PROPOSE(0x96), the vote of earlier releases, is answered with a refusal that says to upgrade every member.
Node-to-node sessions authenticate with the cluster token, which already
exists on every node and is exactly the credential that authorises cluster
membership (HOARDDB_CLUSTER_TOKEN, or the cluster.token file in the data
directory). The transport rejects every non-auth opcode on an unauthenticated
session, so a node authenticates before it can replicate. Tokens are compared
in constant time against an in-memory copy. A token that does not match is
checked against the file only when the file has changed since it was last read,
which is what a rotation does, so a wrong token never costs a file read.
A failed write returns a defined error response rather than a handler error: the transport turns a handler error into a fatal stream reset, which would tear down a client’s session over something as ordinary as an unmet write concern.
Durability, recovery and repair
- The WAL is the authority for what a node has durably accepted. Every entry is appended before it is applied, on the owner and on every replica.
- On startup a node replays its WAL through the ordinary dispatch path. Replay is best-effort per entry (a create for a bucket that already exists is expected, not fatal) and runs once per process.
- One sender per peer. Each node sends what it logged to each other member from one place, in the order it logged it. A write is put on that peer’s queue (1,024 entries) only while the peer is keeping up; the writer never waits for the queue. When the queue is full, or a send fails or is refused, the node empties the queue and stops queueing for that peer: everything the peer has not confirmed is in the log.
- After a start, a node takes no write of its own until a majority of its
peers has answered it (for three members, both of the others; for five,
three of the four). Each answer says how far that peer holds this node’s log,
and before its first write the node moves its log past the highest of them.
A node whose log went backwards — restored from a snapshot of its VM or disk,
or whose
wal/directory was lost — therefore never gives a new write a sequence a peer already holds another write at, which would make the new write look older than the one it replaced. A normal restart costs one answer from each peer, which a node exchanges anyway. Until enough peers answer, a write to a bucket the node owns, or acreate databaseordrop databasesent to it, waits as long as it would wait for its copies (the write-concern timeout, 5 seconds by default) and is then refused,UNAVAILABLE, with “Nothing was written” and the peers not yet heard from named: start them or restore the network to them, or remove a peer that is gone for good withremove-node. A plain read is not held. When the log had to move, the node logs one warning at start: “this node’s log ended at T and its peers hold it through P …”. A peer that answers only after the node has started taking writes and holds the log further moves it again, with a warning naming that peer. - A replica that missed entries — it was down, slow, or its queue was full — is
repaired from the owner’s log when it answers again: the sender asks the
peer whether it is there before it reads any of the log, sends the entries
the peer has not confirmed in sequence order, filtered to the buckets that
peer holds (a
create databaseordrop databasegoes to every member), and only then sends new writes from the queue. A new write cannot overtake the repair. The acknowledgement position advances only as far as the peer says it applied. A sender whose peer does not answer tries again after half a second, then waits twice as long each time up to 30 seconds, and at once when the peer answers the node’s membership check or finishes copying a bucket from it. - A membership change starts every sender again from the log under the new
placement. An entry of a bucket this node no longer owns is not sent by it
after the change, and neither is one it wrote before an earlier change took
the bucket away and a later one gave it back. Each such write is counted once
in
hoarddb_replication_entries_stranded_totalwhen the node’s log is next read for a peer, and the node logs one warning naming the peer and up to five of the buckets. A write a replica lacks is counted once the node’s log is read for that replica, so the count is never below what any one replica lacks, and it can be above it: it also includes writes the bucket’s other copies did receive. - Replication is at-least-once: an acknowledgement lost in flight makes the sender re-send, so a receiver never applies the same sequence twice. The receiver’s position for each sender is rebuilt at start from its own write-ahead log, and checkpointed in the node’s store where the log cannot carry it; nothing is written for it per replicated frame. Without that, non-idempotent store types (FIFO, LIFO, heap, btree) would silently gain duplicate elements.
- A failed send is never dropped, and nothing is sent ahead of it. This is not a nicety: the acknowledgement is a high-water mark, so an entry that is skipped while a later one is acknowledged becomes permanently invisible — the repair starts above it, and the deduplication guard rejects it if it is ever sent again. Measured before the one-sender design: with a replica down longer than its queue, 213 of 1,238 writes were never delivered to it once it returned; after an owner restart, live writes overtook the repair and up to 297 of 1,000 earlier writes never arrived. Both are delivered now.
- The check that a replica’s unique index agrees with its owner’s
(
hoarddb_replica_constraint_disagreements_total) compares every write, live or sent from the log: the owner records in its bucket state each write it did not apply, until its log no longer holds it. - A drop reaches every member, not only the bucket’s current replica set, so a copy left on a node outside the set is dropped too. It removes exactly the bucket it names: a member that already holds a bucket of the same name created after the drop keeps it. Each member remembers the drop until no member, and no node removed in the last 64 membership changes, still holds that bucket, so a copy of a dropped bucket is never copied back.
- A replica never acknowledges a write it did not apply. If the owner
applied a write and a replica cannot — for example its copy of the bucket
is a different bucket of the same name, or it cannot read the bucket’s
declared field types — the replica refuses it, counts it in
hoarddb_replica_divergences_total, and logs one ERROR naming the bucket and the owner. Replication from that owner to that replica then stops for every bucket at that write, writes atw=allto those buckets time out, and the owner’s log is kept; the owner’s retention WARN names the replica and the bucket. Withauto_rebalanceon (the default) the replica repairs itself: it replaces its copy with the owner’s, and replication resumes. With it off, runmigrate <database> <bucket>on the replica to do the same. When the replica refused a create because it holds a different bucket of that name, dropping the bucket through its owner ends the stop too, and the replica keeps its own copy.
Is the cluster in sync?
Each node reports its own streams on /metrics (turn it on with
metrics.enabled; see Configuration). Alert on two
gauges:
hoarddb_replication_peers_not_in_syncis how many members this node’s stream to is stalled: the member is down, its connection hangs, it refused an entry, or this node could not queue one for it. 0 is healthy. Above 0,majorityandallwrites are confirmed by fewer copies (the response’scopies_confirmedsays how many), and what the member misses waits in this node’s log. The gauge returns to 0 once the member answers and the log has been sent to it up to the tail; writes do not have to stop first, except writes atonefaster than the member can be sent them (Limitations). Alert when it stays above 0 for longer than a restart of a node takes.hoarddb_replication_lag_secondsis how long the member that is owed entries has gone without confirming any of them. It stays near 0 while every member keeps up, and reads 0 when every member has confirmed everything sent to it. A reading that keeps growing is a member that is not confirming. Alert when it passes the delay you accept for a replica’s reads. It counts only from the first entry sent to a member, so a member that is down and has been sent nothing reads 0 here;hoarddb_replication_peers_not_in_syncstill counts it, so alert on both.
Four more say how much and why:
hoarddb_replication_lag_entriesis how many entries of this node’s log the furthest-behind member has not confirmed. A member that is sent nothing, because it holds none of the buckets being written, counts 0.hoarddb_membership_changes_total{outcome}counts the membership changes this node proposed, an operator’s or one it finished for a member that stopped, by outcome:committed,finished_another,already_committed,refused,no_majorityandindeterminate. Anindeterminatechange may still complete;nodessays whether it did.hoarddb_cluster_peers_unreachableis how many members fail this node’s membership check: down or cut off. A member whose replication connection hangs while it still answers that check is not in sync and is not unreachable.hoarddb_cluster_members_joiningis how many joined members this node has not yet seen every member settle. 0 is healthy. Above 0, schema changes are paused (Schema changes pause while a node joins), andnodessays what the join waits on.
After a join or a remove-node, hoarddb_handoffs_in_progress reads 0 on
every node when every bucket that moved has been taken over. A node that has no
copy of a moved bucket yet reads 0, and so does every node in the first seconds
after the change, before it has worked out what moved: read every node, more
than once. Three counters say how the hand-off went:
hoarddb_handoff_copies_total (buckets a node copied whole),
hoarddb_handoffs_without_previous_owner_total (buckets taken over without
their previous owner, after it was removed while down) and
hoarddb_handoff_entries_reconciled_total (writes an old owner delivered after
it lost the bucket).
Each is one series per node. None is per bucket or per database.
The node’s log names the member. A stream that leaves the in-sync set logs one
WARN, “replication to a peer left the in-sync set”, with node_id, the
reason, and the bucket when the member refused one it does not hold yet.
Later failures of the same stream are not logged again. When the stream
returns, one INFO, “replication to a peer is back in sync”, says how long it was
out (out_for) and how many entries were sent to it (entries_replayed).
While a member is out, every node’s log grows. No node prunes its
write-ahead log past what every member has confirmed. So every node’s log grows
until the member returns or is removed, not only the logs of the nodes whose
buckets it holds. Watch hoarddb_node_data_bytes{part="wal"} beside the data
directory’s free space (Is this node full?). Each node also logs
a WARN, “WAL retention on this node is held”, that names the member and the
fix: restart it, or remove it with remove-node if it is not coming back. The
first comes at the first retention pass that finds the member out, then one
every five minutes. Just after a node starts, the WARN says the node is waiting
for the member’s first handshake: until every member has answered once, nothing
is pruned.
Never start a copy of a running node
A node’s data directory holds its node id, its record in the member list, its
TLS key and the cluster token. A copy of it (a cloned VM or disk image, a
file-level backup restored beside the live node, a copied data_dir for a test
node) started while the original runs would run as the same node: both would
own its buckets and, if it is the cluster master for user changes, both would
accept them. To add a node, start it with an empty data directory and its own
HOARDDB_NODE_ID, then run join <node-id>@<address> on a member.
A copy that listens on a specific address other than the one the member list gives for its node id stops at start:
cluster: this node listens on 192.0.2.9:4433, but the member set in .../membership.json
lists its node id "n2" at 192.0.2.2:4433, another address: this looks like a copy of
that node's data directory ...
A copy listening on a wildcard address (0.0.0.0) cannot tell its address from
the listed one and starts. The cluster then detects it, within about a minute:
every process sends a random instance id on each membership exchange, and a
member that keeps hearing two of them under one node id reports it.
- The member logs one WARN, “two processes are running as node n2: …”, naming where each process was seen and both instance ids, with the fix.
statuson that member shows aDuplicate:row with the same text, andnodesmarks the nodeDUPLICATE IDENTITY.hoarddb_duplicate_node_id_detected_totalcounts each detection. It is one series per node.- Both processes under the node id are told. Each shows a
Duplicate:row in its ownstatusand refuses user changes until only one runs, so there are never two cluster masters.
What to do: stop the copy, empty its data directory, and start it again
with its own HOARDDB_NODE_ID before you join it. The warning clears about
two minutes after one of the two processes stops: the member that saw both
instances keeps the detection for one 60 s window after its last evidence, and
the duplicated node keeps the member’s report for another 60 s after that. A
node that restarts shows a new instance id once and is never reported.
Moving a standalone node into a cluster
A node started without HOARDDB_SEED_NODES keeps its users under its own
signing key, auth.token.key in its data directory. A clustered node signs with
a key derived from the cluster token instead, so every member can check every
other member’s logins. A standalone data directory cannot become a cluster member
in place. If you start it with HOARDDB_SEED_NODES, or with
HOARDDB_REPLICATION_FACTOR above 1, it stops at start and changes nothing:
this data directory (/var/lib/hoarddb) ran as a standalone node: its users are stored
under its own signing key, auth.token.key (key f4517766), ...
Started without those settings, it runs standalone as before. To move its data and users into the cluster:
- Start the node standalone again, connect with
hoarddb, and note each user and their roles:user list, thenuser describe <name>for each. - Dump its data:
hoarddb dump --address <its client address> --user <admin> --out ./standalone-dump. - Stop it. Start the cluster member on an empty data directory (a new
HOARDDB_DATA_DIR), withHOARDDB_SEED_NODESset. - Restore the data into the cluster:
hoarddb restore --address <a member's client address> --user <admin> --dir ./standalone-dump. - On the cluster, create each user again with the roles you noted:
create user <name> roles [role@db, ...]. Each user gets a new password, because a dump does not carry users or passwords (see Backup and Restore and User management).
Keep the old data directory until the cluster has served the restored data.
Upgrading a cluster that changed membership on an older release
On releases before this one, a membership change that failed after some members
had agreed to it, or two changes run at once, could leave a member on a member
set that never took effect, at the same epoch as the set that did. Such a member
could take buckets over as a second owner, and a later change could replace the
real owner’s copy with its copy, losing writes the real owner had acknowledged.
This release cannot repair that by itself, because which set took effect cannot
be told for certain afterwards. nodes does not show it: once that member has
caught up with later changes it shows the same epoch and set as every other.
Check with nodes history, which prints a member’s membership history, one line
per epoch with a short digest of the set:
epoch 3: n1 n3 n5 n9 (digest 5c1e0a7b)
- Upgrade every member. The upgrade changes no membership. Make no membership change until step 6 is done.
- List the nodes to compare: every member, and every node removed at or after
the earliest epoch any two histories disagree on that is still running. A
removed node is not listed by
nodes, but it is still asked for its copies when a bucket changes owner, so a removed node that is running is as dangerous as a member. The removed nodes are the ids that appear in an epoch’s line and not in a later one. Runnodes historyagainst each. Treat a removed node that does not answer as one on the wrong set (step 4). - Compare the lines. If every node shows the same digest for every epoch it holds, stop: the cluster did not diverge within the epochs recorded (but read the note on the 64-epoch limit below first).
- For each epoch where the nodes disagree, find which set took effect, by one rule: a set fits when the set every node agrees on at the next epoch differs from it by exactly one node. If exactly one of the sets fits, it is the one that took effect, and every node that holds another set at that epoch is on the wrong set, whether it agreed to that set or learned it from another node. In every other case, stop and rebuild the cluster from a backup: no set fits; the epoch is the last one recorded; the nodes disagree on the next epoch too; or both sets fit, which is the likeliest history, when an operator ran the change that seemed to fail again. Do not decide by counting which set more nodes hold: the wrong set can spread to a majority.
- Stop every node on the wrong set at once, before removing any, the removed-but-running ones included: one left running while another is removed can still hand its copy over the real owner’s. Empty the data directory of each removed one now. Then check that a majority of the current members is still running; if not, the cluster cannot remove the stopped ones, so rebuild it from a backup.
- With every one of them down, for each member on the wrong set in turn: empty
its data directory, remove it (
remove-node, run on a running member), and wait until the removal has handed its buckets over (nodesshows schema changes allowed). When every one is removed, add each back as a new node, one at a time.
Removing a node on the wrong set while it is running loses acknowledged writes: the hand-over that the removal starts copies its copy over the real owner’s, and a running one can do the same during another node’s removal.
The 64-epoch limit is a limit on what can be checked, not on the danger.
nodes history shows at most the last 64 epochs. A copy taken over on the wrong
set outranks the real owner’s copy at any age when the real owner’s copy has not
changed hands since, and with auto_rebalance off nothing moves it. A cluster
whose history does not reach back to the first membership change it made on an
older release, and which ever had a change fail or run beside another on such a
release, should be rebuilt from a backup rather than checked.
After the upgrade, a member that reports a different member set at its own epoch
can only be one that diverged on an older release, or a configuration error:
its writes are refused with a message pointing at nodes history and this
procedure.
Upgrading a cluster that already holds data: bucket placement changed
Builds before this change placed a bucket by its name alone, so every
database’s users bucket shared one owner and one replica set. A bucket is now
placed by its database and its name together. On a cluster with data, most
buckets therefore belong on different nodes after the upgrade — about
(N−1)/N of them on N nodes — and they move once, on their own:
- Upgrade every node, then start them. Stop all nodes, install the new build on each, start them all. Do not run old and new builds side by side: they disagree about where a bucket lives, and nothing detects that disagreement. Upgrade every encrypted node before you admit an unencrypted one: an encrypted node on an earlier build does not check its peers and keeps replicating to an unencrypted node. See Encryption in a cluster.
- On start, each node hands off what it should no longer hold. A node
that holds a bucket outside the bucket’s new replica set asks the new owner
and replicas to copy it, owner first. Once every member of the set holds
everything this node’s copy holds, this node removes its own copy; until
then
show bucketson it printsleavingbeside the bucket. The node logsrelocating buckets this node holds outside their replica setwhen it starts moving andrelocation of misplaced buckets finishedwhen it is done. A member that is not up yet is retried with back-off, up to 30 seconds between tries, until it answers. - While a bucket moves, requests for it can fail. Its new owner refuses
writes and reads of it until it has taken it over (“ownership of
<database>.<bucket>is moving to this node … Retry in a moment”), even when it already holds a copy as a replica: that copy may lack writes the previous owner acknowledged. A refused request starts the take-over. A read sent there before its copy has started finds no bucket. Retry them; the move of one bucket is one snapshot copy. - With
auto_rebalanceoff (HOARDDB_AUTO_REBALANCE=false), nothing moves. The node logs a warning with the number of buckets it holds outside their replica set. Runmigrate <database> <bucket>on each such bucket’s new owner (nodeslists the members).
What it costs: each bucket that moves is one snapshot transfer, plus one request to each member of its replica set. A node with nothing to move pays one ring lookup per bucket it holds at start, and sends nothing.
A fresh cluster, or a single node, has nothing to move and notices nothing.
Limitations
These are real and worth knowing before relying on the cluster:
Start every founding node before you use a new cluster. On a cluster’s first start each node’s member list comes from its seed settings, and a node serves database and bucket requests only once every node in that list has answered it and agrees; until then it answers “not added yet”, and
statusshowsMembership: NOT ADDED YET. Starting all of them is enough — they confirm each other within a gossip round. A node added later withjoinis confirmed by the join itself.Eventual consistency by default. A read is served from whichever replica the client reaches. A replica that is behind (or was just offline and has not been repaired yet) can return stale or missing data. A midpoint reads as locally-consistent-per-replica, not globally consistent.
Opt-in strong consistency:
consistent getreads the key from every member of the bucket’s replica set, returns the copy with the highest version, and repairs a stale or missing replica through the normal replication path. The ordinarygetis unchanged — still eventual, still served from whichever replica is reached. Whatconsistent getguarantees:- A version is ordered by membership change first. After a
joinor aremove-nodemoves a bucket, a write through its new owner outranks every write its old owner made, so an owner change does not let an older value win. - A key the owner has deleted is not brought back from a replica that missed the delete. When the bucket’s owner does not hold the key, its copies that the owner has already passed are answered “not found” and removed. A write made while the read runs is kept.
- While the bucket’s owner cannot be read,
consistent getanswers only if every other copy answered and they agree; otherwise it is refused, and the message names the owner. It returns every write acknowledged atmajorityorall. A write acknowledged atone, whose owner is then down, may not be returned. The cost: with the owner down and one more copy down, or the other copies differing, the read is refused until the owner is back. - While a bucket changes owner: if the new owner already held a copy (a
replica that a
remove-nodepromoted),consistent getis refused until it has taken the bucket over. If it did not (a node that just joined), the read answers from the other copies when all of them answered and agree, and is refused otherwise. - It is refused for the moments after a membership change in which members disagree on the membership, and while a member runs an earlier build of hoardDB. Each refusal says what to wait for; retry.
- It compares only copies of the bucket the owner serves. A copy of another bucket of the same name, created apart, is left out, and the message names the node that holds it.
- A version is ordered by membership change first. After a
Database-level DDL is not covered by write concern.
create databasehas no bucket to place by, so it is broadcast to every member on the same ordered stream as bucket writes and not waited for. Ordering guarantees a bucket created after its database arrives after it; acknowledgement does not. It is still not waited for, and it is delivered to a member that was down: every node keeps its log until every member has confirmed it, so a member that was down or behind is sent what it missed when it answers again, whether or not the node that took the DDL restarted in between.The WAL is truncated only up to what every member has confirmed.
WAL.Truncatecan be called, but the server’s retention pass (server/retention.go, everyHOARDDB_WAL_RETENTION_INTERVALseconds) prunes only segments every other member has confirmed and every local store has been synced past, and it syncs the Badger stores first. Until a member has answered since this node started, nothing is pruned. A member that is down, or whose stream is refused, keeps every node’s log from shrinking while it is down — which is exactly when the log is the repair tool. A member this node has sent nothing to for a while does not: each retention pass tells it how far nothing is owed to it, it records that before it answers, and the next pass can prune past it.WAL.FirstSequence()is the retained horizon, and the sender refuses (rather than silently skipping) any peer whose position is below it, and logs the repair: remove the node (remove-node), empty its data directory and add it again, so it is seeded by a snapshot.migratedoes not re-seed a node that already holds a copy. (Before retention existed the WAL was never truncated and this footgun was latent.)A node that comes to hold a bucket copies it whole. When a membership change makes a node a bucket’s owner or replica and it holds nothing, it copies the bucket (every engine: hash, btree, heap, fifo, lifo and blob) and is then kept current by replication. A new owner copies from the node that holds the bucket’s most complete copy; a new replica copies from the bucket’s owner once the owner has taken the bucket over. Adding a node is also not the fix for a node whose disk is filling: see the disk row of Is this node full?.
A copy kept by an earlier version can be stale. A node that leaves a bucket’s replica set — after a membership change or the placement upgrade above — now deletes its copy once every member of the set holds everything it holds, so if it rejoins that set later it fetches a fresh copy. Earlier versions kept the copy when
replication_factorwas above 1. A copy an earlier version left on a node that is still outside the bucket’s set is deleted after the upgrade when auto-rebalance is on (the default): on the node’s first start, or at the next membership change. A copy on a node that had already rejoined the bucket’s set before the upgrade is not detected, and can be missing every write made while the node was out. If a node left a replica set and rejoined it on an earlier version, wipe its data directory and re-add it.A wiped node can be re-added under its old node id. Each node names every life of its write-ahead log with an id kept in
wal/incarnation.bson, and its peers keep their position in that log together with the id. A node whose data directory was emptied starts a new log under a new id, so its peers apply what it sends from the start of that log, whatever they held for the old one. (Before this release a peer that remembered a higher position took the new writes for ones it already held, skipped them, and still confirmed them.)A node restored from a file-level copy of its data directory comes back behind its replicas, and says so. Its log ends where the copy was taken, and its replicas hold writes after that point that it no longer has. It logs one line per replica, naming it: “… had applied this node’s log through P; this log now ends at T …”. Before it takes a write it moves its log past P (see Durability, recovery and repair above), so new writes replicate normally, are never taken for the ones it lost, and order above them. A peer that was not among the majority that answered at start can still hold the log further; the node then moves its log again when that peer answers and warns that the writes it took meanwhile may order below that peer’s. To make the copies identical, remove that replica (
remove-node), empty its data directory and add it again. Let every replica be repaired before you change the membership. Back up and restore throughdumpandrestore(Backup and Restore) to avoid this.wal/incarnation.bsonbelongs to the log. Copy it withwal/, never without it. A node that finds its log and not that file cannot tell which of its peers’ positions are current, and does not replicate to a peer that holds one until the file is restored or the node is removed, emptied and added again; it logs the remedy. The file has no checksum: damage that leaves it readable is not detected, and the node trusts it and writes it back, so it then misjudges its peers’ positions without an error. Keep a copy ofwal/with every backup of the data directory.A membership change needs a majority.
joinandremove-nodecommit only when a majority of the current members accept, so a cluster that has lost its majority cannot change its membership. Placement changes with membership, and the data moves on its own unless auto-rebalance is off (HOARDDB_AUTO_REBALANCE=false).A node that encrypts its store gives no bucket’s data to a node that does not. Each node encrypts what it stores under its own key, so a bucket handed to an unencrypted node would be written there in the clear. When a membership change places a bucket held by an encrypted node on an unencrypted one, the encrypted node refuses to hand the bucket over — to the rebalance and to
migratealike — and refuses creates and writes whose replica set includes that node. It also refuses to answer a read, a dump or a write for its buckets that reaches it through an unencrypted node: connect clients to encrypted nodes. The error names the node; the data stays where it is until that node is started from a fresh data directory with encryption on, or removed withremove-node. Two things do reach every member, by design: the names of databases and buckets, and the user accounts. A node withencryption.mode: requireoronrefuses to add a node atjointhat says its store is unencrypted, and says how to fix it. See Encryption in a cluster.The CLI does not set write concern per request yet, even though requests carry the field.
A node outside a bucket’s replica set does not hold the bucket at all. Bucket DDL is placed on the replica set rather than broadcast, so
show bucketson a non-replica node lists nothing for it even though reads and writes work there (they are forwarded). With 5 members and RF=3 that is two of five nodes.Route reads to the holders, not to every node. A forwarded read costs about 2.4× a local one (measured: 5,024/s forwarded against 11,927/s local at one read in flight), so a client that round-robins over all five members of a 5-node RF=3 cluster is slower than one that uses a single node: 65,820/s versus 74,105/s, while round-robining over the three holders gives 95,602/s (+29%). Anything in front of the port — a load balancer, a proxy, DNS — must know which nodes hold a bucket, which is why clients are expected to use the ring rather than a generic balancer. Details:
bench/RESULTS.md.
Testing
server/cluster_replication_test.go starts three real TCP+TLS servers on loopback with a real ring and a real cluster token — nothing is mocked, because only an end-to-end test can show that data actually arrives.
TestClusterPropagatesWritesToEveryReplicawrites 30 documents to one node and reads each one back from every node’s own storage.TestWriteConcernFailsWhenReplicasAreDownstops two of three nodes and assertsmajorityandallfail whileonestill succeeds.TestRestartedReplicaCatchesUpstops a replica, writes atmajority, restarts it, and asserts the missed writes are repaired from the owner.TestReplicaReceivesEntriesRetriedAfterAFailedSendstops a replica, writes atmajority, restarts it, and asserts the entries arrive without the test asking for a repair, because the sender must not discard them (see the failed-send note above).TestEntryThatCouldNotBeQueuedIsOnTheReplicaAfterItReturns,TestLiveWritesDoNotOvertakeCatchUpAfterAnOwnerRestartandTestAFullQueueNeverBlocksAWriterhold the one-sender guarantees: a replica down longer than its queue, an owner restart with live writes during the repair, and a frozen replica under 2,000 writes atone.TestClusterReplicationFactorThreeOnFiveNodesruns 5 members at RF=3 and asserts each bucket lands on exactly 3 of them, that the other 2 hold none, thatone/majority/allall succeed (write concern is measured against the replica set, not the member count), and that every node can serve a read — including the two that hold no copy.
For throughput, see bench/RESULTS.md. To reproduce a three-node cluster on
one host, give each node its own ring port, spaced ten apart: a node’s internode
port is its ring port plus one, so ring ports 4433, 4434 and 4435 would put one
node’s internode port on the next node’s ring port and break the pins. The
steps are a shared cluster token, a certificate per node (every seed must be
given a fingerprint, so each certificate exists before its node starts), and then the three
nodes:
export HOARDDB_ROOT_USER=admin HOARDDB_ROOT_PASSWORD='choose-a-long-password'
export HOARDDB_CLUSTER_TOKEN_FILE=/tmp/cluster.token
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > /tmp/cluster.token
# a certificate per node, and the pin list built from their fingerprints
PINS=""
for n in 1 2 3; do
mkdir -p /tmp/n$n/keys
openssl req -x509 -newkey ed25519 -nodes -days 365 -subj "/O=hoardDB/CN=hoarddb-node" \
-keyout /tmp/n$n/keys/server.key -out /tmp/n$n/keys/server.crt 2>/dev/null
chmod 600 /tmp/n$n/keys/server.key
fingerprint=$(openssl pkey -in /tmp/n$n/keys/server.key -pubout -outform DER | tail -c 32 | sha256sum | cut -d' ' -f1)
PINS="$PINS,127.0.0.1:$((4423 + 10 * n))=SHA256:$fingerprint"
done
export HOARDDB_SEED_FINGERPRINTS="${PINS#,}"
export HOARDDB_SEED_NODES=127.0.0.1:4433,127.0.0.1:4443,127.0.0.1:4453
export HOARDDB_REPLICATION_FACTOR=3
# ring ports 4433, 4443, 4453 (internode 4434, 4444, 4454); client ports 7433, 7434, 7435
for n in 1 2 3; do
HOARDDB_DATA_DIR=/tmp/n$n HOARDDB_LISTEN=127.0.0.1:$((4423 + 10 * n)) \
HOARDDB_TCP_LISTEN=127.0.0.1:$((7432 + n)) ./hoarddb-server > /tmp/n$n.log 2>&1 &
done
Wait a few seconds, then write through node 1 and read through node 2:
printf 'create database app;\nuse app\ncreate bucket notes {type: hash};\ndb.notes.insert({text: "hello from node 1"})\nexit\n' | \
./hoarddb --insecure --user admin --password "$HOARDDB_ROOT_PASSWORD" --address localhost:7433
printf 'use app\ndb.notes.find({})\nexit\n' | \
./hoarddb --insecure --user admin --password "$HOARDDB_ROOT_PASSWORD" --address localhost:7434
The second command prints the document inserted through node 1.
Those addresses are loopback, which is right on one host and wrong on several.
For a cluster across machines, every node sets HOARDDB_LISTEN to the address
the others reach it on (not 127.0.0.1), and lists the same addresses in
HOARDDB_SEED_NODES:
# on the machine 192.0.2.1 (and likewise 192.0.2.2, 192.0.2.3 with their own address)
HOARDDB_LISTEN=192.0.2.1:4433 HOARDDB_TCP_LISTEN=0.0.0.0:7433 \
HOARDDB_SEED_NODES=192.0.2.1:4433,192.0.2.2:4433,192.0.2.3:4433 \
HOARDDB_SEED_FINGERPRINTS=... HOARDDB_REPLICATION_FACTOR=3 ./hoarddb-server
A node listening on 0.0.0.0 advertises 127.0.0.1 to its peers, so on several hosts set
HOARDDB_LISTEN to each host’s reachable address, or use HOARDDB_NODE_ID with id@address
seeds.
A node with seeds on another machine that is still on the loopback default is
refused at start, and the message names HOARDDB_LISTEN. The internode port
(the node address’s port plus one, 4434 here) must be reachable between the
nodes and from nothing else; see Security.
A node starts happily while its peers are still down: the ring is built from configuration, and unreachable peers are retried in the background.
Source: docs/user/replication.md in the repository.