mirror of
https://github.com/discourse/discourse.git
synced 2025-06-04 11:11:13 +08:00
DEV: moves logic from job to a service (#22691)
`Jobs::AutoJoinChannelBatch` was holding a lot of logic which should be in a service. Moreover, this refactoring is the opportunity to address a bug which could cause a duplicate key error. From now when trying to insert a new membership it won't fail if a membership is already present. Example error: ``` Job exception: ERROR: duplicate key value violates unique constraint "user_chat_channel_unique_memberships" DETAIL: Key (user_id, chat_channel_id)=(1, 2) already exists. Backtrace rack-mini-profiler-3.1.0/lib/patches/db/pg.rb:110:in `exec' rack-mini-profiler-3.1.0/lib/patches/db/pg.rb:110:in `async_exec' (eval):29:in `async_exec' mini_sql-1.4.0/lib/mini_sql/postgres/connection.rb:209:in `run' mini_sql-1.4.0/lib/mini_sql/active_record_postgres/connection.rb:38:in `block in run' mini_sql-1.4.0/lib/mini_sql/active_record_postgres/connection.rb:34:in `block in with_lock' activesupport-7.0.5.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt' activesupport-7.0.5.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize' activesupport-7.0.5.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt' activesupport-7.0.5.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize' mini_sql-1.4.0/lib/mini_sql/active_record_postgres/connection.rb:34:in `with_lock' mini_sql-1.4.0/lib/mini_sql/active_record_postgres/connection.rb:38:in `run' mini_sql-1.4.0/lib/mini_sql/postgres/connection.rb:64:in `query_single' /var/www/discourse/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb:38:in `execute' ``` Note this commit is also using main branch of `shoulda-matchers` as the gem has not been released yet. Co-authored-by: Loïc Guitaut <5648+Flink@users.noreply.github.com>
This commit is contained in:
@ -1,79 +0,0 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
class AutoJoinChannelBatch < ::Jobs::Base
|
||||
def execute(args)
|
||||
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
|
||||
start_user_id = args[:starts_at].to_i
|
||||
end_user_id = args[:ends_at].to_i
|
||||
|
||||
return "End is higher than start" if end_user_id < start_user_id
|
||||
|
||||
channel =
|
||||
ChatChannel.find_by(
|
||||
id: args[:chat_channel_id],
|
||||
auto_join_users: true,
|
||||
chatable_type: "Category",
|
||||
)
|
||||
|
||||
return if !channel
|
||||
|
||||
category = channel.chatable
|
||||
return if !category
|
||||
|
||||
query_args = {
|
||||
chat_channel_id: channel.id,
|
||||
start: start_user_id,
|
||||
end: end_user_id,
|
||||
suspended_until: Time.zone.now,
|
||||
last_seen_at: 3.months.ago,
|
||||
channel_category: channel.chatable_id,
|
||||
permission_type: CategoryGroup.permission_types[:create_post],
|
||||
everyone: Group::AUTO_GROUPS[:everyone],
|
||||
mode: Chat::UserChatChannelMembership.join_modes[:automatic],
|
||||
}
|
||||
|
||||
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
|
||||
# Only do this if we are running auto-join for a single user, if we
|
||||
# are doing it for many then we should do it after all batches are
|
||||
# complete for the channel in Jobs::AutoJoinChannelMemberships
|
||||
if start_user_id == end_user_id
|
||||
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
|
||||
end
|
||||
|
||||
Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_memberships_query(category)
|
||||
query = <<~SQL
|
||||
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
|
||||
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
|
||||
FROM users
|
||||
INNER JOIN user_options uo ON uo.user_id = users.id
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
|
||||
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
|
||||
|
||||
LEFT OUTER JOIN group_users gu ON gu.user_id = users.id
|
||||
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id AND
|
||||
cg.permission_type <= :permission_type
|
||||
|
||||
WHERE (users.id >= :start AND users.id <= :end) AND
|
||||
users.staged IS FALSE AND
|
||||
users.active AND
|
||||
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
|
||||
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
|
||||
(last_seen_at IS NULL OR last_seen_at > :last_seen_at) AND
|
||||
uo.chat_enabled AND
|
||||
uccm.id IS NULL AND
|
||||
(NOT EXISTS(SELECT 1 FROM category_groups WHERE category_id = :channel_category)
|
||||
OR EXISTS (SELECT 1 FROM category_groups WHERE category_id = :channel_category AND group_id = :everyone AND permission_type <= :permission_type)
|
||||
OR cg.category_id = :channel_category)
|
||||
|
||||
RETURNING user_chat_channel_memberships.user_id
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
@ -1,81 +1,17 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Jobs
|
||||
module Chat
|
||||
class AutoJoinChannelBatch < ::Jobs::Base
|
||||
class AutoJoinChannelBatch < ServiceJob
|
||||
def execute(args)
|
||||
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
|
||||
start_user_id = args[:starts_at].to_i
|
||||
end_user_id = args[:ends_at].to_i
|
||||
|
||||
return "End is higher than start" if end_user_id < start_user_id
|
||||
|
||||
channel =
|
||||
::Chat::Channel.find_by(
|
||||
id: args[:chat_channel_id],
|
||||
auto_join_users: true,
|
||||
chatable_type: "Category",
|
||||
)
|
||||
|
||||
return if !channel
|
||||
|
||||
category = channel.chatable
|
||||
return if !category
|
||||
|
||||
query_args = {
|
||||
chat_channel_id: channel.id,
|
||||
start: start_user_id,
|
||||
end: end_user_id,
|
||||
suspended_until: Time.zone.now,
|
||||
last_seen_at: 3.months.ago,
|
||||
channel_category: channel.chatable_id,
|
||||
permission_type: CategoryGroup.permission_types[:create_post],
|
||||
everyone: Group::AUTO_GROUPS[:everyone],
|
||||
mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
|
||||
}
|
||||
|
||||
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
|
||||
|
||||
# Only do this if we are running auto-join for a single user, if we
|
||||
# are doing it for many then we should do it after all batches are
|
||||
# complete for the channel in Jobs::Chat::AutoJoinChannelMemberships
|
||||
if start_user_id == end_user_id
|
||||
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
|
||||
with_service(::Chat::AutoJoinChannelBatch, **args) do
|
||||
on_failed_contract do |contract|
|
||||
Rails.logger.error(contract.errors.full_messages.join(", "))
|
||||
end
|
||||
on_model_not_found(:channel) do
|
||||
Rails.logger.error("Channel not found (id=#{result.contract.channel_id})")
|
||||
end
|
||||
end
|
||||
|
||||
::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_memberships_query(category)
|
||||
query = <<~SQL
|
||||
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
|
||||
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
|
||||
FROM users
|
||||
INNER JOIN user_options uo ON uo.user_id = users.id
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
|
||||
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
|
||||
|
||||
LEFT OUTER JOIN group_users gu ON gu.user_id = users.id
|
||||
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id AND
|
||||
cg.permission_type <= :permission_type
|
||||
|
||||
WHERE (users.id >= :start AND users.id <= :end) AND
|
||||
users.staged IS FALSE AND
|
||||
users.active AND
|
||||
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
|
||||
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
|
||||
(last_seen_at IS NULL OR last_seen_at > :last_seen_at) AND
|
||||
uo.chat_enabled AND
|
||||
uccm.id IS NULL AND
|
||||
(NOT EXISTS(SELECT 1 FROM category_groups WHERE category_id = :channel_category)
|
||||
OR EXISTS (SELECT 1 FROM category_groups WHERE category_id = :channel_category AND group_id = :everyone AND permission_type <= :permission_type)
|
||||
OR cg.category_id = :channel_category)
|
||||
|
||||
RETURNING user_chat_channel_memberships.user_id
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Action
|
||||
class CreateMembershipsForAutoJoin
|
||||
def self.call(channel:, contract:)
|
||||
query_args = {
|
||||
chat_channel_id: channel.id,
|
||||
start: contract.start_user_id,
|
||||
end: contract.end_user_id,
|
||||
suspended_until: Time.zone.now,
|
||||
last_seen_at: 3.months.ago,
|
||||
channel_category: channel.category.id,
|
||||
permission_type: CategoryGroup.permission_types[:create_post],
|
||||
everyone: Group::AUTO_GROUPS[:everyone],
|
||||
mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
|
||||
}
|
||||
::DB.query_single(<<~SQL, query_args)
|
||||
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
|
||||
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
|
||||
FROM users
|
||||
INNER JOIN user_options uo ON uo.user_id = users.id
|
||||
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
|
||||
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
|
||||
|
||||
LEFT OUTER JOIN group_users gu ON gu.user_id = users.id
|
||||
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id AND
|
||||
cg.permission_type <= :permission_type
|
||||
|
||||
WHERE (users.id >= :start AND users.id <= :end) AND
|
||||
users.staged IS FALSE AND
|
||||
users.active AND
|
||||
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
|
||||
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
|
||||
(last_seen_at IS NULL OR last_seen_at > :last_seen_at) AND
|
||||
uo.chat_enabled AND
|
||||
|
||||
(NOT EXISTS(SELECT 1 FROM category_groups WHERE category_id = :channel_category)
|
||||
OR EXISTS (SELECT 1 FROM category_groups WHERE category_id = :channel_category AND group_id = :everyone AND permission_type <= :permission_type)
|
||||
OR cg.category_id = :channel_category)
|
||||
|
||||
ON CONFLICT DO NOTHING
|
||||
|
||||
RETURNING user_chat_channel_memberships.user_id
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
68
plugins/chat/app/services/chat/auto_join_channel_batch.rb
Normal file
68
plugins/chat/app/services/chat/auto_join_channel_batch.rb
Normal file
@ -0,0 +1,68 @@
|
||||
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# Service responsible to create memberships for a channel and a section of user ids
|
||||
#
|
||||
# @example
|
||||
# Chat::AutoJoinChannelBatch.call(
|
||||
# channel_id: 1,
|
||||
# start_user_id: 27,
|
||||
# end_user_id: 58,
|
||||
# )
|
||||
#
|
||||
class AutoJoinChannelBatch
|
||||
include Service::Base
|
||||
|
||||
contract
|
||||
model :channel
|
||||
step :create_memberships
|
||||
step :recalculate_user_count
|
||||
step :publish_new_channel
|
||||
|
||||
class Contract
|
||||
# Backward-compatible attributes
|
||||
attribute :chat_channel_id, :integer
|
||||
attribute :starts_at, :integer
|
||||
attribute :ends_at, :integer
|
||||
|
||||
# New attributes
|
||||
attribute :channel_id, :integer
|
||||
attribute :start_user_id, :integer
|
||||
attribute :end_user_id, :integer
|
||||
|
||||
validates :channel_id, :start_user_id, :end_user_id, presence: true
|
||||
validates :end_user_id, comparison: { greater_than_or_equal_to: :start_user_id }
|
||||
|
||||
# TODO (joffrey): remove after migration is done
|
||||
before_validation do
|
||||
self.channel_id ||= chat_channel_id
|
||||
self.start_user_id ||= starts_at
|
||||
self.end_user_id ||= ends_at
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_channel(contract:, **)
|
||||
::Chat::CategoryChannel.find_by(id: contract.channel_id, auto_join_users: true)
|
||||
end
|
||||
|
||||
def create_memberships(channel:, contract:, **)
|
||||
context.added_user_ids =
|
||||
::Chat::Action::CreateMembershipsForAutoJoin.call(channel: channel, contract: contract)
|
||||
end
|
||||
|
||||
def recalculate_user_count(channel:, added_user_ids:, **)
|
||||
# Only do this if we are running auto-join for a single user, if we
|
||||
# are doing it for many then we should do it after all batches are
|
||||
# complete for the channel in Jobs::AutoJoinChannelMemberships
|
||||
return unless added_user_ids.one?
|
||||
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
|
||||
end
|
||||
|
||||
def publish_new_channel(channel:, added_user_ids:, **)
|
||||
::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: added_user_ids))
|
||||
end
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user