Operating Replication
Replication is how a Harper cluster stays available: peers exchange data directly over WebSockets, there is no primary, and a node that falls behind catches up on its own. The replication reference covers how to configure that. This guide covers the part that only matters once real users depend on it, which is how to tell whether replication is actually working.
The distinction that runs through this guide is between a connection and convergence. A connected socket proves two nodes can talk. It does not prove the data a user is about to read is current. Those are different claims, and only one of them is what your traffic admission decision depends on.
What You Will Learn
- How to determine what your cluster actually replicates, rather than assuming a default
- What moves between peers automatically, what does not, and which of those will surprise you
- How to read
cluster_statusas an operator, including which timing field actually indicates a node is behind - How to prove convergence with a sentinel rather than inferring it from connection state
- Which application behaviors replication cannot make safe, and what to do about them instead
Prerequisites
- A cluster of at least three nodes, so you can interrupt one peer and still observe the others
- A
super_usercredential for the Operations API - How Harper Runs in Production and your service boundary inventory
- Familiarity with how your cluster was joined, either through
harper-config.yamlroutes or the clustering operations
Know your own scope
By default Harper replicates all data in all databases. Scope can be narrowed two ways: per database in configuration, and per table in the schema.
replication:
databases:
- data
- system
type LocalTableForNode @table(replicate: false) {
id: ID! @primaryKey
name: String!
}
All tables in a replicated database replicate unless the table opts out. So the scope you are operating is the product of a config list, a set of schema directives, and any directional routes someone added later. Do not reconstruct it from memory. Read it off the running cluster:
- curl
- fetch
curl -s -X POST https://my-node.example.com:9925/ \
-H 'Content-Type: application/json' \
-u 'admin:password' \
-d '{"operation":"cluster_status"}'
await fetch('https://my-node.example.com:9925/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa('admin:password'),
},
body: JSON.stringify({ operation: 'cluster_status' }),
});
There is one socket per database per peer, so the sockets present in the response are the ground truth about what is flowing. Run this from every node, not one. Replication direction can be constrained per route, so node A's view of the cluster is not necessarily node B's view, and a one-sided picture is how directional configuration mistakes survive into production.
Whether the system database is in your replication scope is the highest-stakes scoping question in a Harper cluster, because system holds hdb_user, hdb_role, and hdb_nodes. It is in scope by default, since the default replication scope is every database, so unless someone has narrowed it your users and roles already propagate and every node that receives system must be trusted with its contents, including encrypted secret rows. That is usually what you want, but it should be a decision rather than a surprise. Confirm your own answer from cluster_status and your configuration, and read replicating the system database with controlled flow before changing it.
What moves automatically, and what does not
| Object or change | Behavior | What it means for you |
|---|---|---|
| Data mutations | Insert, update, upsert, delete, and bulk load replicate for tables in scope | Monitor connections, latency, and convergence for the databases that serve critical journeys |
| Transactions | Replicated atomically, and a transaction may span multiple tables | Tables that need to commit together must live in the same database |
| A brand new node | Requests a full copy of each in-scope database from every peer it has no resume cursor for, then enters incremental replication | Budget N transfers, not one. See the note below before sizing a join |
| A returning node | Resynchronizes automatically to catch up on the transactions it missed | This is not a full copy. A routine restart is much cheaper than a node replacement, so budget them separately |
| Component deployment | deploy_component replicates by default; pass "replicated": false to hold it to one node | Cluster-wide is the default, so single-node validation is the case you must ask for |
| Configuration | set_configuration supports "replicated": true Added in: v5.2.0 | Never replicate node-local parameters. See the warning below |
| Users and roles | Propagate when the system database is in replication scope, which is the default | If you narrow scope to exclude system, identity must be provisioned on every node by your own automation |
Node registry (hdb_nodes) | Each node rewrites its own self-record from its harper-config.yaml routes on restart or component reload | Scoping applied through add_node alone does not survive a restart. Put durable constraints in config routes |
| Destructive schema changes | drop_database and drop_table replicate by default; drop_attribute does not | A drop is a cluster-wide event unless you pass "replicated": false. Verify the result on every node |
The three rows most likely to bite you are the returning node, the new node, and the node registry.
On the returning node: it is common to see a routine restart budgeted as though it were a full resynchronization, which makes teams avoid restarts they should be comfortable with. A node whose databases have never synced does download them in full. A node that was briefly offline catches up on what it missed. Measure both on your own data volume once, and use the right number for the right situation.
On the new node, the cost is larger than "one copy from one peer," because the full-copy decision is made per peer and per database rather than once against a bootstrap source. A joining node requests a full copy from every peer it has no resume cursor for. And because replication is bidirectional, each established peer independently decides it has no cursor for the newcomer and requests a full copy from it as well. Joining a cluster of N peers is therefore N inbound transfers plus N outbound transfers of the new node's own (empty) databases, not a single stream from one source.
The practical consequences are worth planning around. Load lands on every existing peer at once rather than on one, so a join during peak traffic is a capacity event for the whole cluster. If you want a single designated source instead, add_node accepts isLeader: true, which tells the joining node to request its full copy from that peer alone.
On the registry: a node's advertised record is derived from its configuration file, and it replicates. That means a topology constraint you applied imperatively is superseded the next time that node restarts or reloads components, and the node quietly goes back to advertising itself more broadly than you intended.
When replicating configuration, only send cluster-appropriate parameters. Replicating a node-local value such as a port, node.hostname, a file path, TLS material, or replication.hostname, url, or routes overwrites every peer's own local value. To apply a cluster-wide change safely, use set_configuration with "replicated": true for the parameter, then restart_service with "replicated": true, which restarts nodes one at a time.
Read cluster_status like an operator
A trimmed response, with the fields that matter:
{
"type": "cluster-status",
"node_name": "server-1.example.net",
"is_enabled": true,
"connections": [
{
"url": "wss://server-2.example.net:9933",
"name": "server-2.example.net",
"database_sockets": [
{
"database": "data",
"connected": true,
"latency": 0.7,
"lastCommitConfirmed": "Wed, 12 Feb 2025 19:09:34 GMT",
"lastReceivedRemoteTime": "Wed, 12 Feb 2025 16:49:29 GMT",
"lastReceivedLocalTime": "Wed, 12 Feb 2025 19:09:31 GMT"
}
]
}
]
}
What each field is telling you:
connectedis the liveness of this one database's socket to this one peer. A missing peer, or a peer present withconnected: false, is actionable before users notice.latencyis the round trip to that peer in milliseconds. Alert on sustained growth rather than on a single sample.lastCommitConfirmedis the last time this peer acknowledged receiving one of your commits. If it stops advancing while you are still writing, your writes are not landing on that peer.lastReceivedRemoteTimeis the source node's timestamp on the newest transaction you have received.lastReceivedLocalTimeis your own clock when you received it.
The last two are the pair that matters. A widening gap between lastReceivedRemoteTime and lastReceivedLocalTime means this node is behind and working through a backlog. That is the signal to alert on for convergence, and it is the one that tells you a returning node is not ready for traffic yet. sendingMessage appears while a transaction is actively being sent and is absent when the socket is idle, so its absence is not a fault.
Two things will mislead you if you automate on that pair without knowing them. The gap only means "behind" while transactions are actually arriving: on an idle source both stamps freeze, and the gap sits at whatever constant it last reached rather than signalling lag. And because the two stamps come from two different clocks, skew between peers is added to the gap directly, so calibrate your threshold against a healthy baseline rather than treating the raw number as replication delay.
While a database is taking a full copy, these timing fields render as the literal string "Copying" rather than a date. Anything that parses them as timestamps will fail on a joining node, which is exactly the node you most want to be watching. Handle that value explicitly.
Inventory system.hdb_nodes alongside this and compare it to your intended topology. Configuration intent and live peer state should agree, and the node's own row is in there too, not just its peers.
What to alert on
- A peer missing entirely from
connections, orconnected: falseon a database that serves a critical journey - Sustained growth in
latency, judged against your own baseline lastCommitConfirmednot advancing on a peer while writes are occurring- A
lastReceivedRemoteTimetolastReceivedLocalTimegap exceeding your admission budget, evaluated only while writes are flowing - Repeated reconnects, which are visible in the logs even when a point-in-time status check looks healthy
- Version or configuration drift between peers
Prove convergence, not connection
Socket state cannot tell you that a specific business record is current. For anything where the answer matters, write a sentinel and read it back from the peer.
// On the source node: write a sentinel with a known value
const marker = { id: 'convergence-probe', writtenAt: Date.now(), from: 'server-1' };
await tables.OpsProbe.put(marker.id, marker);
# On the target node: read it back and compare writtenAt
curl -s https://server-2.example.net:9926/OpsProbe/convergence-probe \
-u 'admin:password'
The interval between the write and the moment the peer returns the new value is your measured convergence time for that database, under whatever load the cluster is carrying at the time. Run it under load, not on an idle cluster, and record the result. That number is what your node admission gate should be compared against, and it is an input to the RPO work in Engineering RPO, RTO, and Uptime.
The replication-latency metric from analytics gives you the continuous version of the same measurement: the difference between the source commit timestamp and local time, reported per node, database, and table. Use the sentinel for a definitive answer during a change, and the metric for a dashboard.
Note that replication-latency is not recorded for the system database. Since system is in replication scope by default and carries your users and roles, a dashboard built only on this metric will show nothing for the database that propagates identity. Use the sentinel, or the cluster_status receive-time pair, if you need to watch system convergence.
Consistency belongs in the application design
Replication makes data available on every peer. It does not make every peer agree at every instant, and a distributed decision made on a node that has not yet converged can observe stale state and act on it.
This is not a Harper limitation to work around, it is the property that lets any node serve any request without a coordinator. But it means a specific class of operation is unsafe if you write it as a plain read followed by a write:
- Claim-once actions: redeeming a code, assigning a unique handle, awarding a one-per-customer offer
- Hard floors: inventory that must not go negative, a balance that must not overdraw
- Global limits: a rate limit or quota enforced across the whole cluster
- State machine transitions where two nodes could both believe they are making the same transition
For each of these, choose one of three designs: route all decisions for a given key to a single owner, use a serialization mechanism so the conflict is resolved in one place, or delegate to an external coordinator. Then test your read-after-write expectations through the actual public route rather than against a single node, because a single node always looks consistent to itself.
Prove it
On a non-production cluster under write load:
- Record baseline
cluster_statustiming fields on every node. - Interrupt replication to one peer, by stopping the node or blocking the replication port, while writes continue elsewhere.
- Watch what your monitoring reports, and how long it takes to say anything. Note whether
connectedflipped, whether latency alerted, and how long until a human would have known. - Verify your application's actual behavior on the isolated node. Does the critical journey fail, serve stale data, or serve correctly? All three are possible and you should know which.
- Restore the peer, then measure convergence with the sentinel until it is current.
- Compare the convergence time against the admission gate in Health Checks and Traffic Admission. If your gate would have admitted the node before step 5 finished, the gate is wrong.
Operational notes
- mTLS is required on the secure replication port and cannot be disabled. Certificate expiry is therefore a replication outage, so treat certificate lifetime as an operational deadline with its own alert. See certificate management.
- Gossip discovery means one route can join a whole cluster. A node that connects to one peer discovers the rest. That is convenient and it means an accidental route can widen your topology more than you intended.
- Sharding is a separate scope control. If you use sharding, not every node holds every record, so "this node is converged" and "this node can answer this query locally" become different questions.
- Analytics data has its own replication setting. See
analytics.replicatein the analytics configuration before you build dashboards that assume metrics are or are not cluster-wide. - Keep node-local operational state out of scope. Availability flags, maintenance markers, and anything else that describes one node rather than the cluster should not replicate, or a routine drain propagates to peers.
Readiness checklist
- Replication scope read off the running cluster, from every node, not reconstructed from memory
- Whether the
systemdatabase is in scope is a documented, deliberate decision - Identity provisioning procedure exists and matches that decision
- Tables requiring one transaction boundary confirmed to be in one database
- Durable topology constraints live in
harper-config.yamlroutes, not only inadd_nodecalls - Measured convergence time recorded under load, per critical database
- Alerts configured on missing peers, sustained latency growth, stalled
lastCommitConfirmed, and the remote-to-local receive gap - Non-commutative operations identified and given an owner, a serialization point, or an external coordinator
- Replication certificate expiry dates tracked with an alert
- Replication interruption and recovery exercise completed and dated
Additional Resources
- Replication overview for routes, scope, controlled flow, and securing connections
- Clustering operations for
add_node,set_node, and thecluster_statusresponse in full - Sharding for controlling how many nodes hold a given record
- Replication metrics for
replication-latencyand byte counters - Certificate management and certificate verification
- Database schema for the
replicatetable directive - Monitoring and Triage for putting these signals on a dashboard