Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 25 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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-time and refuses X-Kaksha-Time; it must not be status; and a key that starts with rack. is a message to the server that is never sent to the client.
  • The body, which responds to each, or to call for a body that writes to the connection itself, and may also respond to to_ary. Once the response is done, the server must call the body’s close if 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:

lib/middleware/kaksha_time.rb Ruby · lab/lib/middleware/kaksha_time.rb
# 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:

config/application.rb Ruby · lab/config/application.rb
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:

Where KakshaTime landed, and what three requests show Ruby · sessions/timing_session.rb
# 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:

Interactive · message by message One response through KakshaTime, from call to close Step through GET /up as the timing session sent it. Each arrow is a call between two columns, labelled with what travels.
One response through KakshaTime, from call to close, step 10 of 10. Messages so far: Puma to KakshaTime: call(env); KakshaTime to Below it: call(env); Below it to KakshaTime: 200, headers, body; KakshaTime to Puma: 200, headers, proxy; Puma to Below it: each; Below it to Puma: 73 bytes; Puma to KakshaTime: close; KakshaTime to Below it: close.Pumathe serverKakshaTimetotal loggedBelow itclosedcall(env)call(env)200, headers, body200, headers, proxyeach73 bytescloseclose

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. 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.
  2. KakshaTime calls down. KakshaTime calls @app with the same env. Every layer below it runs in turn, down to the health check's action.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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

  1. Question 1 of 3 KakshaTime adds its timing header to the headers hash of a Rack 3 response. Which key may it use?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: x-kaksha-time

    The headers of a Rack 3 response are an unfrozen hash whose keys are strings without uppercase ASCII letters, not named status, and not starting with rack. unless they speak to the server. KakshaTime writes x-kaksha-time.

  2. Question 2 of 3 KakshaTime sits on line 10 of the development stack, below ActionDispatch::RequestId (line 9) and above ShowExceptions. Which responses carry x-kaksha-time? Choose all that apply.

    Choose every answer that is right.

    A hint

    A response gets the header only if it passes back through KakshaTime on its way out.

    Show the answer: Question 2 of 3

    Answer:

    • The 200 from /up
    • The 500 page for /boom, whose route raised an exception

    Position decides what a middleware sees. Everything below KakshaTime, the error pages of ShowExceptions included, comes back through it; a layer above it that answers on its own, like the host check, does not.

  3. Question 3 of 3 In the step-by-step part of the timing session, when did KakshaTime's log line print?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: After "the server closes the body", while body.close ran

    KakshaTime sets its header when the layers below return and logs the total when the body is closed, so a streamed body is timed to its end without being read early.

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:

lib/attendance_csv.rb Ruby · lab/lib/attendance_csv.rb
# 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:

config/routes.rb Ruby · lab/config/routes.rb
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:

Six requests: two mounts, a to: route and a controller Ruby · sessions/mount_session.rb
# 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

  1. Question 1 of 2 With mount AttendanceCsv.new, at: "/open-exports", what does the app find in PATH_INFO for GET /open-exports/attendance.csv?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: /attendance.csv

    A mounted app answers paths relative to its mount point: the router puts the prefix in SCRIPT_NAME and the rest in PATH_INFO. A to: route hands the app the whole path unchanged.

  2. Question 2 of 2 Without a cookie, /batches/7 answered 401 but /open-exports/attendance.csv answered 200 with the CSV. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: require_teacher is a before_action, and filters run only in controllers; the router calls a mounted app directly

    A mounted app brings none of your controllers' filters with it. The lab protects its second mount with a routing constraint that reads the signed cookie: no cookie, no matching route, a 404.

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 runner script, 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.

Interactive · stage by stage What the executor does around one request Step through GET /batches/7 from the executor session, from the run hooks to the complete hooks.
What the executor does around one request, step 7 of 7. Showing Executor runs (query cache on), Reloader (no change this time), Middleware and action (Current.teacher_id 7), Body sent and closed (closed), Executor completes (Current, cache, connections).an exceptionExecutor runsquery cache onReloaderno change this timeMiddleware and actionCurrent.teacher_id 7Body sent and closedclosedExecutor completesCurrent, cache, connections

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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".
  4. 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.
  5. 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.
  6. 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.
  7. 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, the reloader and two threads, seen from outside Ruby · sessions/executor_session.rb
# 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

  1. Question 1 of 2 In the executor session, what did the script see after Rails.application.call returned and before it closed the body?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Executor active true, query cache on, Current.teacher_id 7

    The run hooks ran before the request; the complete hooks wait for the body to be closed. In between, the request's Current attributes and query cache are still in place.

  2. Question 2 of 2 A thread started without the executor ran ActiveRecord::Base.connection.select_value and ended. How many dead connections did pool.stat count right after it?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1

    The thread leased a connection and died holding it, so the pool counted it as dead until the reaper takes it back. The thread wrapped in Rails.application.executor.wrap left an idle connection instead.

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:

Threads per process and connections per pool, as generated Ruby · sessions/threads_session.rb
# 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:

A memo, a class attribute, Current and Thread.current under two threads Ruby · thread_safety_bug.rb
# 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:

Interactive · message by message Two Puma threads and one class Step through the memo of part 1, then the class attribute of part 2, as thread_safety_bug.rb runs them.
Two Puma threads and one class, step 10 of 10. Messages so far: Thread A to The class: teacher(7); The class to Thread A: Asha; Thread B to The class: teacher(9); The class to Thread B: Asha; Thread A to The class: name = Pune; Thread B to The class: name = Nagpur; The class to Thread B: Nagpur; The class to Thread A: Nagpur.Thread APuneThe classper threadThread BNagpurteacher(7)Ashateacher(9)Ashaname = Punename = NagpurNagpurNagpur

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. B asks for teacher 9. A later request, for teacher 9, runs on another thread. ||= finds @teacher already set and skips the lookup.
  4. 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.
  5. 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".
  6. 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.
  7. 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.
  8. B renders Nagpur. B reads the attribute back and renders "batches of Nagpur", which is right for its teacher.
  9. 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".
  10. 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

  1. Question 1 of 2 In part 2 of thread_safety_bug.rb, thread A set Centre.name = "Pune" at the start of its request. What did it render, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: batches of Nagpur, because thread B wrote the same class attribute while A was busy

    State on a class is shared by every thread, so a value one request writes can be replaced by another request before it is read. Current keeps one set of attributes per thread, and part 3 rendered Pune.

  2. Question 2 of 2 With RAILS_MAX_THREADS unset, how many threads does the generated config/puma.rb give each Puma process of a new app?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 3 threads

    Line 28 reads threads_count = ENV.fetch("RAILS_MAX_THREADS", 3). The generated database.yml allows 5 connections under the same condition, so each thread can hold one.

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:

  1. In lab/config/application.rb, replace the insert_after line with config.middleware.use KakshaTime, which puts the timer at the bottom of the stack, just above the router. Run the timing session. Which responses still carry x-kaksha-time, and why does the 500 from /boom lose it?
  2. In lab/config/routes.rb, replace the /open-exports mount with get "open-exports/attendance.csv", to: AttendanceCsv.new and run the mount session. Predict its first line.
  3. In thread_safety_bug.rb, delete the first Executor.wrap do of part 4 and its end, so that the first request runs outside the executor. What does the second request find in Current.centre now?

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.

  1. Question 1 of 12 Which config.middleware call puts a new class directly below an existing one in the stack?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: insert_after

    The stack is listed top first, so "after" means below. KakshaTime went in with insert_after ActionDispatch::RequestId and landed on the next line.

  2. Question 2 of 12 A middleware must add a header with a checksum of the whole body. According to the Rack 3 SPEC, when may it read the body itself?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Only when the body responds to to_ary, and then it returns the array in the body's place

    A body that responds to to_ary is already complete in memory, so reading it costs nothing extra. A streamed body is not, and reading it early would buffer the whole response. Such a header simply cannot be known in advance.

  3. Question 3 of 12 In development, a security header must be on every response: pages, 500 error pages and the 403 that the host check sends. Where does its middleware go?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: config.middleware.insert_before ActionDispatch::HostAuthorization, the first entry of the stack

    A header that must be on every response belongs above every layer that may answer early. KakshaTime only needed the responses of requests that reach the app, so it could sit lower.

  4. Question 4 of 12 Where does an app mounted at /open-exports find the /open-exports part of a request's path?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: In SCRIPT_NAME

    SCRIPT_NAME plus PATH_INFO is the whole path. A mounted app uses PATH_INFO to route inside itself and SCRIPT_NAME to build links back to itself.

  5. Question 5 of 12 The recorded route get "files/attendance.csv", to: AttendanceCsv.new answered 404 with "no export at /files/attendance.csv". Why?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: to: hands the app the whole path, and the app only answers /attendance.csv

    Choose mount when the app should answer paths below a prefix, and to: when one route ends in an app that expects the full path.

  6. Question 6 of 12 Kaksha mounts a job dashboard engine at /jobs. ApplicationController has before_action :require_teacher. What stops a visitor without a cookie from opening /jobs?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Nothing yet; the mount needs a check of its own, such as a routing constraint on the signed cookie or the engine's own authentication

    Whatever you mount answers without your controllers' filters. Protect it where it is attached and test the refusal with a request that has no cookie, as the lesson's session does for /exports.

  7. Question 7 of 12 Which of these does Rails NOT wrap in the executor for you?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A Thread.new that an action starts

    The framework's entry points wrap themselves: requests, jobs, runner scripts and Action Cable messages. Threads you create are yours to wrap.

  8. Question 8 of 12 In development the executor session's edited action answered "Batch 7" without a restart. Why would the same edit on a production server change nothing until a restart?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Production sets config.enable_reloading to false, so the reloader only passes through to the executor

    The threading guide: the reloader checks for file changes only when config.enable_reloading and config.reload_classes_only_on_change are true, the development defaults.

  9. Question 9 of 12 An initializer starts a pool of four threads that run report code for requests. What should each task run inside?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Rails.application.executor.wrap

    A pooled thread runs task after task, just like a Puma thread runs request after request. The executor gives each task the same start and the same clean-up that Rails gives a request.

  10. Question 10 of 12 Which of these are shared by every Puma thread of a process? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 10 of 12

    Answer:

    • A teacher memoised in @teacher by a class method
    • A value set through cattr_accessor

    Classes, their configuration and globals are shared; the env, the controller instance and Current are per request. Part 1 of thread_safety_bug.rb shows a class memo leaking a teacher to the next request.

  11. Question 11 of 12 Part 2's bug never shows in a test that sends one request at a time. Why?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The wrong value appears only if another request writes the attribute between this request's write and its read

    A race condition needs an unlucky order of events. The example forces it with queues; a test that wants to catch such a bug has to force the order the same way.

  12. Question 12 of 12 RAILS_MAX_THREADS=10 is set for Puma and the generated database.yml. Each request also starts one thread that queries inside executor.wrap. At most, how many connections might one process need at the same moment?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: 20, so a pool of 10 is too small for this design

    Count every thread that may query at the same moment, the server's and your own, and size max_connections to that number, or make the extra threads share a smaller pool of work.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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 each and 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.
  • mount hands a Rack app the path below its prefix and to: 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.current values leak between requests; keep per-request data in the env, the controller or Current, and wrap every thread you start in Rails.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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.