From 5cb4316133fd47141527c91cd88b251bebafad9e Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 00:55:24 +0200 Subject: [PATCH 1/3] 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 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- ...oom_id_and_created_at_index_to_messages.rb | 6 +++++ db/schema.rb | 4 ++-- test/models/message_test.rb | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb diff --git a/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb b/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb new file mode 100644 index 0000000..58ff43f --- /dev/null +++ b/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index e7f1540..28ee7c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 5ffecea..6733110 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -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 From 74bdbb0764fcf75c77c51925ddc3116803cab21d Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 01:39:36 +0200 Subject: [PATCH 2/3] Keep the room_id index and date the migration in UTC The composite index doesn't serve everything the room_id one did. In SQLite that index is (room_id, rowid), so it also reads a room's messages in id order, and it is the narrower one to count a room with. Keeping both, as memberships already does, leaves those lookups as they were. The migration was named after local time, two hours ahead of the UTC timestamp Rails generates; it now carries a UTC one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C --- ...1004225000_add_room_id_and_created_at_index_to_messages.rb} | 1 - db/schema.rb | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) rename db/migrate/{20261005010000_add_room_id_and_created_at_index_to_messages.rb => 20261004225000_add_room_id_and_created_at_index_to_messages.rb} (79%) diff --git a/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb b/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb similarity index 79% rename from db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb rename to db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb index 58ff43f..a46b27b 100644 --- a/db/migrate/20261005010000_add_room_id_and_created_at_index_to_messages.rb +++ b/db/migrate/20261004225000_add_room_id_and_created_at_index_to_messages.rb @@ -1,6 +1,5 @@ class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2] def change add_index :messages, %i[ room_id created_at ] - remove_index :messages, :room_id end end diff --git a/db/schema.rb b/db/schema.rb index 28ee7c4..8a900cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_10_05_010000) do +ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.text "custom_styles" @@ -102,6 +102,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_010000) do t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_messages_on_creator_id" t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at" + t.index ["room_id"], name: "index_messages_on_room_id" end create_table "push_subscriptions", force: :cascade do |t| From e12651240ca179327144df35945e4cecf0806e53 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 13:51:56 +0200 Subject: [PATCH 3/3] Drop the query plan test The index is a schema change; a test that pins SQLite's plan for each pagination scope locks in more than the change needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9 --- test/models/message_test.rb | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 6733110..5ffecea 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -65,30 +65,7 @@ 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