From ca626ea7e24e397516fccfdb091cb47bf787d618 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 04:48:53 +0200 Subject: [PATCH 1/3] Find the messages a refresh replaces through an index Every room visit and every reconnection asks for the messages updated since the page was rendered. That query filtered the room by updated_at and sorted it by created_at, so SQLite walked every message in the room: about 220-360 ms in a room of a million messages. An index on (room_id, updated_at) finds the few updated messages directly. Sorting on +created_at keeps SQLite on that index once messages are also indexed by (room_id, created_at): with both, the planner otherwise walks the room in creation order looking for updated rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVFo3Lt9T8M5NR7KxVvsZ2 --- app/models/message/pagination.rb | 5 ++- ...oom_id_and_updated_at_index_to_messages.rb | 5 +++ db/schema.rb | 3 +- test/models/message/pagination_test.rb | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb create mode 100644 test/models/message/pagination_test.rb diff --git a/app/models/message/pagination.rb b/app/models/message/pagination.rb index 3be7983..52658b3 100644 --- a/app/models/message/pagination.rb +++ b/app/models/message/pagination.rb @@ -36,7 +36,10 @@ module Message::Pagination scope :page_after, ->(message) { after(message).first_page } scope :page_created_since, ->(time) { where("created_at > ?", time).first_page } - scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page } + # Sorting on +created_at, which no index covers, has SQLite find the few messages updated since + # through (room_id, updated_at) and sort just those, instead of walking the whole room by + # (room_id, created_at) looking for them. + scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) } end class_methods do diff --git a/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb b/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb new file mode 100644 index 0000000..e59196b --- /dev/null +++ b/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb @@ -0,0 +1,5 @@ +class AddRoomIdAndUpdatedAtIndexToMessages < ActiveRecord::Migration[8.2] + def change + add_index :messages, %i[ room_id updated_at ] + end +end diff --git a/db/schema.rb b/db/schema.rb index e7f1540..288fee5 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_022000) do create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.text "custom_styles" @@ -101,6 +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", "updated_at"], name: "index_messages_on_room_id_and_updated_at" t.index ["room_id"], name: "index_messages_on_room_id" end diff --git a/test/models/message/pagination_test.rb b/test/models/message/pagination_test.rb new file mode 100644 index 0000000..f186d8d --- /dev/null +++ b/test/models/message/pagination_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class Message::PaginationTest < ActiveSupport::TestCase + test "messages updated since a time are found through the room and update time index" do + assert_match(/USING INDEX index_messages_on_room_id_and_updated_at \(room_id=\? AND updated_at>\?\)/, plan_of_page_updated_since) + end + + test "messages updated since a time are the newest page of them, in creation order" do + room = rooms(:watercooler) + first, second, third = room.messages.ordered.first(3) + + travel 1.minute do + [ third, first, second ].each(&:touch) + + stub_const(Message::Pagination, :PAGE_SIZE, 2) do + assert_equal [ second, third ], room.messages.page_updated_since(30.seconds.ago).to_a + end + end + end + + private + def plan_of_page_updated_since + statements = [] + callback = ->(*, payload) { statements << payload[:sql] if payload[:sql].start_with?(%(SELECT "messages")) } + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + rooms(:watercooler).messages.page_updated_since(1.minute.ago) + end + + Message.connection.select_rows("EXPLAIN QUERY PLAN #{statements.sole}").map(&:last).join(" | ") + end +end From ad541b1c6ac0dc2cafa75251b2059ac32553de70 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Mon, 5 Oct 2026 13:59:49 +0200 Subject: [PATCH 2/3] Drop the query plan test Keep the test of what page_updated_since returns, which covers the rewritten scope; pinning SQLite's plan 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/pagination_test.rb | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/test/models/message/pagination_test.rb b/test/models/message/pagination_test.rb index f186d8d..261a9e9 100644 --- a/test/models/message/pagination_test.rb +++ b/test/models/message/pagination_test.rb @@ -1,10 +1,6 @@ require "test_helper" class Message::PaginationTest < ActiveSupport::TestCase - test "messages updated since a time are found through the room and update time index" do - assert_match(/USING INDEX index_messages_on_room_id_and_updated_at \(room_id=\? AND updated_at>\?\)/, plan_of_page_updated_since) - end - test "messages updated since a time are the newest page of them, in creation order" do room = rooms(:watercooler) first, second, third = room.messages.ordered.first(3) @@ -17,15 +13,4 @@ class Message::PaginationTest < ActiveSupport::TestCase end end end - - private - def plan_of_page_updated_since - statements = [] - callback = ->(*, payload) { statements << payload[:sql] if payload[:sql].start_with?(%(SELECT "messages")) } - ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do - rooms(:watercooler).messages.page_updated_since(1.minute.ago) - end - - Message.connection.select_rows("EXPLAIN QUERY PLAN #{statements.sole}").map(&:last).join(" | ") - end end From 839ea34c290ab04f597d9c10868eae38b21f2e29 Mon Sep 17 00:00:00 2001 From: Marcello Costagliola Date: Tue, 6 Oct 2026 21:55:54 +0200 Subject: [PATCH 3/3] Create the room and update time index only where it is missing The Rust port creates index_messages_on_room_id_and_updated_at on boot, under the same name, with CREATE INDEX IF NOT EXISTS (basecamp/once-campfire-rust#45). On a database the port has opened, this migration stopped db:prepare with "index ... already exists" and the app didn't start. With if_not_exists it skips an index that is already there and creates it everywhere else, and the dumped schema stays the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwoX7uRy5vFZSNKJhSqMSG --- ...261005022000_add_room_id_and_updated_at_index_to_messages.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb b/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb index e59196b..dd53cc5 100644 --- a/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb +++ b/db/migrate/20261005022000_add_room_id_and_updated_at_index_to_messages.rb @@ -1,5 +1,5 @@ class AddRoomIdAndUpdatedAtIndexToMessages < ActiveRecord::Migration[8.2] def change - add_index :messages, %i[ room_id updated_at ] + add_index :messages, %i[ room_id updated_at ], if_not_exists: true end end