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:
Joffrey JAFFEUX
2023-07-27 10:25:41 +02:00
committed by GitHub
parent 2d567cee26
commit 05aa55e172
9 changed files with 457 additions and 393 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View 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