Background Jobs in Ruby on Rails
living article · rewritten by this chat, live · voice: your tutor · reader model: angel@2026-07-18
I assessed you this morning: four probes, two blanks. You can name the GVL but declined to explain it. You have shipped deliver_later but never turned a concurrency knob. And at InfoTech you watched multitenant jobs die and re-ran them by hand — experience without a model, which is why it never compounded. So this page spends its depth on mechanism and diagnosis, not API tours, and it leans on analogies because that is your strongest axis. When your troubleshooting score crosses 0.6, I will rewrite it terser.
The low-level floor this page stands on
- Processes vs threadsmissing A process is a running program with its own private memory; if it dies, nothing else notices. A thread lives inside a process and shares its memory with every other thread there — cheaper to create, riskier to share. Puma and Sidekiq are thread-based; Unicorn and Resque are process-based. Every trade-off below reduces to this split.
- Blocking I/Omissing When your code queries Postgres, the CPU doesn't "work slower" — that thread does nothing at all until bytes come back. A 40 ms query is an eternity of idle CPU. Concurrency exists to spend those waits running someone else's code.
- The GIL (today: GVL)shaky MRI's Global VM Lock lets exactly one thread execute Ruby at any instant. It doesn't forbid threads; it schedules them. You could name it this morning but not explain it — §2 exists for you.
- Connection poolsuntested A pool is a box of N reusable connections; a thread checks one out, uses it, puts it back. When more threads want connections than the box holds, they queue — and after 5 seconds ActiveRecord raises the exact error you couldn't diagnose this morning. §4.
- 1What a job actually isstable
- 2The lie in
concurrency: 25rewritten today - 3Your InfoTech incident, decodedfrom memory
- 4Pools: the diagnostic you missednew today
- 5Sidekiq vs Solid Queue, 2026decision pending
- 6Open questions3 open
What a job actually is
Strip the mystique first. A background job is three ordinary things: a piece of JSON describing work — class name, arguments, queue —; a data structure holding it — a Redis list, or a Postgres table —; and a separate OS process that loops forever: pop, deserialize, call perform, repeat.
UserMailer.welcome(user).deliver_later does not send an email. It serializes intent and pushes it onto a list. Your web request answers fast because it did almost nothing. Everything else in this page is what happens inside that third thing — the process you never see.
Saying "a queue is just a list that another process polls" — calmly, before naming any gem — is a seniority tell. Most candidates hand-wave the transport and jump to Sidekiq trivia.
The lie in concurrency: 25
This morning I asked why 25 threads make progress under a lock, and you chose "not clear to me." Fair. Here is the model, once, properly.
Under MRI, only one thread executes Ruby at any instant — that's the GVL. But the moment a thread performs I/O — a Postgres query, an HTTP call, a Redis command — it releases the lock and goes to sleep until the kernel has bytes for it. Another thread grabs the lock and runs. Sidekiq jobs are overwhelmingly I/O-bound, so 25 threads means 25 overlapping waits and roughly one thread executing Ruby at a time. That is concurrency during I/O waits — not CPU parallelism.
Your kitchen version: one chef (the interpreter), twenty-five pans (threads). The chef never fries two eggs simultaneously — but while pan 7 sizzles unattended (I/O), the chef stirs pan 12. A kitchen that is all chopping and no sizzling — CPU-bound work like image processing — gains nothing from more pans. It needs more chefs: processes, each with its own GVL.It needs more chefs: processes, each with its own GVL. It needs more chefs — processes, each with its own GVL — or a kitchen robot: native extensions (image codecs, JSON parsers, embedding math in C) release the GVL while they crunch, so even CPU-heavy work can overlap.rev 4 · from this chat That is why Sidekiq lets you run several processes, and why Resque chose forking outright.
The sentence they are listening for: "The GVL gives you concurrency during I/O waits, not CPU parallelism — so I scale threads for I/O-bound jobs and processes for CPU-bound ones."
Your InfoTech incident, decoded
At InfoTech you ran a multitenant app where jobs kept dying, and you re-enqueued them by hand. You lived through three concepts that day without their names. Let's attach them.
- Where you started
- "Jobs sometimes die; when they do, someone re-runs them." Retry was a chore a human performed, not a mechanism the system owned.
- The catalyst
- Multitenant jobs failing repeatedly — and manual re-enqueues sometimes working, sometimes double-executing, never explaining themselves.
- The shift
- A job is a message to a stranger, not a method call to yourself. It will run later, on another thread, maybe another day — and it must carry everything it needs inside its arguments.
Name one: at-least-once delivery. Sidekiq promises your job runs at least once — never exactly once. Crashes mid-run mean re-runs; re-runs mean duplicates must be survivable. Name two: the retry schedule. A failing job retries ~25 times over ~20 days with exponential backoff, then lands in the Dead set. Your manual re-enqueues were you re-implementing that machinery — without its bookkeeping. Name three: ambient context loss. Anything that lived in thread-local state — the current tenant, above all — is gone when the job wakes up elsewhere. Multitenant jobs that "die mysteriously" are usually jobs that woke up stateless.
The cure has a name too: idempotency. Design perform so that running twice equals running once — upserts instead of creates, guard clauses on state (return if invoice.sent?), uniqueness keys for the truly critical. Once a job is idempotent, retries stop being scary and become what they were meant to be: free reliability.
Before you read §4 — you raise Sidekiq's concurrency from 10 to 30 and ActiveRecord::ConnectionTimeoutError starts flooding the logs. Say your first hypothesis out loud. Then check.
reveal the answer
The ActiveRecord connection pool is still smaller than your thread count. Thirty threads compete for (say) ten connections; the losers wait, and after the 5-second checkout timeout they raise exactly that error. Fix: pool ≥ concurrency in the Sidekiq process — then verify Postgres max_connections can absorb pool × processes.
You blanked on this at 09:14 today. If you just got it right, §3 is doing its job — next review lands 2026-07-21.
Pools: the diagnostic you missed
Every thread that touches ActiveRecord checks out a database connection and keeps it for the duration of its work. The pool size comes from database.yml — historically pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>. Raise Sidekiq to 30 threads without touching that number and you have built a bottleneck on purpose: 30 workers, 5 connections, 25 threads queuing at the box. Five seconds later, the exception.
The arithmetic that matters end-to-end: total Postgres connections ≈ (pool × Sidekiq processes) + (pool × web processes), and that sum must clear the server's max_connections — which is why teams put PgBouncer in front long before they "need microservices." Redis has a pool too, but Sidekiq sizes its own to match concurrency; the pool you personally own is ActiveRecord's.
The instinct you lacked this morning was not knowledge of an obscure flag. It was a habit: read the error class and ask who hands out the resource it names. ConnectionTimeoutError names a connection pool. Start there; compare concurrency vs pool; check the server's headroom; only then blame Redis, the GVL, or the infra. That four-step ritual is the troubleshooting axis on your radar.
"What breaks when you raise Sidekiq concurrency?" is a classic senior screen. The expected shape: DB pool first, then max_connections, then memory-per-thread, then GVL saturation for CPU-bound jobs.
Sidekiq vs Solid Queue, 2026
You know Solid Queue exists — Rails 8 made it the default — but not its internals. Here is the decision, compressed. Solid Queue keeps jobs in Postgres; workers claim them with SELECT … FOR UPDATE SKIP LOCKED, a row-level "take what nobody holds, skip what somebody does" that avoids both double-claims and lock convoys. No Redis, no extra process to babysit, and you can enqueue inside the same transaction as your domain data — the job cannot exist unless the order does. With Redis-backed Sidekiq, that atomicity is your discipline (after_commit), not the system's. Your instinct about polling cost is right to have and wrong to fear: with SKIP LOCKED on an indexed table it is a handful of cheap queries per second — the ceiling that actually bites is row churn and vacuum pressure, orders of magnitude above your ingestion volume.rev 5 · from this chat
| dimension | Sidekiq | Solid Queue |
|---|---|---|
| store / transport | Redis list, BRPOP push semantics | Postgres table, polling + SKIP LOCKED |
| extra infra | Redis (one more thing on call) | none you don't already run |
| latency | sub-millisecond pickup | polling interval (~100 ms+) — fine for almost everything |
| throughput ceiling | very high (memory-speed) | thousands of jobs/min before DB churn matters |
| transactional enqueue | manual discipline (after_commit) | native — same transaction as your data |
| ecosystem | batches, unique jobs, cron, Pro support | recurring jobs built in; younger ecosystem |
Your Graphiti ingestion is bursty and low-volume: dozens-to-hundreds of documents per run, each dominated by minutes-long LLM and embedding calls. Latency is irrelevant; operational simplicity on your own machine (Colima, one Docker daemon already) is everything — and you already run Postgres.
Start with Solid Queue. Adopt Sidekiq only when a measured number — queue depth, pickup latency, jobs/min — says so. In the interview, that sentence with the numbers attached is the senior answer: they score the constraint-to-choice narration, not the pick.
Open questions
- Q1Does Graphiti ingestion need a queue at all yet — or does your existing 7:30 cron pattern cover it until mysecondbrain has a second user?
- Q2What does GVL contention look like measured (Ruby 3.2+ GVL instrumentation,
gvl_timing) on your actual workload, instead of assumed? - Q3If mysecondbrain ever hosts other people's vaults: does the tenant key travel in job arguments or in middleware — and what did InfoTech's architecture get wrong?
solid border = note exists in your vault today · dashed = future entry, created when the pipeline sees you mention it again
- rev 12026-07-18 09:12 — page seeded from live assessment (4 probes, 2 blank)
- rev 209:19 — §2 rewritten around the GVL model + kitchen analogy
- rev 309:27 — InfoTech memory linked; §3 decoded; retrieval scheduled → 2026-07-21