From c761763c8c3ab5a38ecaddaaf3e8007cfd602f66 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 16:40:49 +0200 Subject: [PATCH] Cache boosts with their message instead of one by one Every boost had its own fragment, nested inside the message fragment that already holds it. With a cold cache, a read and a write per boost plus a read_multi and a write_multi per message: a room page of 40 messages with 120 boosts takes ~410 ms instead of ~290 ms. Redis runs without persistence (config/redis.conf), so every page is cold after a restart. A boost touches its message, so adding or removing one rewrites the message fragment anyway. The inner fragments only paid off when a message with many boosts was drawn again, and there they hid a lookup per booster. The boosters are now loaded with the boosts, one query per message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9 --- app/views/messages/boosts/_boost.html.erb | 34 +++++++++---------- app/views/messages/boosts/_boosts.html.erb | 2 +- .../messages/boosts_controller_test.rb | 17 ++++++++++ test/controllers/messages_caching_test.rb | 26 ++++++++++++++ 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/app/views/messages/boosts/_boost.html.erb b/app/views/messages/boosts/_boost.html.erb index b264fe6..a4b374b 100644 --- a/app/views/messages/boosts/_boost.html.erb +++ b/app/views/messages/boosts/_boost.html.erb @@ -1,20 +1,18 @@ -<% cache boost do %> -
-
- <%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %> -
+
+
+ <%= avatar_tag boost.booster, aria: { label: "#{boost.booster.name} boosted #{boost.content}" } %> +
- <%= tag.span boost.content, role: "button", - class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ], - data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %> + <%= tag.span boost.content, role: "button", + class: [ "txt-small", { "txt-medium": boost.content.all_emoji? } ], + data: { action: "click->boost-delete#reveal keydown.enter->boost-delete#reveal:prevent", boost_delete_target: "content" } %> - <%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" }, - class: "btn btn--negative flex-item-justify-end boost__delete" do %> - <%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %> - Delete this boost - <% end %> -
- Press enter to delete this boost -<% end %> + <%= button_to message_boost_path(boost.message, boost), method: :delete, data: { action: "boost-delete#perform", boost_delete_target: "button" }, + class: "btn btn--negative flex-item-justify-end boost__delete" do %> + <%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %> + Delete this boost + <% end %> +
+Press enter to delete this boost diff --git a/app/views/messages/boosts/_boosts.html.erb b/app/views/messages/boosts/_boosts.html.erb index d1a340a..ca25d74 100644 --- a/app/views/messages/boosts/_boosts.html.erb +++ b/app/views/messages/boosts/_boosts.html.erb @@ -2,7 +2,7 @@
- <%= render partial: "messages/boosts/boost", collection: message.boosts.ordered, cached: true %> + <%= render partial: "messages/boosts/boost", collection: message.boosts.ordered.includes(:booster) %>
<%= turbo_frame_tag message, :new_boost do %> diff --git a/test/controllers/messages/boosts_controller_test.rb b/test/controllers/messages/boosts_controller_test.rb index 8cc7de9..733741e 100644 --- a/test/controllers/messages/boosts_controller_test.rb +++ b/test/controllers/messages/boosts_controller_test.rb @@ -13,6 +13,15 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest assert_select ".message__boost-inline a.boost__action[data-action='soft-keyboard#open']" end + test "index looks up the boosters of all boosts at once" do + @message.boosts.create! booster: users(:jason), content: "🎉" + queries_with_two_boosts = count_queries { get message_boosts_url(@message) } + + boost = @message.boosts.create! booster: users(:kevin), content: "👀" + assert_equal queries_with_two_boosts, count_queries { get message_boosts_url(@message) } + assert_select "#" + dom_id(boost) + end + test "create" do assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do assert_difference -> { @message.boosts.count }, 1 do @@ -30,4 +39,12 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest end end end + + private + def count_queries(&block) + count = 0 + counter = ->(*, payload) { count += 1 unless payload[:name] == "SCHEMA" || payload[:cached] } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block) + count + end end diff --git a/test/controllers/messages_caching_test.rb b/test/controllers/messages_caching_test.rb index 131a078..3a7c4c8 100644 --- a/test/controllers/messages_caching_test.rb +++ b/test/controllers/messages_caching_test.rb @@ -27,6 +27,32 @@ class MessagesCachingTest < ActionDispatch::IntegrationTest end end + test "boosts are cached inside their message instead of one fragment each" do + with_memory_cache do + cache_keys = [] + subscriber = ActiveSupport::Notifications.subscribe(/\Acache_(read|read_multi|write|write_multi)\.active_support\z/) do |*, payload| + cache_keys.concat(payload[:key].is_a?(Hash) ? payload[:key].keys : Array(payload[:key])) + end + + get room_messages_url(rooms(:watercooler)) + assert_response :success + assert_select "#" + dom_id(boosts(:fourth_by_bender)) + assert_select "#" + dom_id(boosts(:thirteenth)) + assert_empty cache_keys.grep(%r{messages/boosts/_boost}) + + boost = messages(:fourth).boosts.create! booster: users(:jason), content: "🎉" + get room_messages_url(rooms(:watercooler)) + refreshed = response.body + + Rails.cache.clear + get room_messages_url(rooms(:watercooler)) + assert_equal refreshed, response.body + assert_select "##{dom_id(messages(:fourth))} ##{dom_id(boost)}", text: /🎉/ + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end + end + private def with_memory_cache old_cache = Rails.cache