Bound native fragment caching and collapse concurrent page renders

This commit is contained in:
GPT on behalf of DHH
2026-10-07 21:38:36 +02:00
parent b220486c16
commit 8d02540e31
7 changed files with 254 additions and 30 deletions
+52 -28
View File
@@ -9,14 +9,28 @@ module CachedResponses
end
def perform_caching
super && !@rendering_uncached_response
super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
end
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
def cache_store
FragmentCache.store
end
def combined_fragment_cache_key(key)
@fragment_cache_namespace ||= [
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
Current.user&.id, Current.session&.token,
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
].freeze
super([ @fragment_cache_namespace, key ])
end
private
def capture_response_cache_version
if request.get? && ResponseCache.instance.budget.positive?
@response_cache_version = ResponseCache.instance.version
end
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
# Detached renderers do not run callbacks and therefore render uncached.
@response_cache_version = ResponseCache.instance.version
end
# Register after room authorization, but before presentation queries.
@@ -26,27 +40,47 @@ module CachedResponses
key = response_cache_key
original_session = session.to_hash.deep_dup
if key.bytesize <= ResponseCache::MAX_KEY_BYTES && (entry = ResponseCache.instance.read(key, @response_cache_version))
response.headers.merge!(entry[:headers])
self.response_body = entry[:body].gsub(entry[:marker], token)
else
render_fresh_response { yield }
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
# Replace only framework token attributes, never a matching token in
# message text. Postprocessing also leaves fragment caches untouched.
body = csrf_neutral_body(response.body, marker)
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
entry = ResponseCache.instance.read(key, @response_cache_version)
rendered = false
unless entry
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
entry = ResponseCache.instance.read(key, @response_cache_version)
if !entry && ResponseCache.instance.version == @response_cache_version
yield
rendered = true
cache_completed_response(key, original_session)
end
end
end
if entry
response.headers.merge!(entry[:headers])
self.response_body = entry[:body].gsub(entry[:marker], token)
elsif !rendered
# A queued request retains its pre-auth snapshot. If it has expired,
# render outside the stripe instead of blocking the next generation.
yield
end
else
render_fresh_response { yield }
yield
end
end
def cache_completed_response(key, original_session)
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
# Replace only framework token attributes, never a matching token in
# message text. Postprocessing also leaves fragment caches untouched.
body = csrf_neutral_body(response.body, marker)
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
end
end
def cacheable_read_request?
@response_cache_version && request.get? && request.format.html? && Current.session &&
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
!authenticated_by.bot_key? && flash.empty? &&
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
!Rails.application.config.content_security_policy_nonce_generator &&
@@ -64,16 +98,6 @@ module CachedResponses
])
end
def render_fresh_response
# Foreign SQL can change content without bumping fragment timestamps.
# A whole-page miss renders fresh instead of creating unbounded Redis
# fragment namespaces for every database commit.
@rendering_uncached_response = true
yield
ensure
@rendering_uncached_response = false
end
def csrf_neutral_body(body, marker)
body.gsub(CSRF_TAG) do |tag|
attribute = tag.start_with?("<meta") ? "content" : "value"
+9
View File
@@ -0,0 +1,9 @@
# Native view caches share one byte budget across every database generation.
class FragmentCache
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
private_constant :STORE
def self.store
STORE
end
end
+10 -1
View File
@@ -7,13 +7,14 @@ class ResponseCache
MAX_KEY_BYTES = 2.kilobytes
def self.instance
@instance ||= new
INSTANCE
end
def initialize
@mutex = Mutex.new
@entries = {}
@bytes = 0
@render_locks = Array.new(16) { Mutex.new }
end
def budget
@@ -36,6 +37,12 @@ class ResponseCache
nil
end
# Collapse cold renders without retaining a mutex for every viewer or URL.
# Rendering must never hold the observer/entry mutex: controllers read it too.
def synchronize_render(key, version, &block)
@render_locks[[ key, version ].hash % @render_locks.length].synchronize(&block)
end
def write(key, version, entry)
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
return if key.bytesize > MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min
@@ -86,4 +93,6 @@ class ResponseCache
end
[ @database, @namespace, @version ]
end
INSTANCE = new
end