Operations
View SourceRunning Malachi: the ports it opens, how to scrape it, what to watch, and the settings that must not be left at their development values.
Ports
| port | env | serves |
|---|---|---|
| 4040 | MALACHI_TCP_PORT | the binary log protocol, all client traffic |
| 4041 | MALACHI_DASHBOARD_PORT | dashboard, health checks, metrics |
| 4042 | MALACHI_CONSOLE_PORT | the operator console, while it replaces the dashboard |
Only 4040 needs to be reachable by clients. Treat 4041 and 4042 as internal ports: they expose operational detail and user management.
The console endpoint
The operator console is served from its own port, beside the dashboard, until it reaches parity with it
(docs/design/operator-interfaces.md, sections 10.6 and 13). It serves the console bundle from
priv/static/console/, which is never committed. The bundle is to be built and injected by CI once the
web console itself lands (phase 3 of that document); until then no build produces one, and the endpoint
answers 503 as described below.
- No bundle, no failure. With no
index.htmlin that directory, the node boots, logs a warning, and every console request answers503saying the console was not built into the release. - Switch it off with
MALACHI_CONSOLE_ENABLED=false, which starts no listener at all, for a node run headless against a console served elsewhere. - A port it cannot open does not stop the broker. The error is logged and the node runs without a console until the port is freed and the node restarts.
- Plain HTTP only, for now. TLS for the console is #70. Put it behind a TLS-terminating proxy, like the dashboard, when it is reached from anywhere but the host.
- Caching. Every file is hashed at startup and served with that hash as its ETag. Files under
assets/whose last name segment looks like a build hash (eight or more letters, digits or underscores with a digit, as Vite writes them) are cached for a year as immutable;index.htmland everything else are revalidated on every use, which costs a304. A stable file that must change in place belongs outsideassets/, or under a name that cannot pass for a hash. A new bundle is picked up at restart. Until then a file changed or removed while the node runs, or swapped for a symbolic link (the file or a directory above it), answers404rather than new bytes under the old ETag. The check looks for links on every level below the root and compares each file's device, inode, size and modification time before sending it, so it does not see a rewrite that keeps the size and the modification time, nor a swap made between the check and the send. Replace a bundle by restarting the node, not by writing into it. - Limits. The header limits and read deadline are the dashboard's (
MALACHI_DASHBOARD_MAX_HEADER_*), and an idle keep alive connection is closed after that deadline. - Sessions. The application shell needs no login; the
/api/v1routes do, withAuthorization: Beareror themalachi_tokencookie a login on the dashboard sets, which the browser also sends to the console's port on the same host. Each route requires a console role (console roles), andGET /api/v1/mesays who the session is.
Health checks
Two endpoints, and the difference matters for orchestrators:
GET /health the process is up
GET /ready it is ready to serveUse /health for a liveness probe and /ready for readiness. Wiring readiness to /health means traffic
arrives before the node can serve it.
Metrics
/metrics does content negotiation. Ask for text/plain and you get the Prometheus text exposition
format, every series namespaced malachi_:
curl -H 'Accept: text/plain' http://localhost:4041/metrics
Without that header the same endpoint returns the JSON payload the dashboard uses. Either way it needs a
session whose account holds the console viewer role or above (or the admin permission): a scraper
logging in with a produce or consume account is refused with a 403.
Worth alerting on:
malachi_domain_violations: segments that cannot meet their placement spread requirement. Non-zero means replicas are concentrating where they should not be, which is a real availability risk that is otherwise silent.malachi_storage_integrity_failures_total{reason}: a stored segment failed checksum verification. Non-zero means data at rest is damaged on that node, and the condition is otherwise invisible: a damaged copy serves short reads with no error, so a consumer either stalls at the damaged offset or silently skips the rest of that segment.reason="incomplete"on an active segment is ordinary crash recovery (a partial tail that was never acked); on a sealed segment any reason means corruption at rest, and that copy needs to be rebuilt from an intact replica. The matching log line names the segment and the byte position.malachi_storage_flush_duration_seconds: the group-commit flush latency, as a histogram (_bucket{le}four per octave from 8us to about 16.8s, plus_sumand_count). This is the write plus sync every acknowledged produce waits behind, so it is the first place to look when produce throughput drops without CPU rising: an fsync-bound disk shows up here as a rising p99 before anything else moves. Read it windowed,histogram_quantile(0.99, rate(malachi_storage_flush_duration_seconds_bucket[5m])), and sum the buckets across nodes before taking the quantile for a cluster-wide view. Only flushes that wrote records and succeeded are counted; a failed one is inmalachi_storage_failures_total.malachi_storage_flushed_records_totaldivided bymalachi_storage_flush_duration_seconds_countis records per sync, which is how well group commit is coalescing (near 1 means it is not), andmalachi_storage_flushed_bytes_totalover that same count is the average flush size.malachi_storage_flush_duration_seconds_createdis the Unix time the histogram began: it changes only when the node restarts, so a tool subtracting two scrapes can tell a restart from counters that simply kept growing.- Session and auth counters. Note that
:session_expiredand:session_hijack_attemptare not disjoint: one validation can emit both, so summing them does not count failed validations. The hijack counter means "a token arrived from an unexpected IP", which ordinary NAT rotation can also trigger, so set thresholds against that broader meaning. SeeMalachi.Auth.SessionManager.
Durability tuning (group commit)
Every produce is fsynced before its ack; group commit coalesces those fsyncs. Two independent knobs, one per path (the clustering guide explains the decision rule with examples):
# rf=1, broker-level. Recommended on for throughput workloads. It writes a segment's primary alone, so it
# applies only with a configured replication factor of 1: a single node runs a one-member cluster with the
# default factor of 3, and sets MALACHI_LOG_REPLICATION_FACTOR=1 to use it (the node warns at boot
# otherwise). The replication-level knob below batches the fsync on every replica instead.
MALACHI_LOG_REPLICATION_FACTOR=1
MALACHI_GROUP_COMMIT=true
MALACHI_GROUP_COMMIT_INTERVAL_MS=5 # flush period; ~the latency each produce pays
MALACHI_GROUP_COMMIT_FLUSH_MAX_RECORDS=8000 # eager flush: bound each fsync even on slow disks
MALACHI_GROUP_COMMIT_MAX_INFLIGHT=200000 # backpressure valve: shed with :overloaded past this
# The produce path has TWO refusals, and a client is meant to tell them apart. :overloaded is the valve
# above, the broker saying it is saturated right now; :rate_limited is the OPT-IN publish quota below,
# this user saying it is over its own allowance. Off by default (0 = no limit); see docs/RATE_LIMITING.md.
MALACHI_PUBLISH_RATE_LIMIT=0 # produce requests per window, per user, PER NODE
MALACHI_PUBLISH_RATE_WINDOW_MS=1000
# rf>1 (replicated), replication-level. Default OFF: enable only for hot-range, fsync-bound
# workloads (many producers per range); on thin-spread loads it lowers throughput.
MALACHI_REPLICATION_GROUP_COMMIT=false
MALACHI_REPLICATION_GROUP_COMMIT_INTERVAL_MS=10 # its own flush period, decoupled from the rf=1 one
# The replication server's minimum heap (words). Every produce batch is encoded and written in that one
# process, and at the VM's default heap it garbage-collects several times per batch; 256K words (2MB per
# node) halves that. Raise it only if a profile shows the server collecting per batch again, e.g. with much
# larger batches; 0 restores the VM default.
MALACHI_REPLICATION_MIN_HEAP_WORDS=256000
# Active-segment roll size (bytes); unset keeps the 64MB default. Smaller segments seal (and become
# independently replicable/repairable units) sooner, at the cost of more metadata churn.
MALACHI_SEGMENT_MAX_BYTES=67108864
Integrity scrub
Every node continuously re-verifies the data it stores. A sealed segment is immutable and nothing re-reads it, so a checksum is otherwise only confirmed when a consumer happens to read that exact record: bit rot there is silent, and silent in the worst way, because a damaged copy answers reads with the records before the damage and nothing after, with no error. A consumer then stalls at that offset or skips the rest of the range.
The scrub walks the node's own sealed segments, checks every record's checksum, and repairs a damaged copy from a replica that still verifies:
MALACHI_SCRUB_ENABLED=true # default; set to false to turn the scrub off entirely
MALACHI_SCRUB_INTERVAL_MS=60000 # time between passes
MALACHI_SCRUB_SEGMENTS_PER_TICK=1 # segments verified per pass
A full cycle takes sealed segments on the node x interval / segments per tick. With the defaults
and 64MB segments a node verifies about 90GB a day, so 10k sealed segments are revisited roughly
weekly, the usual period for disk scrubbing. Raise the interval on a slow or busy disk; lower it
(or raise the per-tick count) to cover a large dataset more often.
What it costs. The scan itself was measured directly at 740 MB/s (64-byte records) to 2.7 GB/s
(1KB records) on one core, so a 64MB segment costs 24 to 86ms of a core, and the shipped cadence
works out to under 0.15% of one core and about 1 MB/s of reads. End to end the effect is smaller
than a benchmark can resolve: on the 3-node Docker cluster (benchmark/docker-scrub.sh, which
interleaves the cases so ordering cannot bias them) both the default cadence and one three thousand
times faster landed inside the machine's run-to-run spread of roughly 15%. Re-run that sweep on
real hardware before raising the rate a lot, and note the honest caveat: those runs had the whole
dataset in page cache, so the scrub was reading RAM. On a node whose data dwarfs its memory the
scan is real disk I/O and competes with the write path.
On detection, the node asks the segment's other replicas to verify their own copies. Only when
one of them confirms a copy that is both intact and complete does the repair proceed: if this
node is the segment's primary it first moves itself to the end of the replica set, so reads go to an
intact replica immediately, and only then is the local copy deleted and refetched, then verified
again. Complete matters as much as intact, because a replica whose checksums all pass can still be
missing whole records at the end, and repairing from one would delete this copy to refetch less than
it held. So a replica qualifies only when its scan matches the record and byte counts the control
plane recorded when the segment was sealed. If no replica qualifies, nothing is deleted and the
failure is logged loudly: a partially readable copy is worth more than no copy. A repair is traced
as malachi.scrub.repair.
Two series to watch, and they answer different questions.
malachi_storage_integrity_failures_total{reason} says whether anything is damaged, and
malachi_storage_scrub_segments_total{result} says whether the scrub is even running: a verified
total that stops advancing means the checking stopped, which the failure counter alone can never
tell you, since it reads zero both when all is well and when nothing is looking. Within that second
series, result="unrepairable" is the one worth paging on: it counts damage the cluster could not
heal by itself, so unlike result="repaired" it does not go away on its own.
Note what the failure counter deliberately does not include: a torn frame at the end of an active segment after a crash. Those bytes were never acknowledged, so recovering past them is routine, and counting it would make an ordinary restart look like corruption at rest. It is still logged. Rot in an active segment, and anything at all in a sealed one, is counted.
A single-node deployment scrubs too, and there the distinction matters more: with no replica there is nothing to repair from, so every finding lands in the unrepairable path with a loud log. That is still the difference between knowing and not knowing that data at rest went bad, and because recovery never truncates a copy damaged by rot (a bad checksum or a mangled frame header), the frames after the damage are still on disk for a manual salvage.
Be precise about which damage that covers, because the two shapes are not treated alike. Rot is left alone whatever the file's state. A torn tail is dropped unless the file carries its own seal marker, and a replica usually does not: that marker is written when the segment's log rolls locally, not when the control plane seals the segment. That asymmetry is deliberate rather than an oversight, since a torn tail has nothing valid after it by construction, so there is nothing to salvage. Rot is the case where the bytes past the damage are still worth keeping.
That holds for an active segment too. A node that restarts onto rot in the segment it is still
writing keeps the damaged frame and everything behind it, and never appends over them: the copy is
taken out of service as a storage failure (malachi_storage_failures_total{reason="other"}, and a
failed in storage log line naming the segment and damaged_tail). What happens next depends on the
replication factor:
- RF 2 or more. The heal pass seals the segment on the intact copies at the furthest end they hold, and writing rolls to a new segment, which is what NorthGuard does when a replica of a segment fails. Producers see nothing. The damaged copy is replaced by self-healing. At RF 2 one intact copy is enough: an acknowledgement needed both copies, so the intact one holds every acknowledged record.
- RF 1. There is no other copy to seal on, so the range stops taking writes until an operator acts. That is deliberate: the alternative was writing new records over ones that may have been acknowledged. Rolling a single-copy segment on without that risk is tracked in #210.
To recover a blocked range by hand, on the node that reported the damage:
- Stop the node and copy the segment's
.logfile somewhere safe. The frames after the damage are still in it. - Either truncate the file at the
positionthe integrity log line reported, accepting the loss of everything from there on, or, with a replica elsewhere, remove this node's copy of the segment directory and let the cluster re-replicate it. - Start the node again.
Chaos certification
scripts/docker-chaos-test.sh runs the certification drill on a local 3-node RF=3 Docker cluster:
synthetic traffic flows while a node is power-pulled (SIGKILL), partitioned off the network, stalled
(SIGSTOP, sockets open but mute), and finally every node is rolling-restarted. Three invariants must
hold or the script exits nonzero:
- No acknowledged write is ever lost. A checker produces sequential values through the whole window, retrying through the faults, and records only the confirmed ones; at the end every one of them must read back (rf=3 quorum durability).
- The cluster reconverges to 3/3 healthy after every event.
- Availability recovers: errors during an event are expected, and a clean produce+fetch must pass once the chaos ends.
Run it before releases or after touching replication, failover, or membership code.
scripts/docker-storage-chaos.sh extends the drill to storage faults, injected with the target
node stopped: a follower's segment copy suffers a torn write (cut short with a garbage partial
frame appended: recovery clamps at the last CRC-valid frame and catch-up or the healing pass
repairs the tail), a gross truncation to half, and a sealed-segment directory deleted outright.
The deletion exercises the self-healing integrity probe: metadata still says the segment has
all its replicas, so only a physical check (on-disk bytes vs the sealed byte size, run each healing
pass) can spot the silent under-replication and re-backfill the copy. On top of the three
invariants above, the storage run requires physical reconvergence: every node's copy of every
chaos-topic segment must be readable and hold the same records, byte for byte over the valid part of
its files, and a sealed segment exactly its sealed length. The files themselves need not match: only
a fenced copy has its preallocated tail trimmed, and each node rolls its internal files at its own
sync points. Corruption always targets follower copies;
corruption of a primary copy is seal-on-failure territory (roadmap). A storage FAILURE is not: when a
write or read fails on a node (a full volume, a failing device), that node stops using the segment's
copy, answers {:error, {:storage, reason}} for it, counts it in malachi_storage_failures_total, and
the healing pass seals the segment on its other replicas so producers move to a new one. From then on
the failed copy counts as a lost replica: the healing pass backfills a replacement on another broker and
deletes the broken copy (which lets that node use the segment id again), and the scrub skips it meanwhile
instead of refetching it onto the disk that failed. With no spare broker the copy stays, behind the
healthy replicas, and the pass reports the segment as {:no_spare_broker, copies}. The drill's
last event fills a node's volume to certify it. In-place corruption that keeps the byte size
(bit rot) and a rotted sparse index are covered too, by the integrity scrub described above. The run
sets MALACHI_SEGMENT_MAX_BYTES and MALACHI_LOG_ROLL_MAX_BYTES low so segments seal and roll
within the window; both knobs are available to any deployment that wants smaller roll sizes.
scripts/docker-config-chaos.sh certifies config deployments, the way this repo deploys them (one
image, config via env): a harmless setting is rolled across the nodes one at a time, requiring the
checker's acks to keep flowing between every step and the new value to be effective on all three
nodes at the end; then a config that fails fast at boot is pushed to a single node, which must
crash-loop and never go healthy while the other two keep serving quorum writes, and rolling the
env back must bring it home to 3/3. The same closing invariants apply: no acknowledged write lost,
full reconvergence, clean produce+fetch after the chaos.
Every drill's closing reconvergence check also compares the control plane across members: every Raft group must hold the same state on every node at equal applied indexes, since members that diverged still answer health checks.
scripts/docker-upgrade-chaos.sh certifies the procedure in
Upgrades and the rollback floor: a cluster rolled from the release
before this code to this code under the machine version pin, back again, then forward, finalized, with a canary
feature switched on and a rollback that must be refused with exit 78.
Retention
Segments are reclaimed by age or total size:
MALACHI_RETENTION_MAX_AGE_MS=604800000 # 7 days
MALACHI_RETENTION_MAX_BYTES=10737418240 # 10 GiB per range
MALACHI_RETENTION_INTERVAL_MS=60000
Leave a limit unset to disable it. With both unset, segments are kept forever unless a topic's own
storage policy says otherwise: the sweep runs either way, and with no bound anywhere it expires nothing,
though it still rolls active segments older than MALACHI_SEGMENT_MAX_AGE_MS (below). Do
not write 0 meaning "unlimited": 0 is a valid budget of zero bytes, and it expires every sealed
segment it can. A bound must be a whole number from 0 through 2^64 - 1: a negative one, or one past
that, stops the node at boot.
Only sealed segments are eligible, so the active segment is never deleted. The byte budget is per range, not per topic or per node. With both limits set a segment goes if either says so.
A segment seals by size (MALACHI_SEGMENT_MAX_BYTES) or by age: the sweep also asks for every active
segment older than MALACHI_SEGMENT_MAX_AGE_MS (7 days unless set, at least 60000) to be sealed, so a
topic too quiet to fill a segment still expires on time. A record lives at most that plus
MALACHI_RETENTION_MAX_AGE_MS, plus up to two MALACHI_RETENTION_INTERVAL_MS (the sweep that asks for
the roll and the one that expires it) and the time the fence takes to answer. See Per-topic retention
for the per-topic override and what each roll costs.
These are the cluster's defaults. A topic bound to a storage policy takes the bounds the policy sets and
inherits the rest; see Per-topic retention for mix malachi.policy, the wire
keys and the dashboard routes, and mix malachi.policy get <topic> to see which value applies and why.
Retention metrics
Retention is observable from both sides: what the sweep deleted, and which readers were moved past data that was no longer there.
The sweep (only the node that leads a vnode sweeps it, so read these summed across nodes):
malachi_retention_sweep_duration_seconds: a histogram of sweep durations, with the same buckets as the flush histogram. Its_countis the number of sweeps this node ran. A_countthat stops advancing on every node means no sweep is running, the question an operator cannot answer otherwise.malachi_retention_segments_expired_total{topic}andmalachi_retention_bytes_expired_total{topic}: what the sweep deleted.malachi_retention_expire_failures_total{reply}: deletes the control plane refused, by reply:migrating(the topic was moving between vnodes),segment_active(still the write head) orother(anything else, a Raft timeout included). A segment the sweep found already gone is neither expired again nor a failure.
The readers:
malachi_retention_skips_total{topic,reader,group,origin,span}: how many times a reader was moved past data no longer stored (expired by retention, or deleted by an operator). Each distinct skip is counted once, however often a group re-reads it before committing.readersays which kind of reader it was:groupnames it ingroup,noneis a fetch outside a group andothera group folded by the label cap, both with an emptygroup. Nothing reserves a group name, soreaderis what keeps a group that calls itself__other__or""apart from those two buckets.origin="cursor"is the one to alert on: a group that held a position and fell behind retention.origin="start"is a reader that had no position, a new group or a range's children after a split, which start over the range's history; retention reaching them is expected.malachi_retention_offsets_skipped_total{topic,reader,group,origin,span}: how many offsets those skips stepped over.span="exact"is exact.span="upper_bound"is a skip over the ancestor of a split range: the count covers the ancestor's whole range, of which this reader would only have received its own key slice.span="unknown"is an ancestor with nothing left stored whose end this node could not recover after a restart, so only the fact of the skip is known and the offsets are 0. It counts offsets, not records lost.
A suggested alert: increase(malachi_retention_skips_total{origin="cursor"}[15m]) > 0, which names the
group that lost data. The first skip of a reader is also logged, then at most once per reader per
MALACHI_RETENTION_SKIP_LOG_WINDOW_MS (10 minutes), with the count of skips held back since the last
line.
group is a label a client chooses, so it is capped: past MALACHI_RETENTION_METRICS_MAX_GROUPS (1000)
topic and group pairs on a node, new groups are folded into reader="other" with no name, and the log
line still names them. The sum over readers per topic stays exact. The skip reporter's memory is bounded the same
way by MALACHI_RETENTION_SKIP_LEDGER_MAX (10000 skips remembered); a forgotten skip read again is
counted again.
The orphan sweep
A replica that does not answer its delete keeps the segment's directory, and no later sweep can ask for it again: a segment gone from the control plane never comes back. A separate worker per node reclaims those directories, and other leaks of the same shape (a catch-up that failed after creating the directory, a copy healing moved elsewhere).
MALACHI_RETENTION_ORPHAN_SWEEP=delete # delete | report | off
MALACHI_RETENTION_ORPHAN_SWEEP_INTERVAL_MS=300000
MALACHI_RETENTION_ORPHAN_MIN_AGE_MS=600000
MALACHI_RETENTION_ORPHAN_SIGHTINGS=2
MALACHI_RETENTION_ORPHAN_MAX_PER_PASS=50
MALACHI_RETENTION_ORPHAN_MAX_TRACKED=10000
It runs on every node, not only the one that sweeps retention: only a node can read its own disk.
A single node is no exception: it runs its control plane as a one-member ra cluster and asks it the
same way. Before a directory can go, the sweep asks the metadata vnode that owns its segment, routed by the
segment's topic like any other command for it, and read linearizably. It does not use this node's
cached copy of the metadata: that copy is refreshed by a reconcile that can fall behind without failing
(a control plane slow enough that every refresh outlives its tick), and a view that aged in silence is
missing exactly the segments whose replicas have just arrived here. A registration commits on its owner
before any replica creates the directory, so the owner's answer has no lag.
The pass is skipped whole when an owner does not answer, when the topology changed while it asked, when there is no ring yet, or when the whole question took longer than its deadline (15 seconds by default; ra follows a leader redirect with a fresh timeout, so the deadline is on the question, not each read). On a sharded cluster the topology comes from the durable ring store, which a vnode split writes its pending intent to before it moves anything, so a node whose gossip lags cannot ask only an owner the topic already left.
A directory whose owner does not list its segment is asked of every vnode before it can go, because a
broker routing by a stale ring can write a topic to a vnode that does not own it (see
docs/ARCHITECTURE.md, "a vnode accepts metadata outside its arc"). So while any vnode is silent, a pass
with a real orphan to decide is skipped, even when the silent vnode owns none of this node's directories.
Some directories are held without counting a sighting: one whose segment id carries no topic (no owner to ask, held for good), one whose topic a vnode split is moving and that neither the old nor the new owner lists at the moment each is read, and one a vnode that does not own it still lists. The log names them when that set changes.
On top of that answer, a directory is removed only after it has been unexplained on SIGHTINGS
consecutive passes and is older than MIN_AGE_MS. Only the MAX_TRACKED oldest candidates are
asked about in one pass, which bounds the work on this node and how many owners are asked; each owner
answers with its whole segment map whatever the count. Candidates left out for a later pass are logged
on every pass that leaves some out; a held pass is logged once, when holding starts; directories kept
as undecided are named when that set changes.
The log data directory belongs to Malachi alone. Do not nest MALACHI_RA_DATA_DIR inside it, and do
not keep backups or hand-made copies there. The sweep only ever considers a directory whose name the
storage layout could have written, so anything else is out of its reach by construction, but that is a
check on the name and not a promise about what may share the directory.
report does everything except the removal, which is how to see the list before trusting it on a
cluster for the first time. off does not even list. A value that is none of the three stops the
node at boot rather than falling back, because the fallback is the mode that deletes and a typo
would have chosen it in silence.
malachi_retention_orphan_directories_left_total{topic}: directories an expire left behind because the replica did not answer. It moves whether or not the sweep is on.malachi_retention_orphan_directories_removed_total: directories the sweep reclaimed. Read against the one above: a gap that keeps growing means the sweep is off, is being held back by a guard, or is not keeping up withMAX_PER_PASS.
Recovering a single node
A single node keeps its metadata in a one-member ra cluster, named malachi_log unless
MALACHI_LOG_CLUSTER says otherwise. ra keys that cluster by the node's name: its log lives under
MALACHI_RA_DATA_DIR/<node name>, and the member is recorded under that name. So a single node keeps
what it acknowledged across a restart as long as three things survive it: MALACHI_LOG_DATA_DIR,
MALACHI_RA_DATA_DIR, and the node name. A release names the node after its host, so a container needs a
fixed --hostname (or RELEASE_NODE); iex -S mix without --name always runs as nonode@nohost.
Before the broker starts, every node checks that its log directory and its control plane describe the
same data, and refuses to start (exit status 78, one REFUSING TO START line) in these cases:
- A log directory of another control plane. A start records the control plane name in the log
directory (
malachi.cluster) once it passes every check, or just before it writes a seed, andMALACHI_LOG_CLUSTERnow names another one. A start refused before its seed is written records nothing. One that fails after the seed check passed (the seed write failed, or the membership check refused) keeps the name: the seed's vnodes carry it, and a write that timed out may have landed. Removingmalachi.clusteris safe only when that seed is known not to have landed (on a cluster,mix malachi.ring --showagainst a running peer says whether a ring is recorded). The ring store and theramembers are keyed by node name, not by cluster name, so the two control planes would mix over the sameradirectory and the sweep could delete the first one's segments, even on a member that holds none of them. Adoption does not apply. SetMALACHI_LOG_CLUSTERback, or form the new cluster with anotherMALACHI_LOG_DATA_DIRand anotherMALACHI_RA_DATA_DIR. A directory written before the marker existed gets one on its next start that passes, so a rename made at the same time as that upgrade is not caught by this check (the other checks still apply). - Segments nobody knows. The log directory already holds segment directories, and the control plane
that would describe them is about to be formed instead of resumed: the ring store was never started
on this node (sharded or not), or the unsharded metadata member of
MALACHI_LOG_CLUSTERwas never started here, or a sharded ring is about to be seeded (every vnode formed now, knowing none of them), or, on a sharded node configured alone, one of its vnodes was never started here. A cluster is not held to the vnode check: a vnode with other replicas keeps its history on them, and a rebalance moves vnodes off a node without republishing the ring. A vnode whose only replica is this node (MALACHI_LOG_VNODE_REPLICATION_FACTOR=1on a cluster) has no such protection. The ring store is checked before the boot starts it and the seed before it is written, so those refusals leave nothing behind; the vnode and member checks run after the ring store is resumed or seeded. The ring store is one perradirectory and node name, whatever the cluster is called, so a newMALACHI_LOG_CLUSTERneeds a newMALACHI_RA_DATA_DIRas well. A control plane formed now would be empty, the orphan sweep would delete every one of those directories, and a topic created again under an old name would read the old bytes as its own. The causes are a changed node name, a lostradirectory, a renamedMALACHI_LOG_CLUSTERover a directory with no marker yet, or segments written by a release that kept a single node's metadata in memory (those are unreachable: the metadata that described them is gone). Bring back the node name, theradirectory or the cluster name. If the directories are not wanted, setMALACHI_ADOPT_ORPHANED_LOG_DIR=true: the node starts, logs the names it adopted, and the orphan sweep removes them on its usual schedule. A cluster member is refused too, since it would come back as a voter with no history; when its peers still hold the control plane (only its ownradirectory was lost), adopting is the normal way back: the sweep asks the owners and removes only what none of them lists. A node that joins for the first time has nothing on disk to refuse. - A single node told it has peers. The control plane was started on this node as a one-member
cluster (sharded or not), and
MALACHI_LOG_NODESnow lists other nodes. Growing a single node into a cluster in place is not supported yet: this node would come back alone while the others formed a second cluster under the same name. Remove the other nodes, or form the new cluster under anotherMALACHI_LOG_CLUSTERwith anotherMALACHI_LOG_DATA_DIRand anotherMALACHI_RA_DATA_DIR, and move the data with a client. Pointing a new cluster name at the old log directory is the first case above, and refuses. - A switch to sharding. The control plane already ran unsharded on this node, and
MALACHI_LOG_VNODESnow asks for vnodes. Converting it in place is not supported: the sharded ring would outrank the environment from then on and the topics' current metadata would never be read again, so the orphan sweep would delete their segments. The node refuses before it writes the sharded ring, so removingMALACHI_LOG_VNODESstarts it unsharded again. To shard, form a new cluster under anotherMALACHI_LOG_CLUSTERwith anotherMALACHI_LOG_DATA_DIRand anotherMALACHI_RA_DATA_DIR(there is one ring store perradirectory and node name, whatever the cluster is called), and move the data with a client. - A sharded ring over an unsharded control plane. The control plane ran unsharded on this node,
and a sharded ring is already recorded for it: written by a peer that never ran unsharded (one listed
but never started), by an earlier release, which converted a control plane in place when
MALACHI_LOG_VNODESwas set, or by an earlier release on this node under anotherMALACHI_LOG_CLUSTERover the sameradirectory. That ring outranks any setting, so the node refuses on every start and there is no way back in place. A cluster converted that way by an earlier release has to be formed again under anotherMALACHI_LOG_CLUSTER,MALACHI_LOG_DATA_DIRandMALACHI_RA_DATA_DIR; its unsharded topics were most likely swept already, and keeping the old directories until the data is no longer wanted costs nothing. - A membership that cannot be read. The same check needs the ring store member's recorded
membership, and the member did not report it within
MALACHI_LOG_RING_BOOT_TIMEOUT_MS(a long log replay, or a member that cannot come back). A check that cannot be made does not read as passed. Look at the node'sralog, or raise the timeout.
MALACHI_DATA_SHARDS above 1 with no MALACHI_LOG_CLUSTER is a throughput measurement mode, not a
deployment: each shard keeps its metadata in memory, nothing written survives a restart, and it runs no
orphan sweep. The node logs a warning saying so at boot.
When a topic's policy cannot be read
Retention resolves a topic's policy against the cluster's policy store on every sweep. Two cases stop it expiring, both on purpose, because expiring under the cluster-wide limits instead would delete exactly the data a more permissive policy exists to keep, on every replica, with no way back.
- The store did not answer. The whole sweep is skipped and one line says so. Nothing else is
needed to notice:
malachi_retention_sweep_duration_seconds's_countstops advancing, which is the signal this guide already tells you to alert on. - The topic points at a policy name the store does not define, because it was deleted with
--forceor a bind raced a delete (both refused without it). That topic expires nothing, andmalachi_retention_unresolved_policy_sweeps_total{topic}names it on every sweep.
The second case holds disk until someone fixes the binding. Bound how much with:
MALACHI_RETENTION_UNRESOLVED_POLICY_MAX_AGE_MS=604800000 # 7 days; unset means keep everything
Unset by default on purpose: that bound is a retention decision, and the only person who can make it is the one who wrote the policy the binding points at.
These names are reserved for later retention work and not emitted yet:
malachi_retention_segments_pinned{topic,group} (consumer-aware retention) and
malachi_segment_rolls_total{reason} (time-based rolls).
TLS
MALACHI_ENABLE_TLS=true
MALACHI_REQUIRE_TLS=true
MALACHI_TLS_CERTFILE=/etc/malachi/server.pem
MALACHI_TLS_KEYFILE=/etc/malachi/server-key.pem
MALACHI_TLS_CACERTFILE=/etc/malachi/ca.pem
MALACHI_TLS_VERIFY=verify_peer
MALACHI_TLS_VERSIONS=tlsv1.3,tlsv1.2
MALACHI_ENABLE_TLS offers TLS; MALACHI_REQUIRE_TLS refuses plaintext.
In production both are on unless you turn them off. REQUIRE_TLS defaults to true under
MIX_ENV=prod, and ENABLE_TLS simply follows it. So the risk here is not forgetting to enable TLS, it
is the opposite: setting MALACHI_REQUIRE_TLS=false to get past a certificate problem and leaving it
that way, which disables both at once. Outside production both default to off.
Invalid TLS configuration raises at boot in production rather than starting insecurely; in dev and test it only warns.
Upgrades and the rollback floor
An upgrade has two floors, and a rollback has to clear both: the on-disk format below, and the control-plane machine version further down. They move independently, so check them separately before rolling a release back.
The on-disk format
The root of the log data directory (MALACHI_LOG_DATA_DIR) holds a small text file, malachi.format:
format=1
written_by=0.12.0
requires=0.12.0format is the on-disk format the directory holds, written_by the release that wrote the file, and
requires the oldest release that can read that format. With several data-plane shards, the one file at
the root covers every shard_<n> subdirectory.
The rule: a release starts on a data directory only when format is at or below the highest format it
understands. Otherwise it refuses to start before opening anything, because an older release does not
know a newer frame, would read it as damage, and could write over records that were acknowledged.
What that means for a rollback:
- A directory without the file (a fresh node, or one last written by a release older than 0.12.0) gets one at baseline format 1 on the first start. Starting a newer release does not raise it, whatever format that release can read.
- The format rises only when a format-changing feature is switched on for the cluster. Until then, a rollback to any release from 0.12.0 on is free.
- After a format-changing feature is switched on, the floor is the release named in
requires. Rolling back below it is refused. - Never roll back below 0.12.0 once any format above 1 exists. Releases before 0.12.0 do not read the file at all, so nothing stops them from opening newer data, and that is exactly the overwrite this file exists to prevent.
- Do not delete or edit the file to get a node to start. It is the only thing between an older release and data it cannot read.
A refused start exits with status 78 and logs one line that begins with REFUSING TO START (exit 78):
and names the directory, both format levels and the release to start instead. The same line is printed
on stderr, so it reaches container logs even when the logger has not flushed. The same status covers a
marker that cannot be parsed (restore it from a backup or another node) and one that cannot be read or
written (fix the volume or its permissions), and a node whose build cannot honour a cluster flag that is
already on (see Cluster flags).
A service manager that restarts on failure will restart a refused node again and again. Tell it not to:
- systemd:
RestartPreventExitStatus=78. - Docker Compose:
restart: on-failurerestarts on any non-zero status, so bound it (restart: on-failure:5) and alert on exit code 78 (docker inspect -f '{{.State.ExitCode}}'). - Kubernetes: the pod goes to
CrashLoopBackOff; the last terminated state shows exit code 78 and the log line above.
The control-plane machine version
The control plane (topic metadata, the lease, the ring, users, lockouts, ACLs, cluster flags and storage policies) lives in Raft groups whose state machines carry a version. A group moves to a new version only once every member runs code that supports it, and a command a release introduces is refused, the same way on every member, until then. So a cluster in the middle of a rolling upgrade cannot end up with members that disagree about its state.
The version switch happens on its own when the last node has been upgraded. From that moment, a node started
on the previous build stops applying the control-plane log instead of diverging from it: it stays up, but it
falls behind, and it logs stopped applying entries and emits the [:malachi, :ra, :machine_version]
telemetry event with stuck: true until it is upgraded again. That is this floor: it moves with the
machine version, not with the on-disk format above.
To keep rolling back possible until you are satisfied with a release, hold the version with a pin:
- Set
MALACHI_RA_MACHINE_VERSIONon every node to the version the cluster runs now, and roll the new build out node by node. The group stays at the pinned version, and any node can go back to the previous build. - When the release is proven, finalize: remove
MALACHI_RA_MACHINE_VERSIONand restart the nodes one at a time. The groups switch to the new version once the last node is back, and the rollback floor moves up.
The first release that versions these machines is version 1, and every earlier build counts as version 0.
Version 2 adds the cluster flag store's enable_flag command. Version 3 adds the storage policy
store's define_policy and delete_policy. Version 4 adds the metadata command that binds a topic to
a storage policy (bind_topic_policy), so mix malachi.policy bind is refused, with a message saying to
finish the rolling upgrade, until every node runs a release with version 4 and the pin is lifted. Version
5 adds the console roles to the user store (set_role, creating a user with a role, and importing users
with roles): until the cluster reaches 5, assigning a role is refused on every surface, as HTTP 409
errors.cluster.upgrade_pending on the dashboard and the console and as the same "finish the rolling
upgrade" message over the wire and in mix malachi.user. A default user that carries a role is not created,
while creating a user without one keeps working. node scripts/user.js create --role creates the user and
then sets the role in a second request, so on such a cluster it leaves the user created without a role. Finalizing it gives up rolling back below the release that
introduced the roles. Each time, the machines that gained nothing move with it at no cost, and a member on
the older build refuses the new command until the whole group has moved.
The same rule applies at every step: to keep a rollback to the previous build possible while you roll
out, upgrade with MALACHI_RA_MACHINE_VERSION set to the version the cluster runs now, and finalize
afterwards.
A malformed value (anything but a non-negative integer) stops the node at boot. Silently dropping the pin would finalize the upgrade, so it is treated as an error. A pin above the version a build implements has no effect.
Cluster flags
Finishing a rolling upgrade and committing to a new behaviour are two different moments. A cluster flag is the second one: an operator switches a feature on, once, for the whole cluster, after every node runs a build that can handle it. Until a flag is on, every node keeps to the old behaviour and a rollback stays free.
mix malachi.flag --list
mix malachi.flag enable <flag>Both run against a running node over Erlang distribution, the same way mix malachi.ring and
mix malachi.reshard do (--node, --cookie).
Each node advertises the set of flags its build can honour, through the same SWIM gossip that carries
liveness. Switching a flag on is refused unless every node listed in MALACHI_LOG_NODES is alive and
advertises that flag, and the refusal names the ones that are not:
these nodes are not alive or do not support that flag: malachi@node3. Upgrade them, or take them
out of MALACHI_LOG_NODES, then run this againA node that is down counts as not supporting it. Upgrade it, or take it out of MALACHI_LOG_NODES on
every node, before switching the flag on.
Two things follow from a flag being permanent:
- There is no command to switch one off. Once a feature is on, peers and stored data may already be in the new shape, and going back is a migration, not a toggle. Decide before you enable, not after.
- A node that cannot honour an enabled flag refuses to start, with exit status 78 and a line naming the flag and what the build advertises. In practice that means a node brought back on a build from before the feature: upgrade it rather than trying to start it again.
Why a node remembers its own incarnation
Each node keeps one number in malachi.incarnation, at the root of the log data directory beside
malachi.format. It is how a restarted node outranks what its peers still remember about it.
Membership gossip resolves conflicting news about a member by that number, and a member is the only one who raises its own. A node that came back counting from scratch would announce itself below what its peers hold, every peer would ignore the announcement, and they would keep the attributes they had, including the capability list. A node brought back on an older build would then still be counted as supporting a feature it no longer does.
Nothing is asked of you for this, including on the upgrade that introduces the file. A node coming from a build that never wrote one has no record to read, while its peers may remember it well above zero, because the old build raised its incarnation in memory on every refutation and every attribute change. So the first reservation starts above the current second rather than at 1. An incarnation counts refutations, which reaches tens or hundreds; seconds since 1970 do not, so the upgraded node outranks anything the old build could have reached. There is no procedure here, and that is deliberate: the number it would have to be given is whatever the peers happen to remember, which is not visible from the node being started.
The clock is only a floor. A recorded ceiling higher than it still wins, so a clock moving backwards cannot lower a node.
It matters only when it goes wrong: a node whose incarnation cannot be reserved does not start, and one that later cannot record a new ceiling stops itself so it can reserve a fresh one on the way back up. Both say so on one line. Starting anyway would be worse than not starting, because the node would be ignored by its peers with no way back: they would keep the record they have, and since the node answers their pings they never suspect it, so nothing ever provokes the refutation that would lift it.
Do not delete the file while the node is stopped, and copy it along with the data directory if you ever move one. The clock floor makes a lost file survivable rather than fatal, which is not the same as a licence to remove one: a node restarted within minutes of losing it can still come back below where it already was.
What a node does with the flags at boot
A node always starts, and it always serves. Nothing about the flags holds it back or takes it out of rotation. What the flags decide is when it stops:
| At boot | What the node does | What to do |
|---|---|---|
| The flags are readable and this build supports every one that is on | serves, with the enabled features | nothing |
| The flags are readable and one names something this build does not support | exits 78, naming the flag | upgrade that node; restarting the old build will not help |
| The flags cannot be read yet | serves with the previous behaviour, and reads again on the next tick | nothing, if a rolling upgrade or a cluster restart is in progress; otherwise look at why the flag store has no quorum |
The third row is the one worth understanding. Waiting there, either by refusing to start or by reporting itself unready, sounds safer and is not: the flag store cannot reach a quorum until enough nodes run a build that has it, so the first node of a rolling upgrade would be waiting for a cluster that is waiting for it, and the upgrade would stall on its first step. Serving the previous behaviour is safe in the meantime because a flag is only ever switched on once every node already supports it, so the previous behaviour is one every node in the cluster still understands.
Nothing is given up by that. A node that cannot honour an enabled flag still exits 78 the moment it reads one, and a node whose data directory was written in a format this binary cannot read is stopped earlier and synchronously by the format marker above, which needs no quorum and no peers.
A flag flip is also what raises the on-disk format above the baseline, so the rollback floor described under The on-disk format moves at that moment and not at any other.
Before you go to production
The checks that catch the common mistakes:
- [ ] Passwords set explicitly. Production requires
MALACHI_ADMIN_PASSand friends via environment;no credentials ship in the base config. The dev defaults (`admin123`) exist only in `dev.exs` and `test.exs`. - [ ]
MALACHI_REQUIRE_TLSnot set tofalse. It defaults to true in production, so the check isthat nobody disabled it while debugging certificates. - [ ] Dashboard not publicly reachable, and every operator account holding the least console role it
needs (`viewer` to read). See [console roles](authentication.md#console-roles). - [ ]
MALACHI_LOG_REPLICATION_FACTORat least 3 if you want to survive a node loss. With 2, quorumis 2, so losing either replica stalls writes. - [ ] Retention configured. The default keeps everything, and the disk fills quietly.
- [ ] Readiness probe on
/ready, not/health. - [ ] You know your rollback floor. Read Upgrades and the rollback floor
before the first upgrade, and make your service manager stop restarting on exit status 78. - [ ] The upgrade you are about to make was certified. Releases are tagged on every merge and are not
held back for it, so check it yourself: the [Rolling upgrade certification results](../generated/chaos-upgrade-results.md) page shows the last nightly run, which rolls a cluster from the newest release whose code differs from main's to main's code and back under load, following the procedure in [Upgrades and the rollback floor](#upgrades-and-the-rollback-floor). Its record names both releases and the commit it measured. For another pair, run the workflow by hand with `old_ref` (see [Running the chaos drills](running-chaos-drills.md#rolling-upgrade-and-rollback)). - [ ]
MALACHI_LOG_NODESlists exactly the nodes you run. A node left in the list that is not runningblocks every cluster flag, and one missing from it is not counted when a flag is switched on. - [ ] Your service manager treats exit 78 differently from an ordinary failure. Exit 78 means the
node cannot run with the binary it was given, so restarting it only hides that; an upgrade is what helps. systemd can stop outright (`RestartPreventExitStatus=78`); Compose cannot tell the two apart, so bound the retries (`restart: on-failure:5`) and alert on exit code 78. - [ ]
malachi_domain_violationsalerted on. - [ ] If you use ACLs,
MALACHI_ACL_STRICT=true. Without it grants are inert and global permissionsstill allow everything. See [Per-topic ACLs](per-topic-acls.md).
Docker
Images are published multi-arch. See Running with Docker for compose files and the environment matrix.
Next
For growing the cluster, see Clustering and re-sharding.