Summary
LavinMQ::AMQP::DelayedExchangeQueue::DelayedMessageStore::DelayedRequeuedStore#insert has quadratic (O(N²)) behavior, which caused a production cluster to get stuck in a start/restart loop with a large backlog of delayed messages (millions, via x-delay on a delayed-exchange queue).
Incident background
- Cluster (production) had ~4M messages when an OOM triggered a leader re-election.
- The newly elected leader got stuck during startup — no useful logs even at trace level.
strace -f -c on the running process showed almost no syscall activity (mostly epoll_wait/clock_nanosleep), i.e. not I/O-bound.
perf top on the pid showed:
98.45% lavinmq [.] *LavinMQ::AMQP::DelayedExchangeQueue::DelayedMessageStore::DelayedRequeuedStore#insert<LavinMQ::SegmentPosition, Int64>:Nil
- Claude was asked to investigate assuming a large delayed-message backlog, and confirmed quadratic behavior by benchmarking the insert:
- 100k inserts → 1.2s
- 400k inserts → 18.8s (4× the messages, ~15× the time)
- Extrapolated to 4M messages: ~30+ minutes just for this step.
- Root cause:
Deque#insert shifts up to half the deque element-by-element on every insert, so N random-delay inserts is O(N²).
- Confirmed in practice: 1.5M delayed messages alone was enough to stall a cluster restart.
- Workaround used to recover the cluster: used
lmqrecover to identify the queue with the delayed messages (lmqrecover -D <file> -d -m show_sp surfaces the AMQPTable, including the x-delay header) and deleted that queue (with customer's permission), which allowed the broker to start.
- systemd's
TimeoutStartSec (customer bootstrap sets TimeoutStopSec=600 for stop, but start timeout was still the default ~300s / whatever configured) was also killing the process mid-recovery, compounding the problem — the process needed more than the configured start timeout just to churn through the delayed store.
Proposed fix
Since the delayed message store only ever needs the minimum entry (peek min / pop min / time-to-next-expiration), a Deque is the wrong data structure for this. A binary min-heap would give O(log n) insert and O(1) peek, fixing both:
- Slow/stuck startup when recovering a large delayed-message backlog.
- Slow publishing into a large delayed queue at runtime (same O(N²) insert path is presumably hit during normal operation too, not just startup).
Alternative structures discussed: skip list or B-tree (potentially better if working set fits in CPU cache).
Troubleshooting guide (for future reference, added to this issue for visibility)
- LavinMQ pinned at 1 core, 100% CPU, cluster/node stuck starting.
strace -f -p <pid> -c and iostat show no significant syscall/IO activity — process isn't stuck on I/O.
- Trace-level logs can help but may show nothing (as in this case).
- Run
perf top -p <pid> to see where CPU time is actually spent.
- If it points at the Delayed Store — need to identify which queue has the large delayed backlog.
du -h | sort -h under /var/lib/lavinmq/ to find the largest queue directories.
cat <largest queues>/.queue to inspect.
lmqrecover -D <file> -d -m show_sp to dump segment positions/headers, which will include the x-delay AMQPTable entry identifying the delayed queue.
- If customer agrees, delete the offending queue to unblock startup.
Summary
LavinMQ::AMQP::DelayedExchangeQueue::DelayedMessageStore::DelayedRequeuedStore#inserthas quadratic (O(N²)) behavior, which caused a production cluster to get stuck in a start/restart loop with a large backlog of delayed messages (millions, viax-delayon a delayed-exchange queue).Incident background
strace -f -con the running process showed almost no syscall activity (mostlyepoll_wait/clock_nanosleep), i.e. not I/O-bound.perf topon the pid showed:Deque#insertshifts up to half the deque element-by-element on every insert, so N random-delay inserts is O(N²).lmqrecoverto identify the queue with the delayed messages (lmqrecover -D <file> -d -m show_spsurfaces the AMQPTable, including thex-delayheader) and deleted that queue (with customer's permission), which allowed the broker to start.TimeoutStartSec(customer bootstrap setsTimeoutStopSec=600for stop, but start timeout was still the default ~300s / whatever configured) was also killing the process mid-recovery, compounding the problem — the process needed more than the configured start timeout just to churn through the delayed store.Proposed fix
Since the delayed message store only ever needs the minimum entry (peek min / pop min / time-to-next-expiration), a
Dequeis the wrong data structure for this. A binary min-heap would give O(log n) insert and O(1) peek, fixing both:Alternative structures discussed: skip list or B-tree (potentially better if working set fits in CPU cache).
Troubleshooting guide (for future reference, added to this issue for visibility)
strace -f -p <pid> -candiostatshow no significant syscall/IO activity — process isn't stuck on I/O.perf top -p <pid>to see where CPU time is actually spent.du -h | sort -hunder/var/lib/lavinmq/to find the largest queue directories.cat <largest queues>/.queueto inspect.lmqrecover -D <file> -d -m show_spto dump segment positions/headers, which will include thex-delayAMQPTable entry identifying the delayed queue.