Ruby on Rails Module 15 – Rails internals and extending Rails
Rack middleware, the executor and threads
Write a Rack 3 middleware that keeps streamed bodies intact, mount a Rack app safely, see what the executor resets per request, and keep Puma threads apart.
What you will learn
- Write a Rack 3 middleware that wraps the response body instead of reading it, and add it at the right place in the stack
- Route a path prefix to a Rack app with mount, and protect it with a routing constraint
- Explain what the executor and the reloader do before and after each request, job and thread you start
- Find and fix state that one Puma thread leaks into another thread's request
Before you start
What you’ll learn · Before you start
What you will learn
- Write a Rack 3 middleware that wraps the response body instead of reading it, and add it at the right place in the stack
- Route a path prefix to a Rack app with mount, and protect it with a routing constraint
- Explain what the executor and the reloader do before and after each request, job and thread you start
- Find and fix state that one Puma thread leaks into another thread's request
Before you start
On this page
A new Kaksha app answers up to three requests at the same moment, one on each of the 3 threads that the generated
config/puma.rb gives Puma, and those three requests share a single copy of every class in the process. Much of what
each request sees is decided around your actions rather than inside them: by middleware: A layer that wraps the app: it receives each request before the app, may change it or answer it, and receives the response on its way back out. More in “How Rails handles a request”,
by Rack apps mounted in the routes, and by the executor that sets things up before a request and clears them after it.
The work here sits one level below the controllers. You will write a timing middleware that keeps the Rack 3 rules for headers and bodies and put it in the right place in the stack, send a path to a Rack app of your own without losing the sign-in check, follow what the executor does before and after a request, and find state that one Puma thread leaks into another. Every output below is a recorded run on Rails 8.1.4 and Ruby 4.0.7.
A Rack 3 middleware hands the body back unread
Kaksha wants every response timed: a header, x-kaksha-time, that a browser’s network panel shows, and a log line with
the request id once the whole response has gone out. How a middleware is built from initialize(app) and call(env),
and why a stack is entered top down and left bottom up, is in
How Rails handles a request. This section is about what a Rack middleware owes
the server once it sits in a real stack.
The rules are in the Rack: The interface that Ruby web servers and frameworks share: the server calls one object with a hash describing the request, and gets back a status, headers and a body. More in “How Rails handles a request” specification, the SPEC.rdoc file of the rack gem (3.2.7 in this
app). A response is a non-frozen array of three values:
- The status, an integer of 100 or more.
- The headers, a mutable hash with string keys. A key must not contain an uppercase ASCII letter, so Rack 3 accepts
x-kaksha-timeand refusesX-Kaksha-Time; it must not bestatus; and a key that starts withrack.is a message to the server that is never sent to the client. - The body, which responds to
each, or tocallfor a body that writes to the connection itself, and may also respond toto_ary. Once the response is done, the server must call the body’scloseif it has one.
The rule that shapes a middleware comes next: middleware must not call each on the body itself. It may return a new
body that calls each on the original as the server reads it, and it may treat the body as a whole only when the body
responds to to_ary. The reason is that a Rack body: The third value of a Rack response: an object that responds to each (or to call, when it writes the stream itself), may respond to to_ary, and whose close the server must call when the response is done. Middleware wraps it instead of reading it. can be a stream. An export that yields
its rows one at a time sends the first row while the last is still being built; a middleware that joined the rows to
look at them would hold the whole file in memory and send nothing until the end.
KakshaTime does both of its jobs without touching the body:
# Kaksha's request timer, a Rack 3 middleware (required from config/application.rb).
# x-kaksha-time: how long the layers below took to hand back the status and the headers.
# A log line, once the server has sent the body and closed it: how long the whole response took.
require "rack/body_proxy"
class KakshaTime
HEADER = "x-kaksha-time" # Rack 3: header names are lower case
def initialize(app, logger: nil)
@app = app
@logger = logger
end
def call(env)
started = now
status, headers, body = @app.call(env)
headers[HEADER] = format("%.1fms", now - started)
# Never read the body here: it may still be streaming. Wrap it instead. The block runs when the
# server closes the body, and closing the wrapper closes the original body too.
body = Rack::BodyProxy.new(body) do
logger.info("request #{env["action_dispatch.request_id"]} finished in #{format("%.1fms", now - started)}")
end
[status, headers, body]
end
private
def logger
@logger || Rails.logger
end
def now
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
end
end The header is written as soon as the layers below return. The log line sits in the block of a Rack::BodyProxy, a
wrapper that, when the server closes it, closes the original body first and then runs the block. Rails’ own layers use
the same wrapper for work that must wait for the end of a response: in the 8.1.4 sources Rails::Rack::Logger,
Rack::TempfileReaper and the local cache middleware finish when the body is closed, and so does
ActionDispatch::Executor unless the server offers it a later moment, as the executor section shows.
The class lives in lib/middleware/ and is loaded with require, because the Rails on Rack guide warns that the
stack is loaded once and never reloaded; the autoloader is told to leave that folder alone. Two lines of
config/application.rb then change the stack:
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
# The middleware stack is built once, at boot, and never reloaded, so middleware is loaded with require.
require_relative "../lib/middleware/kaksha_time"
module Kaksha
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# lib/middleware is loaded by the require above, so the autoloader leaves that folder alone.
config.autoload_lib(ignore: %w[assets tasks middleware])
# Time each request below ActionDispatch::RequestId and above the layers that turn errors into pages.
config.middleware.insert_after ActionDispatch::RequestId, KakshaTime
# KakshaTime reports the time that Rack::Runtime's x-runtime header carried, so that layer goes.
config.middleware.delete Rack::Runtime
end
end insert_after ActionDispatch::RequestId, KakshaTime puts the timer directly below the layer that gives each request
its id, and delete Rack::Runtime removes the layer whose x-runtime header measured the same thing. The same
config.middleware object also has insert_before, use (the bottom of the stack), unshift (the top), swap (a
new class in an old one’s place), move_before and move_after; its delete passes silently when the class is not
in the stack, so a misspelt name leaves the layer where it was. Below
RequestId is the right place for three reasons: the request id is already in the env when KakshaTime runs, so the log
line can name it; ShowExceptions and DebugExceptions sit further down, so error pages pass through the timer and are
timed too; and the executor sits further up, so the timer’s work happens inside the request’s execution.
This session copies the lab files into the app, prints the lines of bin/rails middleware around KakshaTime and counts
the stack, then sends three requests in-process, the way Puma calls the app:
# KakshaTime in place: where `insert_after ActionDispatch::RequestId` put it in the development stack, how many
# entries the stack has now that Rack::Runtime is deleted, and the headers and log lines of three requests.
# Request ids are printed as "…" because each run makes new ones.
require_relative "session"
Session.run(<<~SH, masks: [[/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, "…"]])
cd ../kaksha
cp -R ../lab/. .
bin/rails middleware > tmp/stack.txt
grep -n -B3 -A1 KakshaTime tmp/stack.txt
grep -c '^use' tmp/stack.txt
bin/rails runner script/timing_check.rb
SH Output
$ cd ../kaksha $ cp -R ../lab/. . $ bin/rails middleware > tmp/stack.txt $ grep -n -B3 -A1 KakshaTime tmp/stack.txt 7-use ActiveSupport::Cache::Strategy::LocalCache::Middleware 8-use Rack::MethodOverride 9-use ActionDispatch::RequestId 10:use KakshaTime 11-use ActionDispatch::RemoteIp $ grep -c '^use' tmp/stack.txt 28 $ bin/rails runner script/timing_check.rb log: request … finished in …ms GET /up -> 200 header names: cache-control content-type etag referrer-policy server-timing x-content-type-options x-frame-options x-kaksha-time x-permitted-cross-domain-policies x-request-id x-xss-protection x-kaksha-time: …ms log: request … finished in …ms GET /boom -> 500, x-kaksha-time: …ms call returned 200 with x-kaksha-time …ms; the body is not read yet the server writes a chunk of 73 bytes the server closes the body log: request … finished in …ms closed
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby timing_session.rb
KakshaTime is line 10, between RequestId and RemoteIp. Line 8 is now Rack::MethodOverride straight after the local
cache: Rack::Runtime, which stood between them, is gone, so the stack has 28 use lines, as many as before, one in
and one out. The 200 from /up carries eleven header names, every one in lower case, with x-kaksha-time among them
and no x-runtime. The /boom route raised an exception, ShowExceptions turned it into a 500 page below the timer,
and that page still got the header. The last part calls the app as a server does: the status and the header come back
first, the body is read afterwards in one chunk of 73 bytes, and the log line appears only once the body is closed.
Step through the same response, one call at a time:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- Puma calls the stack. A request for /up reaches one of Puma's threads. Puma builds the Rack env and calls the outermost middleware; nine layers later the env reaches KakshaTime, which reads the clock.
- KakshaTime calls down. KakshaTime calls @app with the same env. Every layer below it runs in turn, down to the health check's action.
- Three values come back. The call returns the status 200, a hash of headers and a body. Nothing has read the body yet: for a streamed export, most of the work would still be ahead.
- Header set, body wrapped. KakshaTime writes x-kaksha-time into the headers, with the time the layers below took to return. It leaves the body alone and wraps it in a Rack::BodyProxy whose block will log the total time.
- Puma writes the head. The status, the headers and the proxy travel up to Puma, which writes the status line and the headers to the socket. The header can only describe the time up to this moment.
- Puma reads the body. Puma calls each on the proxy, and the proxy hands the call to the body inside it. Chunks go to the socket as they come; the recorded run wrote one chunk of 73 bytes.
- A chunk comes back. Each chunk reaches Puma unchanged. KakshaTime never held the body in memory, so a body that yields rows one at a time keeps streaming.
- Puma closes the body. With the last chunk sent, Puma calls close on the body it was given. The Rack specification makes that call mandatory, because layers use it to release what they still hold.
- The inner body closes first. KakshaTime's proxy closes the body inside it before anything else, so the layers below finish their own work for this response.
- The block logs the total. Then the proxy runs KakshaTime's block and logs the request id and the time of the whole response. In the recorded run the line printed only after "the server closes the body".
Reading the body to measure it
bytes = body.to_a.join.bytesize in a middleware works on a page and quietly breaks every streamed response: the
whole body is built in memory before the first byte leaves, and the original body may never be closed. Measure
inside a wrapper instead: count the bytes in an each that yields each chunk on, or log at close, as KakshaTime
does.
Checkpoint
Check: A Rack 3 middleware and its place in the stack
A mounted Rack app gets the rest of the path, and no filters
Kaksha’s attendance export needs no controller. It is a plain Rack app, an object whose call returns the three
values, with an Enumerator as its body so that the CSV leaves one row at a time:
# A Rack app of its own, outside the controllers: attendance as CSV, made one row at a time.
class AttendanceCsv
ROWS = [%w[roll present], %w[101 yes], %w[102 no]].freeze
def call(env)
unless env["PATH_INFO"] == "/attendance.csv"
return [404, { "content-type" => "text/plain" }, ["no export at #{env["PATH_INFO"]}\n"]]
end
rows = Enumerator.new { |out| ROWS.each { |row| out << "#{row.join(",")}\n" } }
[200, { "content-type" => "text/csv" }, rows]
end
end The router takes any such object as the end of a route, and the routing guide gives two ways to attach one. With
to:, the app becomes the endpoint of a single route and receives the path unchanged. With mount app, at: "/exports",
it becomes a mounted app: A Rack application attached to a path prefix in config/routes.rb with mount. The router moves the prefix into SCRIPT_NAME and passes the rest of the path in PATH_INFO; no controller filter runs for it. Engines are mounted the same way.: the router moves the prefix into SCRIPT_NAME and passes the
rest of the path in PATH_INFO, so the app answers paths relative to wherever it is mounted. A Rails engine is
attached in the same way, because an engine is a Rack app as well; a later lesson of this module,
Engines, generators and application templates, builds one. The
lab’s routes use both forms, and a third one that matters for security:
Rails.application.routes.draw do
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
get "up" => "rails/health#show", as: :rails_health_check
get "batches/:id", to: "batches#show"
get "boom", to: ->(env) { raise "the attendance service is down" }
# The mistake: a mounted app runs no controller filter, so this path has no sign-in check.
mount AttendanceCsv.new, at: "/open-exports"
# to: hands a Rack app the whole path; mount strips the prefix.
get "files/attendance.csv", to: AttendanceCsv.new
# The fix: the router itself asks for a signed-in teacher before the app is reached.
constraints ->(request) { request.cookie_jar.signed[:teacher_id].present? } do
mount AttendanceCsv.new, at: "/exports"
end
end The session sends six requests, with and without the signed cookie that Kaksha’s sign-in sets for a teacher:
# The routes of the lab's config/routes.rb: a Rack app mounted with no sign-in check, the same app behind to:,
# a controller action behind a before_action, and the app mounted inside a constraint that reads the signed cookie.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
cp -R ../lab/. .
bin/rails runner script/mount_check.rb
SH Output
$ cd ../kaksha $ cp -R ../lab/. . $ bin/rails runner script/mount_check.rb GET /open-exports/attendance.csv (no cookie) -> 200 "roll,present\n101,yes\n102,no\n" GET /files/attendance.csv (no cookie) -> 404 "no export at /files/attendance.csv\n" GET /batches/7 (no cookie) -> 401 (empty body) GET /batches/7 (signed in) -> 200 "batch 7" GET /exports/attendance.csv (no cookie) -> 404 (an HTML error page) GET /exports/attendance.csv (signed in) -> 200 "roll,present\n101,yes\n102,no\n"
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby mount_session.rb
| Route | What the session recorded |
|---|---|
mount at /open-exports |
The app saw /attendance.csv and sent the CSV, with no cookie at all |
get … to: the same app |
The app saw /files/attendance.csv and answered 404 |
batches#show, a controller |
401 with no cookie, 200 and batch 7 when signed in |
mount inside constraints |
404 with no cookie, as no route matched; the CSV when signed in |
The first two rows show the difference between the forms: mounted, the app saw /attendance.csv and sent its three
rows; behind to:, it saw the whole path and answered “no export at /files/attendance.csv”. The first and third rows
together show the danger. /batches/7 without a cookie got a 401, because require_teacher is a before_action of
ApplicationController. The mounted export got a 200 for the same request without a cookie, because filters belong to
controllers and the router calls a mounted app directly. The fix moves the check to the place where the app is
attached: a routing constraint that reads the signed cookie, a cookie whose value the Cookies API says users cannot tamper with.
Without the cookie no route matches, so the request gets a 404 and the export does not even show that it exists; with
it, the export answers as before.
A mounted app brings no filters with it
Whatever you mount, an engine’s admin pages, a job dashboard or a Rack app of your own, answers without the
before_action chain of your controllers. Put the check in front of it, in a routing constraint, in the app’s own
authentication or in a middleware that wraps the app before it is mounted, and test the refusal with a request that
has no session, as the session above does.
Checkpoint
Check: Mounted Rack apps and the filters they skip
The executor sets up and clears away every request and job
When Rails.application.call returned in the timing session, the request was not over: its body still had to be read
and closed. Rails ties its clean-up to that moment through the executor: The Rails wrapper around every call into application code, such as a request, a job or a runner script. Its run hooks prepare per-execution state such as the query cache; its complete hooks clear Current attributes and the query cache and return leased database connections.. The threading
guide describes it as the wrapper the framework puts around every call into application code, with two kinds of
callback: to_run before the code and to_complete after it. The places that wrap code this way are:
- every request, through
ActionDispatch::Executor, the fifth entry of the development stack and the third of the production one; - every Active Job job, which runs inside the reloader: The Rails wrapper that, when code reloading is enabled as in development, checks before a request or job whether watched files changed and, if so, unloads the app's classes so the new code runs. With reloading off it only runs the executor. and so inside the executor too;
- every
bin/rails runnerscript, unless it is started with--skip-executor; - every message handler of an Action Cable connection.
The guide lists what the callbacks do in a default app: they keep track of which threads are in a safe place for
reloading, switch the Active Record query cache on and off, return leased database connections to the
connection pool: The set of database connections Active Record keeps per process; a thread checks one out while it needs it, and a thread that finds none free waits, then raises ActiveRecord::ConnectionTimeoutError. More in “Profiling and tuning a Rails app” and limit the lifetime of internal caches. The 8.1.4 sources add a
complete hook that Kaksha’s sign-in depends on: Active Support clears every set of
Current attributes: A class built on ActiveSupport::CurrentAttributes that holds per-request context, such as the signed-in user, isolated to the request's thread and emptied by Rails before and after each request and job. More in “Sessions, cookies, flash and Current”. For a request, the complete hooks run when the server closes
the body. A server can offer a later moment: Puma 8 puts a rack.response_finished list into the env, and when that
list is there ActionDispatch::Executor adds its completion to it instead of wrapping the body, so Puma runs the hooks
after it has sent the response and closed the body.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Run hooks first. ActionDispatch::Executor is the fifth layer of the development stack. Before any layer below it runs, it calls run! on the executor, and Active Record's run hook switches the query cache on for this thread.
- The reloader checks files. In development ActionDispatch::Reloader comes further down. It asks the file watcher whether a watched file changed since the last request; if one did, it unloads the app's classes so this request runs the new code. Production leaves it out.
- The action runs. The request passes the other layers and the router, and the before_action stores the signed-in teacher in Current. The action renders "batch 7".
- Returned, not finished. Rails.application.call has returned, yet nothing is undone: the recorded run printed executor active true, query cache on and Current.teacher_id 7, because the executor waits for the body to be closed.
- Closed, so it completes. The server closes the body, and the executor's complete hooks run: Current is cleared, the query cache is switched off and emptied, and a connection the thread leased goes back to the pool.
- An exception completes it too. Had an exception escaped every layer below, ActionDispatch::Executor would report it and still complete the execution in its ensure block, so a failed request leaves no state behind either.
- The thread starts clean. After the close the recorded run printed executor active false, query cache off and Current.teacher_id nil. The next request on this Puma thread finds nothing left over from this one.
The executor session starts its script with --skip-executor, so the script itself stands outside any execution, as
a Puma thread does between requests. It sends one request and looks at the executor, the query cache and Current at
three moments, then edits the action and sends the request again, then starts two threads of its own:
# One request seen from outside: what the executor has set up while the body is still open, what its complete
# hooks have undone once the body is closed, the reloader picking up an edited action, and two threads of our own.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
cp -R ../lab/. .
bin/rails runner --skip-executor script/executor_check.rb
SH Output
$ cd ../kaksha $ cp -R ../lab/. . $ bin/rails runner --skip-executor script/executor_check.rb before the request: executor active false, query cache off, Current.teacher_id nil call returned 200: executor active true, query cache on, Current.teacher_id 7 body "batch 7" sent and closed: executor active false, query cache off, Current.teacher_id nil after the edit, the same request answers "Batch 7" after a thread without the executor: busy 0, dead 1, idle 0 after a thread inside executor.wrap: busy 0, dead 1, idle 1
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby executor_session.rb
Before the request nothing was active. After call returned, the executor was active, the query cache was on and
Current.teacher_id was still 7, set by the before_action; only the close cleared all three. The second request is
the reloader at work. The script replaced “batch “ with “Batch “ in the controller, and the next request answered
“Batch 7” with no restart: in development ActionDispatch::Reloader asks the file watcher whether a watched file
changed and, if one did, unloads the app’s classes before the request runs. The guide says it checks only when
config.enable_reloading and config.reload_classes_only_on_change are both true, as they are in development; with
reloading off, as in production, the reloader only passes through to the executor. Middleware is never reloaded, so a
change to KakshaTime still needs a restart.
The last two lines are about threads you start yourself. ActiveRecord::Base.connection, which Rails 8.1 marks as
soft-deprecated in favour of with_connection and lease_connection, leases a connection to the calling thread until
an executor completes. In a thread with no executor nothing completes, so the thread ended still holding it, and the
pool counted one dead connection; its reaper takes such connections back every 20 seconds by default in 8.1.4. Inside
Rails.application.executor.wrap the complete hook returned it, and the pool counted one idle connection. Queries made
through models check a connection out and back in around each query in 8.1, but much code in apps and gems still
leases one, which is why the guide asks for every thread you create, with Thread.new or a thread pool, to be wrapped
at once. Use the executor there, not the reloader: the guide warns that a reloader in a child thread can deadlock
against its parent.
Key idea
Rails clears up after a request when its body is closed, not when the action returns: the executor’s complete hooks
empty Current, switch the query cache off and give leased connections back. A thread you start gets the same care
only inside Rails.application.executor.wrap.
Checkpoint
Check: What the executor and the reloader do
Three Puma threads share every class in the process
Two lines of the generated configuration decide how much runs at once in one Kaksha process:
# How many requests one Kaksha process serves at once, and how many database connections each process may open:
# the two lines of the generated config files that decide it.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
grep -n 'threads' config/puma.rb | grep -v '#'
grep -n 'max_connections' config/database.yml
SH Output
$ cd ../kaksha
$ grep -n 'threads' config/puma.rb | grep -v '#'
28:threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
29:threads threads_count, threads_count
$ grep -n 'max_connections' config/database.yml
9: max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby threads_session.rb
Puma runs threads_count threads in each process, 3 unless RAILS_MAX_THREADS says otherwise, and its README says
that each request is served in a separate thread. On the standard Ruby only one thread runs Ruby code at a time,
because of the global VM lock, but a thread that waits for the database or another service lets the others run, which
is why three threads serve more than one. The pool may open max_connections connections, the name Rails 8.1 gives
the setting that older apps call pool: 5 unless the same variable is set, so each of the three threads can hold one
with two to spare. Threads you start yourself draw on the same pool, one connection each while they query.
Profiling and performance tuning reproduces the timeout that a pool
smaller than the threads produces.
The threading guide states the rule plainly: each request gets its own controller instance, while classes, their configuration and global variables are shared by every thread, and code that changes none of the shared things can mostly ignore that other threads exist. Sorted by that rule, a Kaksha request’s state looks like this:
| Per request | Shared by every thread |
|---|---|
The Rack env: The hash a web server builds for each request under Rack, holding the method, the path, the headers and the input, which every middleware and the app receive. More in “How Rails handles a request”, and with it params, headers and cookies |
Instance variables of a class, such as a memo in a class method |
The controller instance and its @variables |
Class attributes: cattr_accessor, class_attribute, attr_accessor on class << self |
| Local variables of an action or a job | Mutable objects in constants, and global variables |
Current attributes: one set per thread, emptied after every request |
Values in Thread.current, which a Puma thread keeps for the next request it serves |
The program below makes each kind of shared state fail on purpose. Two threads stand for two Puma threads, and queues force the unlucky order of events, so that the wrong value appears on every run:
# Shared state under a threaded server. Two threads stand for two of Puma's threads serving two teachers;
# queues force one order of events, so the same thing happens on every run.
require "active_support"
require "active_support/current_attributes"
require "active_support/execution_wrapper"
TEACHERS = { 7 => "Asha", 9 => "Ravi" }.freeze
# 1. A memo on the class: the first request fills it, and every later request, on any thread, reads it.
class SignedIn
def self.teacher(id) = (@teacher ||= TEACHERS.fetch(id))
end
puts "1. a memo on the class"
[7, 9].each { |id| Thread.new { puts " request for teacher #{id} sees #{SignedIn.teacher(id)}" }.join }
# 2. A class attribute set at the start of every request: another thread overwrites it halfway through.
class Centre
class << self
attr_accessor :name
end
end
def page_with_class_attribute(centre, meanwhile)
Centre.name = centre
meanwhile.call # the request does other work, and another thread runs
"batches of #{Centre.name}"
end
# 3. The fix: Current keeps one set of attributes for each thread.
class Current < ActiveSupport::CurrentAttributes
attribute :centre
end
def page_with_current(centre, meanwhile)
Current.centre = centre
meanwhile.call
"batches of #{Current.centre}"
end
# Thread A stops halfway; thread B serves a whole request; then thread A finishes.
def two_requests(page)
a_paused, b_done = Queue.new, Queue.new
a = Thread.new { send(page, "Pune", -> { a_paused << true; b_done.pop }) }
b = Thread.new { a_paused.pop; send(page, "Nagpur", -> {}).tap { b_done << true } }
puts " thread A asked for Pune and rendered #{a.value}"
puts " thread B asked for Nagpur and rendered #{b.value}"
end
puts "2. a class attribute set per request"
two_requests(:page_with_class_attribute)
puts "3. Current, in the same order of events"
two_requests(:page_with_current)
# 4. Puma's threads serve request after request. Rails registers this hook on its executor, which wraps every
# request, so Current starts empty each time; a value in Thread.current stays with the thread.
Executor = Class.new(ActiveSupport::ExecutionWrapper)
Executor.to_complete { ActiveSupport::CurrentAttributes.clear_all }
puts "4. one thread, two requests in a row"
Thread.new do
Executor.wrap do
Thread.current[:centre] = "Pune"
Current.centre = "Pune"
end
Executor.wrap do
puts " the next request finds Thread.current[:centre] = #{Thread.current[:centre].inspect}"
puts " the next request finds Current.centre = #{Current.centre.inspect}"
end
end.join Output
1. a memo on the class request for teacher 7 sees Asha request for teacher 9 sees Asha 2. a class attribute set per request thread A asked for Pune and rendered batches of Nagpur thread B asked for Nagpur and rendered batches of Nagpur 3. Current, in the same order of events thread A asked for Pune and rendered batches of Pune thread B asked for Nagpur and rendered batches of Nagpur 4. one thread, two requests in a row the next request finds Thread.current[:centre] = "Pune" the next request finds Current.centre = nil
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby thread_safety_bug.rb
In part 1 the request for teacher 9 saw Asha, teacher 7’s name: ||= filled @teacher once for the whole process,
and nothing ever empties it, so this leak needs no unlucky timing at all. Part 2 is the subtler case. The class
attribute was set at the start of each request, which looks safe, yet thread A asked for Pune and rendered “batches
of Nagpur”, because thread B wrote the same attribute while A was busy. A
race condition: A bug where two or more threads or requests reach the same shared state at the same moment and the outcome depends on which one goes first; typically fixed with a lock, an atomic operation, or a single statement that both reads and writes. like this appears only when two requests overlap, so it passes every
test that sends one request at a time. Part 3 runs the same order of events with Current.centre, and each thread
rendered its own centre. Part 4 is the reason Current, and not Thread.current, holds per-request data: the second
request on the same thread still found “Pune” in Thread.current[:centre], while the executor’s complete hook had
emptied Current. Step through parts 1 and 2:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- A asks for teacher 7. Part 1. A request for teacher 7 calls SignedIn.teacher(7). The memo @teacher is empty, so ||= looks Asha up and stores her on the class.
- A gets Asha. The first request gets the right teacher. Nothing will ever empty @teacher again: it lives as long as the class, which is as long as the process.
- B asks for teacher 9. A later request, for teacher 9, runs on another thread. ||= finds @teacher already set and skips the lookup.
- B gets Asha too. Ravi's request sees Asha. This leak needs no unlucky timing: every request after the first reads the first request's teacher.
- A sets the centre. Part 2 sets the class attribute at the start of every request instead. Thread A, for a Pune teacher, writes Centre.name = "Pune".
- A is busy elsewhere. Thread A goes off to do other work for its request, such as a query. In the example a queue holds it here, so the same order happens on every run.
- B overwrites the centre. Thread B starts a request for Nagpur and writes the same attribute. The process has one Centre.name, so Pune is gone.
- B renders Nagpur. B reads the attribute back and renders "batches of Nagpur", which is right for its teacher.
- A resumes and reads. Thread A resumes and reads Centre.name to render its page. It gets Nagpur: the recorded run printed "thread A asked for Pune and rendered batches of Nagpur".
- Current keeps one per thread. Part 3 runs the same order of events with Current.centre. Current keeps a separate set of attributes for each thread, so A renders Pune and B renders Nagpur.
thread safety: Code is thread-safe when several threads can run it at the same time and still get correct results. In a Rails app that means no mutable state on classes, constants or globals, and per-request data kept in the Rack env, arguments or Current. in a Rails app comes down to a few habits. Keep per-request data in
Current, which its API page keeps for the handful of values that nearly every action needs, the account and the
signed-in user among them, or pass it as an argument. Keep nothing mutable on classes, constants or globals; freeze what must be shared. Use a Mutex,
or a thread-safe structure from concurrent-ruby, only for a cache that really belongs to the whole process. Size the
pool to the threads. Settings kept per thread need the same care: the i18n guide warns that I18n.locale can leak
into later requests served by the same thread and recommends I18n.with_locale in an around_action, as
I18n, time zones and Indian number formats does.
Treating Thread.current as request storage
Thread.current[:centre] = params[:centre] in a before_action looks like per-request state, but Puma serves
request after request on the same three threads, so the next request on that thread starts with the old value, as
part 4 printed. Use an attribute of Current, which the executor empties when each request finishes.
Shared state is also where the framework is changing next:
Ractors in Rails main
Rails main, the next release, adds Rails::Application#ractorize! and a before_ractorize load hook, in which
frameworks, engines and gems prepare the state they share with Ractors.
Checkpoint
Check: State shared between Puma threads
Practise: change the lab and predict
Each task below changes one file of this lesson’s examples. Work on a copy of the examples/ folder, write down what
you expect before you run anything, then run the session or the program and compare:
- In
lab/config/application.rb, replace theinsert_afterline withconfig.middleware.use KakshaTime, which puts the timer at the bottom of the stack, just above the router. Run the timing session. Which responses still carryx-kaksha-time, and why does the 500 from/boomlose it? - In
lab/config/routes.rb, replace the/open-exportsmount withget "open-exports/attendance.csv", to: AttendanceCsv.newand run the mount session. Predict its first line. - In
thread_safety_bug.rb, delete the firstExecutor.wrap doof part 4 and itsend, so that the first request runs outside the executor. What does the second request find inCurrent.centrenow?
The questions that follow are built on the same recorded runs: the stack around KakshaTime, the six routed requests, the executor’s three moments and the two threads. Each choice explains itself once you answer:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does the Rails executor do around a request, and when do you call it yourself?
The executor wraps every piece of application code the framework runs: each request, through
ActionDispatch::Executor, each Active Job job and each runner script. Its run hooks switch the Active Record query
cache on. Its complete hooks run when the work is over, which for a request means after the server has closed the
response body: they clear Current attributes, switch the query cache off and return any database connection the thread
leased to the pool. In development the reloader also wraps requests and jobs, and reloads changed code before the work
starts. You wrap code yourself with Rails.application.executor.wrap when it runs in a thread you create, with
Thread.new or a thread pool; without it, a connection that thread leases stays checked out after the thread dies,
until the pool’s reaper takes it back.
Two longer questions start from these runs: a size header that never reaches a streamed export, and a memo on a class that shows one centre’s batches to another centre’s teachers.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Kaksha's new middleware adds x-kaksha-bytes, the body's size, whenever the body responds to to_ary. The header appears on every HTML page but never on the attendance export, which streams its CSV row by row. A teammate proposes calling body.to_a first so that the header is always set. Diagnose the gap and say what to do instead.
-
Mid to senior
Under Puma, teachers at one Kaksha centre now and then see another centre's batch list. The batch query is scoped by Centre.current, a class method that memoises the signed-in teacher's centre in @current. Debug the leak, fix it, and say how you would prove the fix with a test that fails before it and passes after.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What makes up a Rack 3 response?
A status, a hash of headers whose names are lower case, and a body the server reads with each and must close.
-
What may a Rack middleware do with the response body?
Set headers and wrap the body, calling each on the original as the server reads it. It may treat the body as a whole only when the body responds to to_ary, because a Rack body can be a stream.
-
Why does it matter where a middleware sits in the stack?
Its position decides what it sees. Placed below ActionDispatch::RequestId, a timing middleware has the request id, also times the error pages ShowExceptions makes, and runs inside the executor.
-
How do mount and to: differ when routing to a Rack app, and what do both leave out?
mount hands the app the part of the path below its prefix, while to: hands it the whole path. Neither runs a controller's filters, so a mounted app needs a check of its own, such as a routing constraint.
-
What does the Rails executor do around each request, job and runner script?
Its run hooks switch the query cache on. Its complete hooks run once the response body is closed: they clear Current, switch the query cache off and give leased database connections back. In development the reloader brings in changed code.
-
What leaks between requests that three Puma threads serve at the same time?
Anything stored on shared objects: memos and attributes on classes, mutable constants, globals and Thread.current values. Keep per-request data in the env, the controller or Current.
-
You start a thread of your own inside a Rails app. What must its work run inside?
Rails.application.executor.wrap, so the thread gets the same set-up and clean-up as a request.
Key takeaways
- A Rack 3 response is a status, a hash with lower-case header names and a body that the server reads with
eachand must close; a Rack middleware sets headers and wraps the body, and never reads a body it cannot treat as a whole. - Where a middleware sits decides what it sees: KakshaTime, below
ActionDispatch::RequestId, has the request id, times the error pages that ShowExceptions makes and runs inside the executor. mounthands a Rack app the path below its prefix andto:hands it the whole path, and neither runs a controller’s filters, so a mounted app needs a check of its own, such as a routing constraint.- The executor wraps every request, job and runner script, and its complete hooks run when the response body is closed: they clear Current, the query cache and leased connections, while the reloader brings in changed code in development.
- Puma’s threads share every class, so memos and attributes on classes, mutable constants, globals and
Thread.currentvalues leak between requests; keep per-request data in the env, the controller or Current, and wrap every thread you start inRails.application.executor.wrap.
Terms in this lesson
- executor
- The Rails wrapper around every call into application code, such as a request, a job or a runner script. Its run hooks prepare per-execution state such as the query cache; its complete hooks clear Current attributes and the query cache and return leased database connections.
- mounted app
- A Rack application attached to a path prefix in config/routes.rb with mount. The router moves the prefix into SCRIPT_NAME and passes the rest of the path in PATH_INFO; no controller filter runs for it. Engines are mounted the same way.
- Rack body
- The third value of a Rack response: an object that responds to each (or to call, when it writes the stream itself), may respond to to_ary, and whose close the server must call when the response is done. Middleware wraps it instead of reading it.
- reloader
- The Rails wrapper that, when code reloading is enabled as in development, checks before a request or job whether watched files changed and, if so, unloads the app's classes so the new code runs. With reloading off it only runs the executor.
- thread safety
- Code is thread-safe when several threads can run it at the same time and still get correct results. In a Rails app that means no mutable state on classes, constants or globals, and per-request data kept in the Rack env, arguments or Current.
References
- Rails on Rack (Ruby on Rails)
- Threading and Code Execution in Rails (Ruby on Rails)
- Rails Routing from the Outside In (Ruby on Rails)
- Configuring Rails Applications (Ruby on Rails)
- Autoloading and Reloading Constants (Ruby on Rails)
- Rails Internationalization (I18n) API (Ruby on Rails)
- Rack Specification (rack 3.2.7, SPEC.rdoc) (Rack)
- ActiveSupport::CurrentAttributes (Rails 8.1.4 API) (Ruby on Rails)
- ActiveSupport::ExecutionWrapper (Rails 8.1.4 API) (Ruby on Rails)
- ActionDispatch::Cookies (Rails 8.1.4 API) (Ruby on Rails)
- ActionDispatch::MiddlewareStack (Rails 8.1.4 API) (Ruby on Rails)
- ActionDispatch::Executor, actionpack 8.1.4 source (Ruby on Rails)
- Active Record connection pool, activerecord 8.1.4 source (Ruby on Rails)
- Puma README (8.0.2) (Puma)
- Railties changelog (main branch) (Ruby on Rails)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress