20 January 2025
I recently updated this blog from an antique Rails version to the latest, in part so I could use Thruster.
This site uses Rails full page caching. This works by writing a copy of the rendered page into the public folder of the Rails app. When a request comes in, a middleware checks if a cached response exists and returns it rather than invoking the Rails controller.
The Rails stack serves this cached page via x-sendfile if the upstream proxy supports it, which Thruster does.
Rails serves all static files using the static middleware, which includes images, assets and cached files.
This middleware has a single cache header setting, which is controlled by the following setting in application.rb, and caches all files for 1 year:
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
Thruster also performs asset caching, and if Rails returns a cache header for a request, Thruster will store the content in memory and serve the next request from its own cache instead of sending it to Rails.
Requests served with x-sendfile and a cache header appear to be a bit more complex in Thruster. I found an issue where Thruster appears to cache the x-sendfile header. Later if the cached file is expired Thruster throws a 404 if it cannot find the original file.
Aside from the problem, I don't want the full pages to be cached upstream indefinitely, as then any edits would be somewhat invisible. So, we need a way to avoid setting a cache header on "Full Page Cached" files, while retaining the cache header on static assets.
Out of the box, Rails cannot do this, but we can make it work by adding another middleware.
First, disable all cache headers for the static middleware, and configure our new middleware:
# application.rb
# Remove or comment out the default cache headers
# config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Default all static files to no cache for full page caching.
config.public_file_server.headers = { "cache-control" => "max-age=0, private, must-revalidate" }
# Add a new middleware before the Static file server. Then reinstate the cache header for asset
# paths
config.middleware.insert_before ActionDispatch::Static, StaticFileHeaderOverride, /^\/assets\/.*/,
{ "cache-control" => "public, max-age=#{1.year.to_i}" }
Note the original config.public_file_server.headers
was defined in development.rb
and production.rb
, but I centralized the setting in application.rb
.
I added the code for the new middleware into lib/middleware/static_file_header_override.rb
:
class StaticFileHeaderOverride
# Pass a pattern to match the paths we want to override the heads on, and the
# headers to merge in, which will overwrite any existing with the same name.
def initialize(app, pattern, headers)
@app = app
@pattern = pattern
@headers = headers
end
def call(env)
# Call the next middleware, and apply any overrides as the request is returned.
status, headers, response = @app.call(env)
if @pattern.match? env['REQUEST_PATH']
headers.merge! @headers
end
[status, headers, response]
end
+end
This also required adding the new middleware
folder to the autoload_lib setting in application.rb
:
- config.autoload_lib(ignore: %w[assets tasks])
+ config.autoload_lib(ignore: %w[assets tasks middleware])
Now, only static files under /assets get a cache header, and all others return no-cache.