You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Downstream heartbeat pump misses timer slots under channel fanout on shared upstream — client-side MissedHeartbeatException (downstream analog of #210) #253
Terminology used below (all three numbers matter and are easy to conflate):
heartbeat = 60 s — the interval negotiated in connection.tune (this is what the Java client's log message reports).
heartbeat/2 = 30 s — the conventional peer send cadence (each side emits one heartbeat frame every 30 s so the far side has a retry chance before its deadline).
2 × heartbeat = 120 s — the Java client's MissedHeartbeatException deadline (per com.rabbitmq:amqp-client: two consecutive 60 s socket-read timeouts with no frame received).
On v3.2.0 (which fixed the upstream heartbeat pump per #210), the downstream heartbeat pump still misses timer slots when a single upstream connection multiplexes a large number of downstream channels. Concretely: amq_proxy.client[client: "<ip:port>"] Sending heartbeat (last heartbeat 30s ago) log lines that should appear at ~30 s wall-clock cadence (heartbeat/2 for negotiated heartbeat=60) appear at 60 s, 90 s, 120 s, and occasionally 130–140 s intervals for the same connection. When the gap exceeds the client's 2 × heartbeat = 120 s grace, the AMQP 0-9-1 client library (in our case Java com.rabbitmq:amqp-client) raises MissedHeartbeatException, closes the TCP socket, and the proxy then reads EOF (IO::EOFError: End of file reached) on that downstream socket.
Note: the log message always literally says "last heartbeat 30s ago" — this string appears to be a hard-coded reflection of the configured heartbeat interval, not the actual elapsed time. The delay is invisible in the log message text; only the log timestamps reveal it.
The proxy is not CPU- or memory-constrained when this happens (see setup below — measured usage is ~3–4 mCPU and ~4–5 MiB per pod). This looks like fiber scheduling latency inside the single-threaded Crystal reactor when one fiber has to walk many downstream sockets on each heartbeat tick.
Downstream clients: ~15 Spring AMQP services (Java com.rabbitmq:amqp-client), default requested-heartbeat=60, each with a SimpleMessageListenerContainer opening 5–15 channels
Per-proxy-pod resource usage: 3–4 mCPU, 4–5 MiB (essentially idle — nowhere near any cgroup limit)
Network path: pure Kubernetes CNI, pod-to-pod, no LB / NAT / firewall between client pods and proxy pods (confirmed via nf_conntrack_tcp_timeout_established = 86400; no session-tracking middlebox exists on this leg)
How to reproduce
Deploy amqproxy v3.2.0 as a K8s Deployment, args ["--listen=0.0.0.0", "--debug", "amqps://<broker>:5671"].
Point ~15 Spring AMQP services (default heartbeat=60) at it, each opening 5–15 channels via SimpleMessageListenerContainer.
Wait until aggregate channel count on any one upstream connection exceeds ~150 (visible on the broker side via rabbitmqctl list_connections peer_host channels).
Observe on client side: com.rabbitmq.client.MissedHeartbeatException: Heartbeat missing with heartbeat = 60 seconds at a steady ~3–7/min rate across the client fleet.
In amqproxy debug logs, grep for IO::EOFError — steady rate of ~3–7/min matches, one line per client that timed out.
For any one such disconnect, grep the proxy debug log for that client's <ip:port> and look at the wall-clock intervals between successive Sending heartbeat (last heartbeat 30s ago) lines: intervals should be 30 s but are frequently 60–120+ s.
Anchor evidence — two client connections on two different proxy pods, same downstream client service
Connection A (client 100.76.0.166:59822, proxy pod A):
2026-09-06T07:02:42.234891Z Connected
2026-09-06T07:03:12.433230Z Sending heartbeat (last heartbeat 30s ago) Δ=30.198s ✓
2026-09-06T07:05:27.535556Z Disconnected #<IO::EOFError:End of file reached>
^^ 135.10 s of ZERO heartbeat frames — client's 120 s grace expired
Connection B (client 100.76.0.166:47448, proxy pod B):
com.rabbitmq.client.ShutdownSignalException: connection error
at com.rabbitmq.client.impl.AMQConnection.startShutdown(AMQConnection.java:1007)
at com.rabbitmq.client.impl.AMQConnection.shutdown(AMQConnection.java:997)
at com.rabbitmq.client.impl.AMQConnection.handleFailure(AMQConnection.java:801)
at com.rabbitmq.client.impl.AMQConnection.access$500(AMQConnection.java:48)
at com.rabbitmq.client.impl.AMQConnection$MainLoop.run(AMQConnection.java:696)
Caused by: com.rabbitmq.client.MissedHeartbeatException: Heartbeat missing with heartbeat = 60 seconds
at com.rabbitmq.client.impl.AMQConnection.handleSocketTimeout(AMQConnection.java:869)
at com.rabbitmq.client.impl.AMQConnection.readFrame(AMQConnection.java:769)
Aggregate signal (across 3 proxy pods, 60 min sample)
IO::EOFError disconnect rate: ~4/min steady-state (3–7/min per pod), NOT bursty — matches per-connection heartbeat-slot-miss cadence, not fanout-storm bursts.
Distribution across 15 distinct client pod IPs — systemic pump problem, not one bad-actor client.
Top offender: single client pod with 54 events/hr — a Spring service with the highest per-JVM channel count.
Expected behavior
The proxy should send heartbeat frames to each downstream client at heartbeat/2 (~30 s for default heartbeat=60) with sub-second jitter regardless of how many channels are multiplexed on the shared upstream connection.
No downstream client should observe > 60 s between heartbeat frames while the proxy has CPU/memory headroom.
Suspected root cause (informational — from reading behavior, not code)
amqproxy's single-fiber-per-upstream-connection reactor pattern requires that same fiber to walk every downstream socket on each heartbeat tick.
Under 150–200 downstream channels per upstream, the fiber cannot service the heartbeat timer for every downstream socket within the 120 s AMQP grace window.
This appears to be the downstream-leg analog of Getting a lot of Upstream error messages #210: Getting a lot of Upstream error messages #210 fixed the upstream leg by making the proxy actively emit heartbeats rather than passively echo them. On the downstream leg the active pump exists (the log lines prove it), but its scheduling cadence degrades under fanout.
Suggested fix directions (informational)
Decouple each downstream heartbeat pump from the upstream fiber (dedicated timer fiber per downstream connection) so a single busy fiber can't starve peer heartbeat emission across many sockets.
Or, expose a config knob (e.g. --downstream-heartbeat-cadence-seconds) so operators can tune the pump to fire faster than heartbeat/2 (e.g. every 10 s for a 60 s heartbeat), giving substantial slack for scheduler jitter.
Or, distribute downstream sockets across multiple Crystal fibers under -Dpreview_mt.
As a stopgap, update the Sending heartbeat (last heartbeat 30s ago) log message to report the actual elapsed time from the previous emission so scheduler drift becomes visible without cross-referencing timestamps.
Impact / workaround
Client-side MissedHeartbeatException log noise at a steady rate; the Java client auto-reconnects, so under normal load there is no queue backlog or message loss.
However, when the broker is restarted, the reconnect storm combined with continued heartbeat misses can exhaust Spring AMQP's declarationRetries × recoveryInterval window on some listeners, leaving them in stopped state until app pod restart — an operational cost.
Workaround adopted: for high-fanout Spring services, bypass amqproxy and connect the Java client directly to the RabbitMQ cluster. Zero MissedHeartbeatException observed on those services over 2 days post-cutover, matching a peer namespace that has always used direct connect.
Thanks for maintaining amqproxy — happy to run more diagnostics (proxy debug tails, broker list_connections samples, or a smaller reproducer) if it helps narrow this down.
Describe the bug
Terminology used below (all three numbers matter and are easy to conflate):
heartbeat = 60 s— the interval negotiated inconnection.tune(this is what the Java client's log message reports).heartbeat/2 = 30 s— the conventional peer send cadence (each side emits one heartbeat frame every 30 s so the far side has a retry chance before its deadline).2 × heartbeat = 120 s— the Java client'sMissedHeartbeatExceptiondeadline (percom.rabbitmq:amqp-client: two consecutive 60 s socket-read timeouts with no frame received).On v3.2.0 (which fixed the upstream heartbeat pump per #210), the downstream heartbeat pump still misses timer slots when a single upstream connection multiplexes a large number of downstream channels. Concretely:
amq_proxy.client[client: "<ip:port>"] Sending heartbeat (last heartbeat 30s ago)log lines that should appear at ~30 s wall-clock cadence (heartbeat/2 for negotiatedheartbeat=60) appear at 60 s, 90 s, 120 s, and occasionally 130–140 s intervals for the same connection. When the gap exceeds the client's2 × heartbeat = 120 sgrace, the AMQP 0-9-1 client library (in our case Javacom.rabbitmq:amqp-client) raisesMissedHeartbeatException, closes the TCP socket, and the proxy then reads EOF (IO::EOFError: End of file reached) on that downstream socket.Note: the log message always literally says "last heartbeat 30s ago" — this string appears to be a hard-coded reflection of the configured heartbeat interval, not the actual elapsed time. The delay is invisible in the log message text; only the log timestamps reveal it.
The proxy is not CPU- or memory-constrained when this happens (see setup below — measured usage is ~3–4 mCPU and ~4–5 MiB per pod). This looks like fiber scheduling latency inside the single-threaded Crystal reactor when one fiber has to walk many downstream sockets on each heartbeat tick.
Describe your setup
cloudamqp/amqproxy:v3.2.0(linux/amd64)amqproxy --listen=0.0.0.0 --debug --idle-connection-timeout=120 amqps://<broker>:5671--max-upstream-channels: not set (default — server max or 65535), so one upstream connection accumulates 150–200+ downstream channels at steady statecom.rabbitmq:amqp-client), defaultrequested-heartbeat=60, each with aSimpleMessageListenerContaineropening 5–15 channelsnf_conntrack_tcp_timeout_established = 86400; no session-tracking middlebox exists on this leg)How to reproduce
["--listen=0.0.0.0", "--debug", "amqps://<broker>:5671"].heartbeat=60) at it, each opening 5–15 channels viaSimpleMessageListenerContainer.rabbitmqctl list_connections peer_host channels).com.rabbitmq.client.MissedHeartbeatException: Heartbeat missing with heartbeat = 60 secondsat a steady ~3–7/min rate across the client fleet.IO::EOFError— steady rate of ~3–7/min matches, one line per client that timed out.<ip:port>and look at the wall-clock intervals between successiveSending heartbeat (last heartbeat 30s ago)lines: intervals should be 30 s but are frequently 60–120+ s.Anchor evidence — two client connections on two different proxy pods, same downstream client service
Connection A (client
100.76.0.166:59822, proxy podA):Connection B (client
100.76.0.166:47448, proxy podB):Client-side (Java
com.rabbitmq:amqp-client) stack trace matching Connection B's disconnect:Aggregate signal (across 3 proxy pods, 60 min sample)
IO::EOFErrordisconnect rate: ~4/min steady-state (3–7/min per pod), NOT bursty — matches per-connection heartbeat-slot-miss cadence, not fanout-storm bursts.Expected behavior
heartbeat/2(~30 s for defaultheartbeat=60) with sub-second jitter regardless of how many channels are multiplexed on the shared upstream connection.Suspected root cause (informational — from reading behavior, not code)
Suggested fix directions (informational)
--downstream-heartbeat-cadence-seconds) so operators can tune the pump to fire faster than heartbeat/2 (e.g. every 10 s for a 60 s heartbeat), giving substantial slack for scheduler jitter.-Dpreview_mt.Sending heartbeat (last heartbeat 30s ago)log message to report the actual elapsed time from the previous emission so scheduler drift becomes visible without cross-referencing timestamps.Impact / workaround
MissedHeartbeatExceptionlog noise at a steady rate; the Java client auto-reconnects, so under normal load there is no queue backlog or message loss.declarationRetries × recoveryIntervalwindow on some listeners, leaving them instoppedstate until app pod restart — an operational cost.MissedHeartbeatExceptionobserved on those services over 2 days post-cutover, matching a peer namespace that has always used direct connect.References
Thanks for maintaining amqproxy — happy to run more diagnostics (proxy debug tails, broker
list_connectionssamples, or a smaller reproducer) if it helps narrow this down.