Skip to content

Queue Topology

Mantis uses a structured RabbitMQ topology for command dispatch and AI log analysis. Mandible declares the entire topology on startup. Thorax is the command consumer; the mantis.analysis consumer runs inside Mandible, not a separate worker process.

ExchangeTypePurpose
mantis.commandsDirectRoute commands to Thorax by mode and priority
mantis.analysisDirectRoute AI log-analysis jobs to Mandible’s analysis consumer
mantis.dlxFanoutDead-letter exchange for failed messages

The mantis.commands exchange routes commands based on target mode and priority:

mantis.commands (direct) — every command published lands on exactly one of the
three priority queues, which are what Thorax consumes:
├── command.poll.high -> mantis.commands.priority.high
├── command.listen.high -> mantis.commands.priority.high
├── command.poll.normal -> mantis.commands.priority.normal
├── command.listen.normal -> mantis.commands.priority.normal
├── command.poll.low -> mantis.commands.priority.low
├── command.listen.low -> mantis.commands.priority.low
└── command.thorax -> mantis.commands.thorax (legacy; nothing publishes it)

The bare command.thorax key is the only binding mantis.commands.thorax has, and no code publishes it. The priority keys were deliberately unbound from that queue because binding them only doubled every command onto a queue nothing drains.

The mantis.analysis exchange routes AI log-analysis jobs to the mantis.analysis queue, consumed inside Mandible.

Execution results are not published to RabbitMQ. Thorax reports each command’s outcome to Mandible directly over gRPC (store_command_result), so there is no mantis.results exchange or result queue to monitor.

Deployment lifecycle events (deployment.*, step.*, target.*) are streamed to clients over Server-Sent Events via Mandible’s /sse endpoints, not a mantis.events exchange.

QueuePurposeConsumers
mantis.commands.priority.highHigh-priority commandsThorax instances
mantis.commands.priority.normalNormal-priority commandsThorax instances
mantis.commands.priority.lowLow-priority commandsThorax instances
mantis.commands.thoraxLegacy, unusedNone — nothing publishes to it

Each Thorax instance starts one consumer per priority queue and drains them high before normal before low. Monitor the three priority queues; depth or consumer count on mantis.commands.thorax tells you nothing, because it is never published to and has no consumers by design.

QueuePurposeConsumers
mantis.analysisAI log-analysis jobsMandible (in-process)
QueuePurposeConsumers
mantis.dlqFailed/expired messagesManual review
Routing KeyTarget QueueDescription
command.thoraxmantis.commands.thoraxLegacy; nothing publishes it
command.poll.highthorax + priority.highHigh-priority poll commands
command.poll.normalthorax + priority.normalNormal poll commands
command.poll.lowthorax + priority.lowLow-priority poll commands
command.listen.highthorax + priority.highHigh-priority listen commands
command.listen.normalthorax + priority.normalNormal listen commands
command.listen.lowthorax + priority.lowLow-priority listen commands

Default queue configuration:

# Queue durability
durable = true
auto_delete = false
exclusive = false
# Dead-lettering
x-dead-letter-exchange = mantis.dlx
# Message TTL — set unconditionally by Mandible, and it varies per queue:
# priority.high: 1,800,000 (30 min) commands.thorax / priority.normal: 3,600,000 (1 hr)
# priority.low: 7,200,000 (2 hr) analysis: 14,400,000 (4 hr) mantis.dlq: 604,800,000 (7 days)
x-message-ttl = 3600000 # 1 hour (commands.thorax / priority.normal)

Priority queues use max-priority argument:

# Priority queue arguments
x-max-priority = 10

For high availability, use quorum queues:

# Quorum queue arguments
x-queue-type = quorum
x-quorum-initial-group-size = 3

Deployment lifecycle events are delivered to clients over Server-Sent Events, not RabbitMQ:

Messages are dead-lettered when:

ReasonDescription
rejectedMessage explicitly rejected
expiredMessage TTL exceeded
maxlenQueue length limit reached

Mantis implements retry with exponential backoff:

1. On arrival the x-death count is checked first; if it already meets max_retries the
message is NACKed straight to the DLQ (no processing attempt).
2. Otherwise the message is processed.
3. First processing failure (not redelivered): NACK requeue=true — it re-enters the same
queue immediately.
4. Second failure (redelivered): NACK requeue=false — RabbitMQ routes it through the
dead-letter exchange back to the main queue, incrementing x-death.
5. The cycle repeats until x-death reaches max_retries, then the message goes to the DLQ.

Monitor dead-letter queue:

Terminal window
# List messages in DLQ
sudo rabbitmqctl list_queues name messages \
| grep mantis.dlq
# Inspect DLQ messages
sudo rabbitmqadmin get queue=mantis.dlq count=10
# Purge DLQ (use with caution)
sudo rabbitmqctl purge_queue mantis.dlq

Requeue messages from DLQ:

Terminal window
# Move messages back to original queue
# Using shovel plugin
sudo rabbitmqctl set_parameter shovel reprocess \
'{"src-uri": "amqp://", "src-queue": "mantis.dlq", \
"dest-uri": "amqp://", "dest-queue": "mantis.commands.thorax", \
"delete-after": "queue-length"}'

Mandible declares the full topology on startup. Configure the RabbitMQ connection in mandible.toml:

mandible.toml
[queue]
enabled = true
host = "localhost"
port = 5671
username = "mantis"
password = "password"

Topology declaration is idempotent — Mandible re-declares exchanges, queues, and bindings on every startup, so no manual setup is needed. Thorax is a pure consumer and assumes the topology already exists (it retries its consumer connection until the queues appear).

Declare topology manually:

Terminal window
# Create exchanges
rabbitmqadmin declare exchange name=mantis.commands type=direct durable=true
rabbitmqadmin declare exchange name=mantis.analysis type=direct durable=true
rabbitmqadmin declare exchange name=mantis.dlx type=fanout durable=true
# Create queues
rabbitmqadmin declare queue name=mantis.commands.thorax durable=true \
arguments='{"x-dead-letter-exchange": "mantis.dlx"}'
rabbitmqadmin declare queue name=mantis.commands.priority.high durable=true \
arguments='{"x-dead-letter-exchange": "mantis.dlx", "x-max-priority": 10}'
rabbitmqadmin declare queue name=mantis.commands.priority.normal durable=true \
arguments='{"x-dead-letter-exchange": "mantis.dlx", "x-max-priority": 10}'
rabbitmqadmin declare queue name=mantis.commands.priority.low durable=true \
arguments='{"x-dead-letter-exchange": "mantis.dlx", "x-max-priority": 10}'
rabbitmqadmin declare queue name=mantis.analysis durable=true \
arguments='{"x-dead-letter-exchange": "mantis.dlx"}'
rabbitmqadmin declare queue name=mantis.dlq durable=true
# Create bindings
rabbitmqadmin declare binding source=mantis.commands destination=mantis.commands.thorax \
routing_key=command.thorax
rabbitmqadmin declare binding source=mantis.commands destination=mantis.commands.priority.high \
routing_key=command.poll.high
rabbitmqadmin declare binding source=mantis.commands destination=mantis.commands.priority.high \
routing_key=command.listen.high
# ... additional bindings

Configure consumer prefetch:

thorax.toml
[queue]
prefetch_count = 10 # Messages to prefetch per consumer

Thorax generates unique consumer tags:

thorax-{instance_id}-{uuid}

Mantis uses manual acknowledgment:

ActionWhen
ackMessage processed successfully
nack requeue=trueTemporary failure, retry immediately
nack requeue=falsePermanent failure, route to DLX

For classic mirrored queues:

Terminal window
# Set HA policy for all mantis queues
sudo rabbitmqctl set_policy ha-mantis "^mantis\." \
'{"ha-mode":"all","ha-sync-mode":"automatic"}'

For quorum queues (recommended):

Terminal window
# Set policy for quorum queues
sudo rabbitmqctl set_policy quorum-mantis "^mantis\." \
'{"queue-type":"quorum"}' --apply-to queues

When a Thorax instance fails:

  1. Messages are redelivered to other consumers
  2. In-flight messages timeout and retry
  3. Health monitor switches to polling if needed

Scale horizontally with competing consumers:

For very high throughput:

mantis.commands.thorax.{shard_id}

Listen-mode targets use instance affinity:

# Command message includes assigned instance
assigned_instance_id = "thorax-node-1"
  1. Check binding exists:

    Terminal window
    rabbitmqadmin list bindings source=mantis.commands
  2. Verify routing key matches:

    Terminal window
    # Publish test message
    rabbitmqadmin publish exchange=mantis.commands \
    routing_key=command.thorax payload='test'
  3. Check queue is not paused:

    Terminal window
    rabbitmqctl list_queues name state
  1. Inspect message headers:

    Terminal window
    rabbitmqadmin get queue=mantis.dlq count=1
  2. Check x-death header for failure reason

  3. Review application logs for processing errors

  1. Check consumer is registered:

    Terminal window
    rabbitmqctl list_consumers queue=mantis.commands.thorax
  2. Verify prefetch is not exhausted:

    Terminal window
    rabbitmqctl list_channels name prefetch_count messages_unacknowledged