Skip to content

Run Solid Queue in async mode on Render free tier#23

Merged
raghubetina merged 2 commits into
mainfrom
fix/sq-async-mode-on-free-tier
May 18, 2026
Merged

Run Solid Queue in async mode on Render free tier#23
raghubetina merged 2 commits into
mainfrom
fix/sq-async-mode-on-free-tier

Conversation

@raghubetina

Copy link
Copy Markdown
Contributor

Summary

Companion to #22 (`WEB_CONCURRENCY=0`). Switches SQ to async mode so it fits in the 512MB Render free plan, plus the matching DB pool bump (5 → 8) so the in-process SQ threads don't starve Puma's request threads for connections.

Why this is needed

Render free is 512MB. With SQ's default fork mode, the supervisor spawns 4 subprocesses (~460MB across them) on top of Puma. The container OOM-cycles every ~7-15 min; deploys look "live" but HTTP returns 502s during each restart window.

`solid_queue_mode :async` is a documented switch in the SQ Puma plugin (`lib/puma/plugin/solid_queue.rb`) that runs worker/dispatcher/scheduler as threads inside Puma master. The 4 subprocesses collapse to ~50MB of thread overhead.

state container RSS OOM cycle
fork mode (default) ~530 MB every 7–15 min
async mode ~300 MB none

Trade-offs (per SQ docs)

SQ explicitly says "Only use async if you know what you're doing and have strong reasons to." The strong reasons here:

  • 512MB is a hard ceiling for student projects.
  • Free tier is 0.1 vCPU — inter-process parallelism is already nonexistent.
  • Recurring tasks and concurrency controls still work in async mode.

The downsides — less isolation between SQ and Puma, a hung SQ thread affecting request serving — are acceptable for portfolio projects with effectively zero traffic. The comment in `config/puma.rb` notes to revisit on a paid plan.

Pool change

The pool bump is non-optional in async mode. Previously sized for Puma's 3 request threads only; now the same process also runs 3 SQ worker threads + dispatcher + scheduler. `pool: 5` was right at the edge; under any load it would throw `ActiveRecord::ConnectionTimeoutError`. New default 8 respects the existing `DB_POOL` env override for finer control.

Validation

Diagnosed and shipped on raghubetina/aplace (PR #58). Render's RSS metric showed the predicted plateau at ~300 MB with zero growth after deploy — confirmed via that app's memory_logger and Render's metric API.

Sequencing note

This PR is independent of #22 — they don't conflict. Order of merge doesn't matter. A separate follow-up will collapse the production multi-DB role config (`primary`/`cache`/`queue`/`cable`) to a single role since student projects don't benefit from the pool isolation.

Test plan

  • Merge → fresh deploy of a project using this template stays under 400MB RSS, no OOM-cycle.
  • aplace.app already confirms this works end-to-end.

Companion to #22 (WEB_CONCURRENCY=0). Free tier (512MB) can't fit
SQ's default fork mode, which spawns supervisor + worker +
dispatcher + scheduler subprocesses for ~460MB of RSS overhead on
top of Puma. The container OOM-cycles every ~7-15 min — the deploy
still looks "live" but HTTP returns 502s during each restart
window.

`solid_queue_mode :async` (documented switch in
lib/puma/plugin/solid_queue.rb of the gem) tells the plugin to
run worker/dispatcher/scheduler as threads inside the Puma master
process. The 4 SQ subprocesses collapse into ~50MB of thread
overhead in Puma. Container RSS drops from ~530MB (cycling into
OOM) to a flat ~300MB.

Trade-off per SQ's own docs ("Only use async if you know what
you're doing and have strong reasons to"):
- Less isolation between SQ and Puma. A leaky/hung SQ thread
  affects request serving. For low-traffic student projects this
  is the right call; revisit if upgrading to a paid plan.
- No inter-process parallelism for jobs. Free tier is already
  0.1 vCPU, so this isn't real lost throughput.
- Recurring tasks and concurrency controls still work unchanged.

The DB pool bump (5 → 8) is non-optional: previously the pool was
sized for Puma's 3 request threads only; now the same process
also runs 3 SQ worker threads + dispatcher + scheduler. Under any
load the old pool=5 would throw ConnectionTimeoutError. The 5 ->
8 default still respects DB_POOL env override for projects that
need finer control.

Diagnosed and validated on raghubetina/aplace:
raghubetina/aplace#58 — RSS metric showed
the predicted plateau at ~300MB with zero growth after deploy.
This branch's puma.rb adds `solid_queue_mode :async if ENV["SOLID_QUEUE_IN_PUMA"]`,
but the lockfile resolved to solid_queue 1.1.4, which has no such DSL method.
Booting in production crashes immediately with:

  config/puma.rb:47:in 'Puma::DSL#_load_from': undefined method
  'solid_queue_mode' for an instance of Puma::DSL (NoMethodError)

Async supervisor mode was re-introduced in solid_queue 1.3.0
(rails/solid_queue#644); the earlier 0.4 implementation was
removed in 0.7. Pinning to ~> 1.3 documents the minimum that
matches this branch's puma.rb and updates the lockfile to 1.4.0.

Caught by deploying a marketplace smoke-test app built from this
branch + #22 + #24 to Render free tier.
@raghubetina raghubetina merged commit 6007aa4 into main May 18, 2026
1 check passed
@raghubetina raghubetina deleted the fix/sq-async-mode-on-free-tier branch May 18, 2026 16:51

@bpurinton bpurinton left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants