Operations & monitoring
Persistence, queues and limits, statistics, and logs.
Persistence
[storage] backend = "sqlite" path = "/var/lib/mercurio/mercurio.db"
Or, in a PostgreSQL database:
[storage] backend = "postgres" url = "postgres://mercurio:password@localhost/mercurio"
With backend = "memory", the default, everything is lost when the broker stops. With SQLite or PostgreSQL, the broker keeps:
- sessions and their subscriptions, with their expiry;
- retained messages, with the client that published them;
- wills, including those delayed with Will Delay Interval;
- messages queued for disconnected clients with persistent sessions, and messages in flight.
After a restart, clients with persistent sessions resume where they left off: their queued messages are delivered, and unacknowledged ones are sent again. Sessions that expired while the broker was down are discarded, and their wills published.
Changes are written in the background, in order for each client and in batches, so storage doesn't slow clients down. They reach the database within milliseconds: a broker that crashes loses only the changes of its last moments, and one that shuts down writes everything first. When storage falls behind, by 10,000 changes for one client, that client waits for it. Changes that fail because the database is unreachable, busy or out of connections are retried until it recovers; changes the database refuses are given up after five attempts, with an error in the log.
The SQLite database runs in WAL mode: while the broker runs, it keeps mercurio.db-wal and mercurio.db-shm next to the database, and recent changes may be only in the -wal file. To back it up while the broker runs, use sqlite3 mercurio.db ".backup copy.db"; a plain copy of mercurio.db alone can miss those changes. The broker creates the database readable by its owner only, and holds a lock on mercurio.db.lock while it runs: a second broker started on the same database exits with an error, while reading it with sqlite3, as for a backup, still works. The database records its schema version, and a broker refuses a database written by a newer version of it.
PostgreSQL needs version 12 or later. Connect the broker to it directly: PgBouncer drops the connection settings the broker relies on, and in transaction mode the lock described below. The broker creates its tables, and updates them when a new version needs it; it refuses a database updated by a newer version.
Standby brokers
Only one broker serves from a PostgreSQL database at a time, since two would overwrite each other's sessions. A broker started on a database another one is using exits with an error, unless it is a standby:
[storage] backend = "postgres" url = "postgres://mercurio:password@db.example.com/mercurio?sslmode=require" standby = true
A standby waits without listening on its ports. It takes over as soon as the serving broker's process stops, or about 11 seconds after the database last heard from the serving broker's host, and serves every session from the database. Point clients at both brokers, through a load balancer or a list of addresses, so they reconnect to whichever serves.
A standby updates the tables only once it takes over, so one running a newer version can wait next to an older serving broker; a standby older than the tables exits at once. Its wait is not cut short by a statement_timeout, lock_timeout or idle_session_timeout set on the database role, as managed PostgreSQL services often do.
A broker that can't reach the database for about 7 seconds stops serving and exits with an error, because a standby may take over. With standby = true and the provided systemd unit, it restarts as the standby. Each takeover is recorded in the database, which refuses writes from brokers that served before it, even those delayed in the network, so a replaced broker can't overwrite the new one's sessions. A broker that has a write refused this way stops at once, as when it loses the database.
Queues and slow clients
Each session has its own queue, so a slow or offline client never makes others lose messages. When its queue is full, further messages for that client are dropped:
| Option | Default | Drops |
|---|---|---|
max_queued_qos0_messages | 1000 | QoS 0 messages once this many messages are queued |
max_queued_messages | 10000 | any message once this many are queued |
max_queued_bytes | unlimited | any message once the queued payloads take this many bytes |
QoS 0 messages, which their publisher accepted could be lost, go first. The broker logs one warning when a queue starts dropping and another, with the count, once it has room again.
MQTT 3.x clients connecting without clean session keep their session forever. persistent_client_expiration bounds it, so devices that are reinstalled with a new client id don't leave sessions behind:
[server]
persistent_client_expiration = 604800 # one week
Protecting the broker
| Option | Protects against |
|---|---|
max_connections, per-listener max_connections | Connection floods |
connect_timeout | Connections that never send CONNECT, or stall during TLS |
max_packet_size | Oversized packets; MQTT 5 clients are told the limit |
max_keepalive | Clients asking for very long or no keep-alive, which keeps dead connections around |
receive_maximum | Too many QoS 2 messages awaiting release |
max_inflight | Clients that read messages and never acknowledge them, each held in memory until they do |
max_qos, retain_available | Costly QoS 2 traffic, or retained messages you must not keep |
clientid_prefixes | Clients outside your naming scheme |
$SYS statistics
Every sys_interval seconds (10 by default), the broker publishes retained statistics under $SYS/broker/, with mosquitto's names, so dashboards built for mosquitto work unchanged. Retained $SYS messages, bridge states included, are kept in memory: a new subscriber gets the latest values, but they are not written to storage and don't count toward max_retained_messages or retained messages/count.
recado sub -t '$SYS/broker/#' -T
| Topic | Value |
|---|---|
version | Broker version |
uptime | Seconds since start |
clients/connected | Clients connected now |
clients/disconnected | Sessions kept for clients that are not connected |
clients/total | Sessions, connected or not |
clients/maximum | Most clients connected at once |
clients/expired | Sessions ended by their expiry |
messages/received, messages/sent | PUBLISH packets since start |
bytes/received, bytes/sent | Bytes since start |
retained messages/count | Retained messages stored, $SYS ones not counted |
subscriptions/count | Subscriptions |
publish/messages/dropped | Messages accepted from a publisher and lost before reaching a subscriber, since start |
publish/messages/dropped/queue_full_qos0 | QoS 0 messages that found a session's queue at max_queued_qos0_messages |
publish/messages/dropped/queue_full | QoS 1 and 2 messages that found a session's queue at max_queued_messages or max_queued_bytes |
publish/messages/dropped/expired | Messages whose expiry interval passed while they waited in a queue |
publish/messages/dropped/too_large | Messages larger than the subscriber's Maximum Packet Size |
publish/messages/dropped/not_authorized | Messages on a topic the subscriber may not read, although its subscription matched |
publish/messages/dropped/no_share_member | Messages no member of a shared subscription's group could take |
storage/batches/retried | Batches of session changes written again after a storage error, since start |
storage/batches/dropped | Batches of session changes storage refused for good since start; the clients they belonged to are written whole again once storage takes it |
storage/clients/dirty | Clients whose stored state differs from the broker's until it is written whole again |
storage/sessions/set aside | Stored sessions that couldn't be read when the broker started, left in storage untouched; see below |
load/messages/received/1min, 5min, 15min | Messages received per minute, averaged over 1, 5 and 15 minutes |
load/messages/sent/…, load/bytes/received/…, load/bytes/sent/…, load/connections/… | The same for messages sent, bytes and new connections |
connection/<client id>/state | 1 or 0 for each bridge |
Clients may subscribe to $SYS topics, subject to ACLs, but never publish to them. Wildcards at the start of a filter, such as # or +/broker, don't match $SYS topics.
A stored session that can't be read when the broker starts, such as one written by a newer version before a downgrade, or a damaged row, doesn't keep the broker from starting. The broker logs an error naming the client and what failed, leaves the session's rows in storage as they are, and counts it in storage/sessions/set aside. Its client is refused with Server Unavailable until the session is deleted with mercurioctl client delete-session <client id>, after which it connects with a new session.
Health and metrics
A WebSocket listener with health = true answers GET /healthz with 200 OK while the broker serves, and 503 Service Unavailable to a request read as it starts stopping; once it has stopped, the listener refuses connections. With metrics = true it answers GET /metrics with the counters of $SYS in the Prometheus text format, read at the time of the request whatever sys_interval is. Both are off by default and need no http_dir.
Metrics tell anyone who can reach the listener how many clients the broker has and how much traffic it carries. They name no clients or topics, but enable them on a listener of their own on a loopback address, or one only your monitoring network reaches:
[[listeners]] host = "127.0.0.1" port = 9883 protocol = "websocket" health = true metrics = true
$ curl -s http://127.0.0.1:9883/metrics # HELP mercurio_clients_connected Clients connected now # TYPE mercurio_clients_connected gauge mercurio_clients_connected 12 … # HELP mercurio_messages_received_total PUBLISH packets received # TYPE mercurio_messages_received_total counter mercurio_messages_received_total 48210 … # HELP mercurio_messages_dropped_total Messages lost before reaching a subscriber, by cause # TYPE mercurio_messages_dropped_total counter mercurio_messages_dropped_total{cause="queue_full_qos0"} 0 mercurio_messages_dropped_total{cause="queue_full"} 3 … # HELP mercurio_uptime_seconds Seconds since the broker started # TYPE mercurio_uptime_seconds gauge mercurio_uptime_seconds 86400
| Metric | Type | $SYS/broker/ |
|---|---|---|
mercurio_clients_connected | gauge | clients/connected |
mercurio_clients_connected_max | gauge | clients/maximum |
mercurio_sessions, mercurio_sessions_disconnected | gauge | clients/total, clients/disconnected |
mercurio_sessions_expired_total | counter | clients/expired |
mercurio_messages_received_total, mercurio_messages_sent_total | counter | messages/received, messages/sent |
mercurio_bytes_received_total, mercurio_bytes_sent_total | counter | bytes/received, bytes/sent |
mercurio_retained_messages | gauge | retained messages/count |
mercurio_subscriptions | gauge | subscriptions/count |
mercurio_messages_dropped_total{cause} | counter | publish/messages/dropped/<cause>; their sum is publish/messages/dropped |
mercurio_storage_batches_retried_total, mercurio_storage_batches_dropped_total | counter | storage/batches/retried, storage/batches/dropped |
mercurio_storage_dirty_clients | gauge | storage/clients/dirty |
mercurio_connections_total | counter | Connections accepted; the rate is load/connections/… |
mercurio_uptime_seconds | gauge | uptime |
The endpoints share the listener's limits for plain HTTP: an 8 KiB request head, read within connect_timeout, 32 responses at once and a minute per response.
Logging
[logging] level = "info" # file = "/var/log/mercurio/mercuriod.log" # syslog = true # connection_messages = false
- Under systemd, standard output goes to the journal:
journalctl -u mercuriod -f. fileappends to a file. Rotate it with logrotate'scopytruncate, as the file stays open.syslogsends each line to the local syslog daemon through/dev/log, with facilitydaemonand a severity from the level.connection_messages = falsedrops the lines logged for every connection and disconnection, which dominate the log of a busy broker. They are logged undermercurio::connections, and alevelnaming it, such as"info,mercurio::connections=info", turns them back on, in the configuration or withmercurioctl broker log-level.levelcan be set per module:"info,mercurio_server::session=warn". An empty or invalid level meansinfo, with a warning in the log.mercuriod -vlogs at debug level.mercurioctl broker log-level debugchanges the level of a running broker until it restarts; see Administration.
Shutdown
On SIGTERM or Ctrl+C, every client is sent a DISCONNECT saying the server is shutting down, and the broker waits up to five seconds for connections to close. With SQLite or PostgreSQL, persistent sessions and their queues are in the database, so a restart loses nothing a client was promised.
Memory
On Linux with glibc, the daemon limits the allocator to two arenas. Under heavy load with many threads, glibc otherwise keeps the memory each thread freed, and the broker's memory grows and stays high; with two arenas it stays flat, at no measurable cost in throughput. Memory use is otherwise dominated by queued messages, which the queue limits above bound.