Delete a room's messages in a job, one transaction each

Room#destroy destroyed every message inside the room's own
transaction, which holds SQLite's write lock until the last one: on a
room with many messages, every other write in the app waited and
failed. The request now takes the room away from its members and
leaves the rest to Room::DestroyJob, which destroys the messages one
at a time, each in its own short transaction, and then the room.

An open room is closed in the request, so that someone who joins the
account before the job ends isn't given it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
This commit is contained in:
Marcello Costagliola
2026-10-05 20:41:55 +02:00
parent 32b4144b52
commit 7b16014f01
6 changed files with 93 additions and 5 deletions
@@ -25,8 +25,10 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
sign_in :kevin
assert_difference -> { Room.count }, -1 do
delete rooms_direct_url(rooms(:david_and_kevin))
assert_redirected_to root_url
perform_enqueued_jobs do
delete rooms_direct_url(rooms(:david_and_kevin))
assert_redirected_to root_url
end
end
end
+14 -2
View File
@@ -83,11 +83,23 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
test "destroy" do
assert_turbo_stream_broadcasts :rooms, count: 1 do
assert_difference -> { Room.count }, -1 do
delete room_url(rooms(:designers))
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
end
end
end
test "destroy takes the room away from its members at once and leaves its messages to a job" do
room = rooms(:designers)
assert_enqueued_with(job: Room::DestroyJob, args: [ room ]) do
assert_no_difference -> { Message.count } do
delete room_url(room)
assert_redirected_to root_url
end
end
assert_empty room.memberships.reload
end
test "destroy only allowed for creators or those who can administer" do
sign_in :jz
@@ -99,7 +111,7 @@ class RoomsControllerTest < ActionDispatch::IntegrationTest
rooms(:designers).update! creator: users(:jz)
assert_difference -> { Room.count }, -1 do
delete room_url(rooms(:designers))
perform_enqueued_jobs { delete room_url(rooms(:designers)) }
end
end
+46
View File
@@ -30,8 +30,54 @@ class RoomTest < ActiveSupport::TestCase
assert Rooms::Closed.new.closed?
end
test "an open room destroyed later lets no one in who joins the account before the job runs" do
room = rooms(:pets)
room.destroy_later
newcomer = User.create!(name: "Newcomer", email_address: "newcomer@example.com", password: "secret123456")
assert_not newcomer.memberships.exists?(room_id: room.id)
perform_enqueued_jobs only: Room::DestroyJob
assert_not Room.exists?(room.id)
end
test "destroying one message at a time leaves nothing of the room behind" do
room = rooms(:designers)
searchable = room.messages.create!(body: "Kept in the search index", creator: users(:david))
searchable.attachment.attach io: StringIO.new("hello"), filename: "hello.txt", content_type: "text/plain"
message_ids = room.messages.ids
assert Boost.where(message_id: message_ids).exists?
assert_equal 1, search_index_rows(searchable)
room.destroy_one_message_at_a_time
assert_not Room.exists?(room.id)
assert_empty Message.where(id: message_ids)
assert_empty Boost.where(message_id: message_ids)
assert_empty ActionText::RichText.where(record_type: "Message", record_id: message_ids)
assert_empty ActiveStorage::Attachment.where(record_type: "Message", record_id: message_ids)
assert_enqueued_jobs 1, only: ActiveStorage::PurgeJob
assert_equal 0, search_index_rows(searchable)
end
test "each message is destroyed in its own transaction, so other writes get through in between" do
room = rooms(:designers)
messages = room.messages.count
transactions = 0
count_transactions = ->(*, payload) { transactions += 1 if payload[:sql].start_with?("RELEASE SAVEPOINT") }
ActiveSupport::Notifications.subscribed(count_transactions, "sql.active_record") { room.destroy_one_message_at_a_time }
assert_equal messages + 1, transactions
end
test "default involvement for new users" do
room = Rooms::Closed.create_for({ name: "Hello!", creator: users(:david) }, users: [ users(:kevin), users(:david) ])
assert room.memberships.all? { |m| m.involved_in_mentions? }
end
private
def search_index_rows(message)
Message.connection.select_value("select count(*) from message_search_index where rowid = #{message.id}")
end
end