mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-09 00:00:12 +09:00
462eff10df
Deleting the memberships with delete_all skips Membership's after_destroy_commit, which resets a member's connections when one membership is revoked. Until the job ran, a member who had the room open kept its streams, and a message that still landed in the room (a request already past the membership check, a bot's reply) reached them. The request now reads the members' ids in the transaction that deletes their memberships, and the job resets their connections before it destroys the messages. It costs a Redis round trip per member, about 1.5 s for 10,000, so it's done in the job rather than in the request. User#grant_membership_to_open_rooms read the open rooms and inserted in a separate statement, so a user created while a room was being closed could read it as open and be granted it after the close. It's now a single insert ... select, which SQLite runs under the write lock, skipping duplicates as insert_all did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
115 lines
3.4 KiB
Ruby
115 lines
3.4 KiB
Ruby
class Room < ApplicationRecord
|
|
has_many :memberships, dependent: :delete_all do
|
|
def grant_to(users)
|
|
room = proxy_association.owner
|
|
Membership.insert_all(Array(users).collect { |user| { room_id: room.id, user_id: user.id, involvement: room.default_involvement } })
|
|
end
|
|
|
|
def revoke_from(users)
|
|
destroy_by user: users
|
|
end
|
|
|
|
def revise(granted: [], revoked: [])
|
|
transaction do
|
|
grant_to(granted) if granted.present?
|
|
revoke_from(revoked) if revoked.present?
|
|
end
|
|
end
|
|
end
|
|
|
|
has_many :users, through: :memberships
|
|
has_many :messages, dependent: :destroy
|
|
|
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
|
|
|
validate :direct_rooms_keep_their_type, on: :update
|
|
|
|
scope :opens, -> { where(type: "Rooms::Open") }
|
|
scope :closeds, -> { where(type: "Rooms::Closed") }
|
|
scope :directs, -> { where(type: "Rooms::Direct") }
|
|
scope :without_directs, -> { where.not(type: "Rooms::Direct") }
|
|
|
|
scope :ordered, -> { order("LOWER(name)") }
|
|
|
|
class << self
|
|
def create_for(attributes, users:)
|
|
transaction do
|
|
create!(attributes).tap do |room|
|
|
room.memberships.grant_to users
|
|
end
|
|
end
|
|
end
|
|
|
|
def original
|
|
order(:created_at).first
|
|
end
|
|
end
|
|
|
|
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
|
|
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
|
|
# An open room is closed first, so that no one who joins the account before the job runs is let in.
|
|
def destroy_later
|
|
room = open? ? becomes!(Rooms::Closed) : self
|
|
|
|
former_member_ids = transaction do
|
|
room.save!
|
|
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
|
|
end
|
|
|
|
Room::DestroyJob.perform_later(room, former_member_ids)
|
|
end
|
|
|
|
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
|
|
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
|
|
# request, because it costs a Redis round trip per member.
|
|
def reset_remote_connections_of(user_ids)
|
|
User.where(id: user_ids).find_each(&:reset_remote_connections)
|
|
end
|
|
|
|
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
|
|
# meanwhile goes with the room.
|
|
def destroy_one_message_at_a_time
|
|
messages.find_each(&:destroy)
|
|
destroy
|
|
end
|
|
|
|
def receive(message)
|
|
unread_memberships(message)
|
|
push_later(message)
|
|
end
|
|
|
|
def open?
|
|
is_a?(Rooms::Open)
|
|
end
|
|
|
|
def closed?
|
|
is_a?(Rooms::Closed)
|
|
end
|
|
|
|
def direct?
|
|
is_a?(Rooms::Direct)
|
|
end
|
|
|
|
def default_involvement
|
|
"mentions"
|
|
end
|
|
|
|
private
|
|
# Open and closed rooms convert into each other freely. A direct room can't become
|
|
# either: its participants agreed to a private conversation, not to one whose
|
|
# audience someone else gets to widen afterwards.
|
|
def direct_rooms_keep_their_type
|
|
if type_changed? && type_was == "Rooms::Direct"
|
|
errors.add :type, "can't be changed for a direct room"
|
|
end
|
|
end
|
|
|
|
def unread_memberships(message)
|
|
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
|
end
|
|
|
|
def push_later(message)
|
|
Room::PushMessageJob.perform_later(self, message)
|
|
end
|
|
end
|