Skip to content

A heartbeat that blocks forever is never detected, unlike one that fails #808

Open

Description

@julik

(Written by Claude on @julik's behalf)

Follow-up to #763, which I filed and which was closed as completed by #778. #778 fixes the case I could reproduce at the time; this is the neighbouring case, which I have now hit in production and which I believe is still open.

The distinction

#778 stops a process whose heartbeats keep failing:

def heartbeat
  process&.heartbeat
rescue ActiveRecord::RecordNotFound
  stop_to_be_replaced
rescue => error
  stop_to_be_replaced if presumed_dead?
  raise error
end

Both new paths hang off a raise. But a heartbeat can also never return at all, and then neither branch is reachable.

SolidQueue::Process#heartbeat does a real round-trip:

restore_attributes
with_lock { touch(:last_heartbeat_at) }

If the socket under that connection is half-open - the server process is alive enough for its kernel to keep the connection, but nothing is being served - the thread blocks in the driver indefinitely. No exception is raised, so presumed_dead? is never evaluated.

Why the timer does not save you

launch_heartbeat runs the heartbeat inside a Concurrent::TimerTask. In concurrent-ruby (checked against 1.3.7), execute_task is:

_success, value, reason = @task.execute(self)
if completion.try?
  self.value = value
  schedule_next_task(calculate_next_interval(start_time))
  ...
  observers.notify_observers { [time, self.value, reason] }

The reschedule and the observer notification both happen after the task returns. A run that never returns is never rescheduled and never reported - the heartbeat thread goes quiet permanently, having neither succeeded nor failed.

There is also no configuration escape: TimerTask#timeout_interval is now a no-op that warns "TimerTask timeouts are now ignored as these were not able to be implemented correctly".

The supervisor has the same property

This is the part I think is most worth looking at. Supervisor::Maintenance#launch_maintenance_task is also a Concurrent::TimerTask, running prune_dead_processes. So when a supervisor's maintenance run blocks on the same unresponsive database, pruning stops too - the mechanism meant to notice dead processes is built from the same material that just died.

Meanwhile supervise only calls check_and_replace_terminated_processes, so a forked child that blocks without exiting is never replaced. Between the three, a process can be alive, registered, holding claimed executions, and doing nothing whatsoever, with nothing in the system positioned to notice.

What it looked like in production

A PgBouncer instance in front of our Postgres froze without dying - process alive, event loop wedged, kernel holding every TCP connection half-open. Our scheduler's heartbeat thread blocked mid-with_lock and stayed there. The recurring scheduler runs in exactly one process, so all 33 recurring tasks stopped for 101 minutes and only came back on a manual redeploy. The supervisor never replaced the child; other supervisors pruned the process row but pruning sends no signal, so it kept claiming jobs and those claims later failed as ProcessMissingError.

Left alone, the sockets would have cleared on Linux's default 7200s keepalive - about 2h11m.

What actually fixed it for us, and why I am not sure it is your problem

Setting client-side socket bounds in database.yml:

keepalives: 1
keepalives_idle: 30
keepalives_interval: 10
keepalives_count: 3
tcp_user_timeout: 60000
connect_timeout: 5

tcp_user_timeout is the load-bearing one - TCP keepalive only probes a socket with no unacknowledged data in flight, which is the wrong half of the problem. With it, the block becomes a raise after ~60s, and #778's logic then engages exactly as designed.

So one defensible answer is "this is a libpq configuration problem, document it and close". I would understand that. What makes me file anyway:

  • The gap is invisible. An app with default database.yml has no socket bound, and nothing in SolidQueue's own supervision will notice, so the failure mode is a silent stop rather than a loud one.
  • It is not Postgres-specific. Any adapter whose socket can go quiet has the same shape.
  • The supervisor's maintenance task sharing the defect seems worth fixing regardless of what the app does with its sockets.

Possible directions

Not a proposal, just what seems available:

  1. Track the last time a heartbeat completed in memory, separately from last_heartbeat_at, and have the run loop stop the process when that goes older than process_alive_threshold. This is the only option that does not depend on the blocked thread itself.
  2. Have supervisors act on prunable children rather than only on exited ones - prune currently deletes the row and sends nothing.
  3. At minimum, README guidance on socket-level timeouts for the database connection, since without one nothing else here can engage.

Happy to have a go at (1) if you think it is the right shape.

Refs

Versions: solid_queue 1.6.0, concurrent-ruby 1.3.7, Ruby 3.4.2, PostgreSQL via PgBouncer in transaction mode.

Activity

  1. crespire commented on Oct 13, 2026

    @crespire

    Another data point that I think belongs with this one, from a different angle: no network socket involved, and the process got further than the heartbeat. Here the run loop noticed the pruned registration and shut down cleanly, but the fork never exited. Nothing afterwards could remove it, including the supervisor's QUIT.

    Setup:

    • solid_queue 1.7.0, Rails 8.1.4, Ruby 4.0.1, concurrent-ruby 1.3.8, macOS 15.7 (Apple Silicon)
    • Puma 8.0.2 in single mode (3 threads) with plugin :solid_queue, so a fork supervisor under Puma, run by launchd with KeepAlive
    • SQLite only (sqlite3 2.9.6), local files on the same disk. The queue has its own database through connects_to, the default timeout: 5000, and a pool of 5, so it logs the "needs at least 10 database connections" warning at boot. No Postgres, no pooler, nothing over TCP.
    • One dispatcher (polling_interval: 1, batch_size: 500, default maintenance with batches on), three workers (1, 8 and 2 threads), a scheduler with two recurring tasks every 2 and 15 seconds
    • A laptop that sleeps a lot: DarkWake about every 15 minutes, plus a weekend asleep, so every resume is well past the 5 minute alive threshold

    What the log shows:

    1. Sleep/resume prunes the children over and over, and the !registered? self-heal handles it fine nearly every time. The log has 17 Replaced terminated Dispatcher and 150 Replaced terminated Worker lines, all status: 0.
    2. One dispatcher took the same path and logged Shutdown Dispatcher ... pid: 20916, process_id: nil, next to a Shutdown Worker for its sibling. The worker was replaced right away. The dispatcher never got a Replaced terminated Dispatcher, so the supervisor's waitpid2 never reaped it. The supervisor then ran for about two days with no working dispatcher.
    3. On the next stop, the supervisor tried three times and gave up:
      Supervisor terminated immediately  supervisor_pid: 7527, supervised_processes: [20916]
      Supervisor wasn't terminated gracefully - shutdown timeout exceeded (5095.4ms)  supervised_processes: [7534, 20916, 77705, 78039, 78054]
      Supervisor terminated immediately  supervised_processes: [20916]
      Supervisor wasn't terminated gracefully - shutdown timeout exceeded (5111.8ms)  supervised_processes: [20916]
      Supervisor terminated immediately  supervised_processes: [20916]
      Supervisor wasn't terminated gracefully - shutdown timeout exceeded (5032.7ms)  supervised_processes: [20916]
      Shutdown Supervisor(fork)  pid: 7527
      
      So TERM didn't stop it, and neither did QUIT (exit! in a trap). The supervisor exited, and pid 20916 was reparented to PID 1 and kept running.
    4. Because the plugin forks from Puma, the orphan still held Puma's listening socket. Every new Puma failed with EADDRINUSE on its port until the orphan was killed by hand. With launchd restarting on exit, that's a crash loop.

    My read, which I couldn't confirm because the process was gone before I got a sample of it: after run returned, the fork was stuck exiting. Ruby waits for the remaining threads to finish, and the timer threads (the dispatcher's Maintenance task, or the heartbeat as described above) may not have. A process in that state doesn't run trap handlers, which would explain why TERM and QUIT did nothing. With everything on local SQLite, I don't think the stuck thread was waiting on the database the way it was in your PgBouncer case. It points more at the fork or the sleep itself. So I think it's the same "blocked and never noticed" family, one step later than the heartbeat case, and a socket timeout in database.yml wouldn't have helped.

    Two things from this case for the directions above. Supervisors acting on prunable children (2) would have caught the two days with no dispatcher. And immediate termination probably wants a KILL after QUIT, since exit! from a trap needs the child to still handle signals. With the Puma plugin, a child the supervisor leaves behind keeps the server's port, so a stuck child stops the next boot as well as the queue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions