HermesMQ
The simple af, performant, durable Kafka-like Raft-replicated message queue.
Status: in production
Features
- Easy, fast cluster setup — client-driven bootstrap (cluster forms from the node list the client supplies)
- Ordered message log per topic (Kafka-like), payloads replicated through Raft
- Consumer groups — one consumer per message within a group; fan-out across groups
- Two consume styles — server push (
subscribe, no client loop) or pull (poll, long-poll) - Per-group consumer offsets + in-flight (leased) tracking with visibility timeout
- At-least-once (default) or at-most-once via per-subscription
ack_mode;ack/nack+ redelivery - Consumer-side dedup on
subscribe— slow handlers get lease auto-refresh instead of same-connection duplicates; late acks still count - 8-level priority with reserved-fraction anti-starvation
- Cluster-wide rate limits (token bucket per topic;
ratemay be < 1/s) — paces delivery only; produce is never throttled, the backlog absorbs bursts - Retention by message count and/or age
- Idempotent produce (dedup by
producer_id+seq) - Configuration via the client (topics, rate limits, retention)
- Observability —
/health,/ready, Prometheus/metrics - Pure-Rust build — no
protoc(protobuf codegen viaprotox+prost-build)
Highly subjective performance (0.2.0)
...testing on local machine, w/o network, etc. bottlenecks

The chart is regenerated from the measured numbers on every cargo perf run.
Workspace layout
| Crate | Role |
|---|---|
crates/hermesmq |
Umbrella library — re-exports hermesmq-core (cargo add hermesmq) |
crates/hermesmq-proto |
Protobuf wire types (prost), shared by server and clients |
crates/hermesmq-core |
Storage (redb), Raft engine, queue state machine, TCP/protobuf + HTTP servers |
crates/hermesmqd |
The server daemon binary |
Build
Run a node
A freshly started node waits for a client to bootstrap it. For a multi-node cluster, start each
node (no special flags), then have a client send a Bootstrap with the full node list (the Node
addon's connect() does this automatically).
| Flag | Env var | Default | Purpose |
|---|---|---|---|
--node-id |
HERMESMQ_NODE_ID |
1 |
Unique node id |
--data-dir |
HERMESMQ_DATA_DIR |
data |
redb data directory |
--client-addr |
HERMESMQ_CLIENT_ADDR |
127.0.0.1:7600 |
Client protobuf/TCP listener |
--peer-addr |
HERMESMQ_PEER_ADDR |
127.0.0.1:7700 |
Inter-node Raft RPC listener |
--metrics-addr |
HERMESMQ_METRICS_ADDR |
127.0.0.1:9600 |
HTTP /health /ready /metrics |
--metrics-enabled |
HERMESMQ_METRICS_ENABLED |
true |
false disables Prometheus /metrics (/health and /ready stay on) |
Every flag can also be set via its environment variable; a CLI flag takes precedence. The Docker
image bakes in container-appropriate defaults (0.0.0.0 listeners, /data data dir), so a
container only needs HERMESMQ_NODE_ID.
Environment variable RUST_LOG controls log verbosity (e.g. RUST_LOG=info). Example - RUST_LOG=info,openraft=warn
Run a 3-node test cluster with Docker
This starts hermesmq1/2/3 (client ports 7600/7601/7602, metrics 9600/9601/9602); a client
bootstraps them with the peer addresses (hermesmq1:7700, …).
Node.js client
connect(nodes) returns a Client and auto-bootstraps the cluster from the node list. Every
method takes a single options object and returns a Promise.
import from "hermesmq-node";
const client = await ;
Topics vs. consumer groups
You produce to a topic — there is no group on the produce side. A consumer group is a
consume-side label: each group reads the whole topic independently (fan-out across groups), while
consumers within one group split the messages between them (work queue). Groups aren't created
explicitly — a group springs into existence the first time you poll with that name ("workers"
below is just a name you picked). Same split as Kafka: produce → topic; consume → topic + group.
Methods
createTopic(options) — create a topic (idempotent) and configure it. rateLimit and
retention are per-topic and optional; set them here once, not on every publish. The rate
limit applies to delivery (poll/subscribe), never to produce: bursts queue up and drain to
consumers at ratePerSec.
await client.;
produce(options) → offset — append a message to a topic; returns the assigned offset (string).
priority is per-message (each message carries its own). Optional producerId + seq (a
per-producer monotonic counter) make retries idempotent: a re-send with the same pair returns the
original offset instead of appending a duplicate. All produces share one pipelined connection to
the leader (up to 32 in flight) and are group-committed, so concurrent produces scale to thousands
of msg/s while a serial await loop is bound to one Raft round per message.
const offset = await client.;
produceMany(items) → results[] — produce a batch concurrently through the pipeline; returns
per-item { offset?, error? } aligned with the input, so partial failures are visible. Pair with
producerId/seq and retry only the failed items with their original seqs — items that already
committed dedup to their original offsets.
const results = await client.;
poll(options) → messages[] — lease up to max deliverable messages for a (topic, group).
With waitMs > 0 it long-polls: the server parks the request (no Raft writes while idle) and
returns as soon as a message is available, or empty after waitMs. With waitMs = 0 it returns
immediately. Each message is leased for visibilityMs; ack before it expires or it is redelivered.
const msgs = await client.;
// each: { leaseId, offset, priority, contentType, payload: Buffer, tsMs } (ids are strings)
subscribe(options, onMessage) → Subscription — server-driven push: the leader streams
deliverable messages to your handler as they arrive (priority-ordered, no client loop, no idle Raft
writes). Returns a Subscription with unsubscribe(). onMessage may be async.
const sub = await client.;
// later:
sub.;
ack(lease) — mark a leased message done so it is not redelivered.
await client.;
nack(lease) — release a lease now so the message is redelivered immediately (don't wait for the timeout).
await client.;
stats() → { lastApplied, currentLeader } — Raft applied index + current leader node id.
const = await client.;
bootstrap() — (re)form the cluster from the node list. connect() already calls it; only needed
to re-bootstrap manually. Idempotent.
await client.;
Writing a consumer
Push (recommended) — subscribe: the server streams messages to your handler. No loop, no
busy-spin, no Raft writes while idle. With ackMode: "manual" (default) the message is acked
after onMessage resolves and nacked if it throws (redelivered) — at-least-once. Up to
prefetch messages are processed concurrently, so one slow/stuck handler doesn't block the others.
const sub = await client.;
// sub.unsubscribe() to stop.
For at-most-once push, pass ackMode: "auto" (acked on delivery; a crash mid-handler drops the message).
Pull (alternative) — long-poll with waitMs: the call blocks server-side until a message
arrives, then you ack/nack yourself. Useful when you want explicit control over fetching:
while
The client auto-discovers the leader (rotates through nodes on not_leader/unreachable). 64-bit ids
(offset, leaseId, tsMs) are returned as strings to avoid JS 2^53 precision loss.
Protocol
Length-prefixed frames (u32 big-endian length + Protobuf body). One Request/Response envelope
(hermesmq.proto); the message payload is an opaque bytes field. Ops: bootstrap, produce,
poll, subscribe, ack, nack, commit, create_topic, delete_topic, set_rate_limit,
set_retention, stats. subscribe takes over its connection: the leader pushes Delivered
frames and reads ack/nack frames back on the same socket. Inter-node Raft RPC uses the same
framing with postcard-encoded openraft messages (one persistent connection per peer).
Server-side limits and defaults (applied when a field is 0): produce payloads are capped at
1 MiB (payload_too_large otherwise), poll max defaults to 16 (capped at 1024),
visibility_timeout_ms defaults to 30 000, wait_ms is capped at 300 000, and priority is
clamped to 0–7. not_leader errors include the current leader's peer address in leader_addr
when one is known.
Requests may be pipelined: a client can send further frames without waiting for responses
(up to 32 are processed concurrently per connection; beyond that, TCP backpressure applies).
Responses are always written in request order — there are no request ids. Pipelined produces are
processed concurrently, so their offsets may not match submission order; don't pipeline a
long-poll ahead of requests whose responses you need promptly. A subscribe frame waits for all
pending responses to drain, then takes over the connection as before.
Concurrent produces (pipelined or across connections) are group-committed: the node coalesces them into a single replicated log entry and one fsync, so produce throughput scales with the number of in-flight requests instead of paying a full Raft round per message. A produce only ever returns after its batch is durable and replicated — semantics are unchanged.
Observability
GET /health→200 ok(liveness)GET /ready→200if the node sees a leader, else503(readiness)GET /metrics→ Prometheus text: Raft term/leader, last-applied, last-log-index, replication lag, topics, messages, in-flight. Disable withHERMESMQ_METRICS_ENABLED=false(or--metrics-enabled false) — the endpoint then returns404while/healthand/readykeep working.
Delivery semantics
- At-least-once (default):
subscribe(push) acks after your handler resolves, orpoll+ack(pull). If a lease's visibility timeout expires without an ack, the message is redelivered — so consumers must be idempotent. Both paths preserve priority ordering and per-(topic, group)redelivery. - Consumer-side dedup (
subscribe): while a subscription connection is alive, a message whose visibility timeout expires mid-handler is not re-pushed to that connection — the server auto-refreshes the lease (up to 2 times) and a late ack for the original lease still completes the message. After the refresh cap (~3×visibilityMs) the message is redelivered as usual, and if the connection dies its leases expire normally — at-least-once is preserved, so consumers must still be idempotent across reconnects and consumer failover. Pull (poll) consumers can dedup byoffset. - At-most-once:
subscribewithackMode: "auto", orpollwithackMode: "auto"— acked on delivery. - Dedup: provide
producer_id/seq; re-sends within the dedup window return the original offset. - Quorum: a 3-node cluster tolerates 1 failure for full read/write/consume availability; losing 2 stops writes by design (no split-brain). Run 5 nodes to tolerate 2 failures.
Testing
cargo test covers: queue semantics (unit + a proptest property), the real TCP/protobuf protocol,
3-node replication, leader/follower loss, quorum-loss safety, network partition + heal, on-disk
restart durability, slow-disk tolerance, and the HTTP endpoints.
End-to-end (Docker)
This is one test that runs a full cluster lifecycle (so the runner reports 1 passed —
that's expected), printing its progress step by step ([e2e 12.3s] ...): it builds the image,
starts a dedicated 3-node compose cluster (docker-compose.e2e.yml, host ports 17600-17602 /
19600-19602), bootstraps it over the wire, exercises produce/dedup/priority/poll/ack, kills the
leader container, verifies failover and that un-acked messages survive, restarts the killed node,
waits for catch-up, checks /metrics, and tears the cluster down (also on failure). Requires
Docker with compose v2. The first run builds the image and can take several minutes; later runs
reuse the Docker cache. (cargo e2e is an alias from .cargo/config.toml for
cargo test -p hermesmq-core --test e2e_docker -- --ignored --nocapture.)
Performance
Prints throughput and latency percentiles, and asserts loose floors (release builds only) to catch
catastrophic regressions: queue state-machine ops, sequential / concurrent / pipelined produce
against a single fsync-backed node, poll/ack drain, subscribe push, produce-to-delivery push tail
latency (p50/p99/p99.9), and 3-node replicated writes.
(Alias for cargo test -p hermesmq-core --release --test perf -- --ignored --nocapture --test-threads=1.)
Caveats
- The TCP and peer-RPC ports have no auth/TLS — only run on a trusted/private network. Add a token + TLS before any untrusted exposure, and firewall the peer port to cluster hosts only.
- Memory is reclaimed two ways. Messages that every group has acked past are dropped automatically (consumption-based), so a fully-drained topic costs nothing. On top of that, retention is Kafka-style: it drops messages by age/size regardless of consumption, so a lagging group can lose un-consumed messages. Set generous retention if that matters.
- A topic that sets no retention still gets a default safety cap of 1,000,000 messages, so an
un-consumed (or never-acked) topic can't grow unbounded in RAM. Set explicit
retentionto raise, lower, or age-bound it. - Reads (
poll) go through the leader; followers redirect.
License
GPL-3.0-or-later