Index messages by room and creation time

Every page of a room's messages (opening the room, scrolling back, the
refresh after reconnecting, the bot API) selects WHERE room_id = ? ORDER BY
created_at LIMIT 40. With only the room_id index, SQLite reads every
message in the room and sorts them in a temporary B-tree to return 40, so
these requests grow with the room's history.

A composite index on (room_id, created_at) lets SQLite read the 40 rows in
order straight from the index. It covers every lookup the room_id index
served, so that one goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
This commit is contained in:
Marcello Costagliola
2026-10-05 00:55:24 +02:00
parent 254dd1d46f
commit 5cb4316133
3 changed files with 31 additions and 2 deletions
@@ -0,0 +1,6 @@
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id created_at ]
remove_index :messages, :room_id
end
end
Generated
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
ActiveRecord::Schema[8.2].define(version: 2026_10_05_010000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -101,7 +101,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
t.integer "room_id", null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_messages_on_creator_id"
t.index ["room_id"], name: "index_messages_on_room_id"
t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at"
end
create_table "push_subscriptions", force: :cascade do |t|
+23
View File
@@ -65,7 +65,30 @@ class MessageTest < ActiveSupport::TestCase
assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id)
end
test "pages read a room's messages from an index instead of sorting the whole room" do
room, message = rooms(:watercooler), messages(:sixth)
statements = capture_message_selects do
room.messages.last_page
room.messages.page_before(message)
room.messages.page_after(message)
room.messages.page_created_since(1.day.ago)
end
assert_equal 4, statements.size
statements.each do |sql|
plan = Message.connection.select_rows("EXPLAIN QUERY PLAN #{sql}").map(&:last).join(" | ")
assert_no_match(/TEMP B-TREE/, plan, sql)
end
end
private
def capture_message_selects(&block)
statements = []
callback = ->(*, payload) { statements << payload[:sql] if payload[:sql].start_with?(%(SELECT "messages")) }
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
statements
end
def create_new_message_in(room)
room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123")
end