Change algorithm of follow recommendations (#28314)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
shrike
Eugen Rochko 2023-12-19 11:59:43 +01:00 committed by GitHub
parent b7bdcd4f39
commit b5ac61b2c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 297 additions and 292 deletions

View File

@ -102,9 +102,12 @@
{ {
// Group actions/*-artifact in the same PR // Group actions/*-artifact in the same PR
matchManagers: ['github-actions'], matchManagers: ['github-actions'],
matchPackageNames: ['actions/download-artifact', 'actions/upload-artifact'], matchPackageNames: [
'actions/download-artifact',
'actions/upload-artifact',
],
matchUpdateTypes: ['major'], matchUpdateTypes: ['major'],
groupName: 'artifact actions (major)' groupName: 'artifact actions (major)',
}, },
{ {
// Update @types/* packages every week, with one grouped PR // Update @types/* packages every week, with one grouped PR

View File

@ -8,7 +8,7 @@ module Admin
authorize :follow_recommendation, :show? authorize :follow_recommendation, :show?
@form = Form::AccountBatch.new @form = Form::AccountBatch.new
@accounts = filtered_follow_recommendations @accounts = filtered_follow_recommendations.page(params[:page])
end end
def update def update

View File

@ -3,22 +3,23 @@
class Api::V1::SuggestionsController < Api::BaseController class Api::V1::SuggestionsController < Api::BaseController
include Authorization include Authorization
before_action -> { doorkeeper_authorize! :read } before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user! before_action :require_user!
before_action :set_suggestions
def index def index
suggestions = suggestions_source.get(current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i).map(&:account), each_serializer: REST::AccountSerializer
render json: suggestions.map(&:account), each_serializer: REST::AccountSerializer
end end
def destroy def destroy
suggestions_source.remove(current_account, params[:id]) @suggestions.remove(params[:id])
render_empty render_empty
end end
private private
def suggestions_source def set_suggestions
AccountSuggestions::PastInteractionsSource.new @suggestions = AccountSuggestions.new(current_account)
end end
end end

View File

@ -3,17 +3,23 @@
class Api::V2::SuggestionsController < Api::BaseController class Api::V2::SuggestionsController < Api::BaseController
include Authorization include Authorization
before_action -> { doorkeeper_authorize! :read } before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user! before_action :require_user!
before_action :set_suggestions before_action :set_suggestions
def index def index
render json: @suggestions, each_serializer: REST::SuggestionSerializer render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i), each_serializer: REST::SuggestionSerializer
end
def destroy
@suggestions.remove(params[:id])
render_empty
end end
private private
def set_suggestions def set_suggestions
@suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT)) @suggestions = AccountSuggestions.new(current_account)
end end
end end

View File

@ -1,31 +0,0 @@
# frozen_string_literal: true
class PotentialFriendshipTracker
EXPIRE_AFTER = 90.days.seconds
MAX_ITEMS = 80
WEIGHTS = {
reply: 1,
favourite: 10,
reblog: 20,
}.freeze
class << self
include Redisable
def record(account_id, target_account_id, action)
return if account_id == target_account_id
key = "interactions:#{account_id}"
weight = WEIGHTS[action]
redis.zincrby(key, weight, target_account_id)
redis.zremrangebyrank(key, 0, -MAX_ITEMS)
redis.expire(key, EXPIRE_AFTER)
end
def remove(account_id, target_account_id)
redis.zrem("interactions:#{account_id}", target_account_id)
end
end
end

View File

@ -19,6 +19,7 @@ class AccountDomainBlock < ApplicationRecord
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
after_commit :invalidate_domain_blocking_cache after_commit :invalidate_domain_blocking_cache
after_commit :invalidate_follow_recommendations_cache
private private
@ -26,4 +27,8 @@ class AccountDomainBlock < ApplicationRecord
Rails.cache.delete("exclude_domains_for:#{account_id}") Rails.cache.delete("exclude_domains_for:#{account_id}")
Rails.cache.delete(['exclude_domains', account_id, domain]) Rails.cache.delete(['exclude_domains', account_id, domain])
end end
def invalidate_follow_recommendations_cache
Rails.cache.delete("follow_recommendations/#{account_id}")
end
end end

View File

@ -1,28 +1,48 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountSuggestions class AccountSuggestions
include DatabaseHelper
SOURCES = [ SOURCES = [
AccountSuggestions::SettingSource, AccountSuggestions::SettingSource,
AccountSuggestions::PastInteractionsSource, AccountSuggestions::FriendsOfFriendsSource,
AccountSuggestions::SimilarProfilesSource,
AccountSuggestions::GlobalSource, AccountSuggestions::GlobalSource,
].freeze ].freeze
def self.get(account, limit) BATCH_SIZE = 40
SOURCES.each_with_object([]) do |source_class, suggestions|
source_suggestions = source_class.new.get( def initialize(account)
account, @account = account
skip_account_ids: suggestions.map(&:account_id), end
limit: limit - suggestions.size
def get(limit, offset = 0)
with_read_replica do
account_ids_with_sources = Rails.cache.fetch("follow_recommendations/#{@account.id}", expires_in: 15.minutes) do
SOURCES.flat_map { |klass| klass.new.get(@account, limit: BATCH_SIZE) }.each_with_object({}) do |(account_id, source), h|
(h[account_id] ||= []).concat(Array(source).map(&:to_sym))
end.to_a.shuffle
end
# The sources deliver accounts that haven't yet been followed, are not blocked,
# and so on. Since we reset the cache on follows, blocks, and so on, we don't need
# a complicated query on this end.
account_ids = account_ids_with_sources[offset, limit]
accounts_map = Account.where(id: account_ids.map(&:first)).includes(:account_stat).index_by(&:id)
account_ids.filter_map do |(account_id, source)|
next unless accounts_map.key?(account_id)
AccountSuggestions::Suggestion.new(
account: accounts_map[account_id],
source: source
) )
end
suggestions.concat(source_suggestions)
end end
end end
def self.remove(account, target_account_id) def remove(target_account_id)
SOURCES.each do |source_class| FollowRecommendationMute.create(account_id: @account.id, target_account_id: target_account_id)
source = source_class.new
source.remove(account, target_account_id)
end
end end
end end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
class AccountSuggestions::FriendsOfFriendsSource < AccountSuggestions::Source
def get(account, limit: 10)
Account.find_by_sql([<<~SQL.squish, { id: account.id, limit: limit }]).map { |row| [row.id, key] }
WITH first_degree AS (
SELECT target_account_id
FROM follows
JOIN accounts AS target_accounts ON follows.target_account_id = target_accounts.id
WHERE account_id = :id
AND NOT target_accounts.hide_collections
)
SELECT accounts.id, COUNT(*) AS frequency
FROM accounts
JOIN follows ON follows.target_account_id = accounts.id
JOIN account_stats ON account_stats.account_id = accounts.id
LEFT OUTER JOIN follow_recommendation_mutes ON follow_recommendation_mutes.target_account_id = accounts.id AND follow_recommendation_mutes.account_id = :id
WHERE follows.account_id IN (SELECT * FROM first_degree)
AND follows.target_account_id NOT IN (SELECT * FROM first_degree)
AND follows.target_account_id <> :id
AND accounts.discoverable
AND accounts.suspended_at IS NULL
AND accounts.silenced_at IS NULL
AND accounts.moved_to_account_id IS NULL
AND follow_recommendation_mutes.target_account_id IS NULL
GROUP BY accounts.id, account_stats.id
ORDER BY frequency DESC, account_stats.followers_count ASC
LIMIT :limit
SQL
end
private
def key
:friends_of_friends
end
end

View File

@ -1,39 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountSuggestions::GlobalSource < AccountSuggestions::Source class AccountSuggestions::GlobalSource < AccountSuggestions::Source
include Redisable def get(account, limit: 10)
FollowRecommendation.localized(content_locale).joins(:account).merge(base_account_scope(account)).order(rank: :desc).limit(limit).pluck(:account_id, :reason)
def key
:global
end
def get(account, skip_account_ids: [], limit: 40)
account_ids = account_ids_for_locale(I18n.locale.to_s.split(/[_-]/).first) - [account.id] - skip_account_ids
as_ordered_suggestions(
scope(account).where(id: account_ids),
account_ids
).take(limit)
end
def remove(_account, _target_account_id)
nil
end end
private private
def scope(account) def content_locale
Account.searchable I18n.locale.to_s.split(/[_-]/).first
.followable_by(account)
.not_excluded_by_account(account)
.not_domain_blocked_by_account(account)
end
def account_ids_for_locale(locale)
redis.zrevrange("follow_recommendations:#{locale}", 0, -1).map(&:to_i)
end
def to_ordered_list_key(account)
account.id
end end
end end

View File

@ -1,36 +0,0 @@
# frozen_string_literal: true
class AccountSuggestions::PastInteractionsSource < AccountSuggestions::Source
include Redisable
def key
:past_interactions
end
def get(account, skip_account_ids: [], limit: 40)
account_ids = account_ids_for_account(account.id, limit + skip_account_ids.size) - skip_account_ids
as_ordered_suggestions(
scope.where(id: account_ids),
account_ids
).take(limit)
end
def remove(account, target_account_id)
redis.zrem("interactions:#{account.id}", target_account_id)
end
private
def scope
Account.searchable
end
def account_ids_for_account(account_id, limit)
redis.zrevrange("interactions:#{account_id}", 0, limit).map(&:to_i)
end
def to_ordered_list_key(account)
account.id
end
end

View File

@ -1,32 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountSuggestions::SettingSource < AccountSuggestions::Source class AccountSuggestions::SettingSource < AccountSuggestions::Source
def key def get(account, limit: 10)
:staff if setting_enabled?
base_account_scope(account).where(setting_to_where_condition).limit(limit).pluck(:id).zip([key].cycle)
else
[]
end end
def get(account, skip_account_ids: [], limit: 40)
return [] unless setting_enabled?
as_ordered_suggestions(
scope(account).where(setting_to_where_condition).where.not(id: skip_account_ids),
usernames_and_domains
).take(limit)
end
def remove(_account, _target_account_id)
nil
end end
private private
def scope(account) def key
Account.searchable :featured
.followable_by(account)
.not_excluded_by_account(account)
.not_domain_blocked_by_account(account)
.where(locked: false)
.where.not(id: account.id)
end end
def usernames_and_domains def usernames_and_domains
@ -61,8 +47,4 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
def setting def setting
Setting.bootstrap_timeline_accounts Setting.bootstrap_timeline_accounts
end end
def to_ordered_list_key(account)
[account.username.downcase, account.domain&.downcase]
end
end end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
class AccountSuggestions::SimilarProfilesSource < AccountSuggestions::Source
class QueryBuilder < AccountSearchService::QueryBuilder
def must_clauses
[
{
more_like_this: {
fields: %w(text text.stemmed),
like: @query.map { |id| { _index: 'accounts', _id: id } },
},
},
{
term: {
properties: 'discoverable',
},
},
]
end
def must_not_clauses
[
{
terms: {
id: following_ids,
},
},
{
term: {
properties: 'bot',
},
},
]
end
def should_clauses
{
term: {
properties: {
value: 'verified',
boost: 2,
},
},
}
end
end
def get(account, limit: 10)
recently_followed_account_ids = account.active_relationships.recent.limit(5).pluck(:target_account_id)
if Chewy.enabled? && !recently_followed_account_ids.empty?
QueryBuilder.new(recently_followed_account_ids, account).build.limit(limit).hits.pluck('_id').map(&:to_i).zip([key].cycle)
else
[]
end
rescue Faraday::ConnectionFailed
[]
end
private
def key
:similar_to_recently_followed
end
end

View File

@ -1,34 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountSuggestions::Source class AccountSuggestions::Source
def key
raise NotImplementedError
end
def get(_account, **kwargs) def get(_account, **kwargs)
raise NotImplementedError raise NotImplementedError
end end
def remove(_account, target_account_id)
raise NotImplementedError
end
protected protected
def as_ordered_suggestions(scope, ordered_list) def base_account_scope(account)
return [] if ordered_list.empty? Account.searchable
.followable_by(account)
map = scope.index_by { |account| to_ordered_list_key(account) } .not_excluded_by_account(account)
.not_domain_blocked_by_account(account)
ordered_list.filter_map { |ordered_list_key| map[ordered_list_key] }.map do |account| .where.not(id: account.id)
AccountSuggestions::Suggestion.new( .joins("LEFT OUTER JOIN follow_recommendation_mutes ON follow_recommendation_mutes.target_account_id = accounts.id AND follow_recommendation_mutes.account_id = #{account.id}").where(follow_recommendation_mutes: { target_account_id: nil })
account: account,
source: key
)
end
end
def to_ordered_list_key(_account)
raise NotImplementedError
end end
end end

View File

@ -26,15 +26,20 @@ class Block < ApplicationRecord
end end
before_validation :set_uri, only: :create before_validation :set_uri, only: :create
after_commit :remove_blocking_cache after_commit :invalidate_blocking_cache
after_commit :invalidate_follow_recommendations_cache
private private
def remove_blocking_cache def invalidate_blocking_cache
Rails.cache.delete("exclude_account_ids_for:#{account_id}") Rails.cache.delete("exclude_account_ids_for:#{account_id}")
Rails.cache.delete("exclude_account_ids_for:#{target_account_id}") Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
end end
def invalidate_follow_recommendations_cache
Rails.cache.delete("follow_recommendations/#{account_id}")
end
def set_uri def set_uri
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
end end

View File

@ -64,6 +64,7 @@ module Account::Associations
has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
# Follow recommendations # Follow recommendations
has_one :follow_recommendation, inverse_of: :account, dependent: nil
has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
# Account statuses cleanup policy # Account statuses cleanup policy

View File

@ -116,8 +116,6 @@ module Account::Interactions
rel.save! if rel.changed? rel.save! if rel.changed?
remove_potential_friendship(other_account)
rel rel
end end
@ -131,13 +129,10 @@ module Account::Interactions
rel.save! if rel.changed? rel.save! if rel.changed?
remove_potential_friendship(other_account)
rel rel
end end
def block!(other_account, uri: nil) def block!(other_account, uri: nil)
remove_potential_friendship(other_account)
block_relationships.create_with(uri: uri) block_relationships.create_with(uri: uri)
.find_or_create_by!(target_account: other_account) .find_or_create_by!(target_account: other_account)
end end
@ -148,8 +143,6 @@ module Account::Interactions
mute.expires_in = duration.zero? ? nil : duration mute.expires_in = duration.zero? ? nil : duration
mute.save! mute.save!
remove_potential_friendship(other_account)
# When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
mute.update!(hide_notifications: notifications) if mute.hide_notifications? != notifications mute.update!(hide_notifications: notifications) if mute.hide_notifications? != notifications
@ -307,10 +300,4 @@ module Account::Interactions
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, id), domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, id),
}) })
end end
private
def remove_potential_friendship(other_account)
PotentialFriendshipTracker.remove(id, other_account.id)
end
end end

View File

@ -116,6 +116,7 @@ module Account::Search
[].tap do |properties| [].tap do |properties|
properties << 'bot' if bot? properties << 'bot' if bot?
properties << 'verified' if fields.any?(&:verified?) properties << 'verified' if fields.any?(&:verified?)
properties << 'discoverable' if discoverable?
end end
end end

View File

@ -44,10 +44,10 @@ class Follow < ApplicationRecord
before_validation :set_uri, only: :create before_validation :set_uri, only: :create
after_create :increment_cache_counters after_create :increment_cache_counters
after_create :invalidate_hash_cache
after_destroy :remove_endorsements after_destroy :remove_endorsements
after_destroy :decrement_cache_counters after_destroy :decrement_cache_counters
after_destroy :invalidate_hash_cache after_commit :invalidate_follow_recommendations_cache
after_commit :invalidate_hash_cache
private private
@ -74,4 +74,8 @@ class Follow < ApplicationRecord
Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}") Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}")
end end
def invalidate_follow_recommendations_cache
Rails.cache.delete("follow_recommendations/#{account_id}")
end
end end

View File

@ -17,12 +17,9 @@ class FollowRecommendationFilter
def results def results
if params['status'] == 'suppressed' if params['status'] == 'suppressed'
Account.joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc).to_a Account.includes(:account_stat).joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc)
else else
account_ids = redis.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i) Account.includes(:account_stat).joins(:follow_recommendation).merge(FollowRecommendation.localized(@language).order(rank: :desc))
accounts = Account.where(id: account_ids).index_by(&:id)
account_ids.filter_map { |id| accounts[id] }
end end
end end
end end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: follow_recommendation_mutes
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# target_account_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class FollowRecommendationMute < ApplicationRecord
belongs_to :account
belongs_to :target_account, class_name: 'Account'
validates :target_account, uniqueness: { scope: :account_id }
after_commit :invalidate_follow_recommendations_cache
private
def invalidate_follow_recommendations_cache
Rails.cache.delete("follow_recommendations/#{account_id}")
end
end

View File

@ -11,19 +11,5 @@
# #
class FollowRecommendationSuppression < ApplicationRecord class FollowRecommendationSuppression < ApplicationRecord
include Redisable
belongs_to :account belongs_to :account
after_commit :remove_follow_recommendations, on: :create
private
def remove_follow_recommendations
redis.pipelined do |pipeline|
I18n.available_locales.each do |locale|
pipeline.zrem("follow_recommendations:#{locale}", account_id)
end
end
end
end end

View File

@ -45,10 +45,15 @@ class FollowRequest < ApplicationRecord
end end
before_validation :set_uri, only: :create before_validation :set_uri, only: :create
after_commit :invalidate_follow_recommendations_cache
private private
def set_uri def set_uri
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
end end
def invalidate_follow_recommendations_cache
Rails.cache.delete("follow_recommendations/#{account_id}")
end
end end

View File

@ -23,11 +23,16 @@ class Mute < ApplicationRecord
validates :account_id, uniqueness: { scope: :target_account_id } validates :account_id, uniqueness: { scope: :target_account_id }
after_commit :remove_blocking_cache after_commit :invalidate_blocking_cache
after_commit :invalidate_follow_recommendations_cache
private private
def remove_blocking_cache def invalidate_blocking_cache
Rails.cache.delete("exclude_account_ids_for:#{account_id}") Rails.cache.delete("exclude_account_ids_for:#{account_id}")
end end
def invalidate_follow_recommendations_cache
Rails.cache.delete("follow_recommendations/#{account_id}")
end
end end

View File

@ -4,8 +4,8 @@
# #
# Table name: preview_cards_statuses # Table name: preview_cards_statuses
# #
# preview_card_id :bigint(8) not null # preview_card_id :bigint(8) not null, primary key
# status_id :bigint(8) not null # status_id :bigint(8) not null, primary key
# url :string # url :string
# #
class PreviewCardsStatus < ApplicationRecord class PreviewCardsStatus < ApplicationRecord

View File

@ -23,6 +23,7 @@ class AccountSearchService < BaseService
query: { query: {
bool: { bool: {
must: must_clauses, must: must_clauses,
must_not: must_not_clauses,
}, },
}, },
@ -49,6 +50,10 @@ class AccountSearchService < BaseService
end end
end end
def must_not_clauses
[]
end
def should_clauses def should_clauses
if @account && !@options[:following] if @account && !@options[:following]
[boost_following_query] [boost_following_query]

View File

@ -20,7 +20,7 @@ class FavouriteService < BaseService
Trends.statuses.register(status) Trends.statuses.register(status)
create_notification(favourite) create_notification(favourite)
bump_potential_friendship(account, status) increment_statistics
favourite favourite
end end
@ -37,11 +37,8 @@ class FavouriteService < BaseService
end end
end end
def bump_potential_friendship(account, status) def increment_statistics
ActivityTracker.increment('activity:interactions') ActivityTracker.increment('activity:interactions')
return if account.following?(status.account_id)
PotentialFriendshipTracker.record(account.id, status.account_id, :favourite)
end end
def build_json(favourite) def build_json(favourite)

View File

@ -178,9 +178,6 @@ class PostStatusService < BaseService
return if !@status.reply? || @account.id == @status.in_reply_to_account_id return if !@status.reply? || @account.id == @status.in_reply_to_account_id
ActivityTracker.increment('activity:interactions') ActivityTracker.increment('activity:interactions')
return if @account.following?(@status.in_reply_to_account_id)
PotentialFriendshipTracker.record(@account.id, @status.in_reply_to_account_id, :reply)
end end
def status_attributes def status_attributes

View File

@ -33,7 +33,7 @@ class ReblogService < BaseService
ActivityPub::DistributionWorker.perform_async(reblog.id) ActivityPub::DistributionWorker.perform_async(reblog.id)
create_notification(reblog) create_notification(reblog)
bump_potential_friendship(account, reblog) increment_statistics
reblog reblog
end end
@ -50,12 +50,8 @@ class ReblogService < BaseService
end end
end end
def bump_potential_friendship(account, reblog) def increment_statistics
ActivityTracker.increment('activity:interactions') ActivityTracker.increment('activity:interactions')
return if account.following?(reblog.reblog.account_id)
PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
end end
def build_json(reblog) def build_json(reblog)

View File

@ -38,3 +38,5 @@
= nothing_here 'nothing-here--under-tabs' = nothing_here 'nothing-here--under-tabs'
- else - else
= render partial: 'account', collection: @accounts, locals: { f: f } = render partial: 'account', collection: @accounts, locals: { f: f }
= paginate @accounts

View File

@ -2,61 +2,11 @@
class Scheduler::FollowRecommendationsScheduler class Scheduler::FollowRecommendationsScheduler
include Sidekiq::Worker include Sidekiq::Worker
include Redisable
sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i
# The maximum number of accounts that can be requested in one page from the
# API is 80, and the suggestions API does not allow pagination. This number
# leaves some room for accounts being filtered during live access
SET_SIZE = 100
def perform def perform
# Maintaining a materialized view speeds-up subsequent queries significantly
AccountSummary.refresh AccountSummary.refresh
FollowRecommendation.refresh FollowRecommendation.refresh
fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE)
Trends.available_locales.each do |locale|
recommendations = if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.rank, recommendation.account_id] }
else
[]
end
# Use language-agnostic results if there are not enough language-specific ones
missing = SET_SIZE - recommendations.size
if missing.positive? && fallback_recommendations.size.positive?
max_fallback_rank = fallback_recommendations.first.rank || 0
# Language-specific results should be above language-agnostic ones,
# otherwise language-agnostic ones will always overshadow them
recommendations.map! { |(rank, account_id)| [rank + max_fallback_rank, account_id] }
added = 0
fallback_recommendations.each do |recommendation|
next if recommendations.any? { |(_, account_id)| account_id == recommendation.account_id }
recommendations << [recommendation.rank, recommendation.account_id]
added += 1
break if added >= missing
end
end
redis.multi do |multi|
multi.del(key(locale))
multi.zadd(key(locale), recommendations)
end
end
end
private
def key(locale)
"follow_recommendations:#{locale}"
end end
end end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateFollowRecommendationMutes < ActiveRecord::Migration[7.1]
def change
create_table :follow_recommendation_mutes do |t|
t.references :account, null: false, foreign_key: { on_delete: :cascade }, index: false
t.references :target_account, null: false, foreign_key: { to_table: 'accounts', on_delete: :cascade }
t.timestamps
end
add_index :follow_recommendation_mutes, [:account_id, :target_account_id], unique: true
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddLanguagesIndexToAccountSummaries < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :account_summaries, [:account_id, :language, :sensitive], algorithm: :concurrently
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do ActiveRecord::Schema[7.1].define(version: 2023_12_12_073317) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -474,6 +474,15 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
t.index ["tag_id"], name: "index_featured_tags_on_tag_id" t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
end end
create_table "follow_recommendation_mutes", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "target_account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "target_account_id"], name: "idx_on_account_id_target_account_id_a8c8ddf44e", unique: true
t.index ["target_account_id"], name: "index_follow_recommendation_mutes_on_target_account_id"
end
create_table "follow_recommendation_suppressions", force: :cascade do |t| create_table "follow_recommendation_suppressions", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@ -1209,6 +1218,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
add_foreign_key "featured_tags", "accounts", on_delete: :cascade add_foreign_key "featured_tags", "accounts", on_delete: :cascade
add_foreign_key "featured_tags", "tags", on_delete: :cascade add_foreign_key "featured_tags", "tags", on_delete: :cascade
add_foreign_key "follow_recommendation_mutes", "accounts", column: "target_account_id", on_delete: :cascade
add_foreign_key "follow_recommendation_mutes", "accounts", on_delete: :cascade
add_foreign_key "follow_recommendation_suppressions", "accounts", on_delete: :cascade add_foreign_key "follow_recommendation_suppressions", "accounts", on_delete: :cascade
add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
@ -1341,6 +1352,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_06_183200) do
WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false)) WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false))
GROUP BY accounts.id; GROUP BY accounts.id;
SQL SQL
add_index "account_summaries", ["account_id", "language", "sensitive"], name: "idx_on_account_id_language_sensitive_250461e1eb"
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL

View File

@ -18,8 +18,7 @@ RSpec.describe 'Suggestions' do
let(:params) { {} } let(:params) { {} }
before do before do
PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) Setting.bootstrap_timeline_accounts = [bob, jeff].map(&:acct).join(',')
PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
end end
it_behaves_like 'forbidden for wrong scope', 'write' it_behaves_like 'forbidden for wrong scope', 'write'
@ -65,17 +64,15 @@ RSpec.describe 'Suggestions' do
delete "/api/v1/suggestions/#{jeff.id}", headers: headers delete "/api/v1/suggestions/#{jeff.id}", headers: headers
end end
let(:suggestions_source) { instance_double(AccountSuggestions::PastInteractionsSource, remove: nil) }
let(:bob) { Fabricate(:account) } let(:bob) { Fabricate(:account) }
let(:jeff) { Fabricate(:account) } let(:jeff) { Fabricate(:account) }
let(:scopes) { 'write' }
before do before do
PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) Setting.bootstrap_timeline_accounts = [bob, jeff].map(&:acct).join(',')
PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
allow(AccountSuggestions::PastInteractionsSource).to receive(:new).and_return(suggestions_source)
end end
it_behaves_like 'forbidden for wrong scope', 'write' it_behaves_like 'forbidden for wrong scope', 'read'
it 'returns http success' do it 'returns http success' do
subject subject
@ -86,8 +83,7 @@ RSpec.describe 'Suggestions' do
it 'removes the specified suggestion' do it 'removes the specified suggestion' do
subject subject
expect(suggestions_source).to have_received(:remove).with(user.account, jeff.id.to_s).once expect(FollowRecommendationMute.exists?(account: user.account, target_account: jeff)).to be true
expect(suggestions_source).to_not have_received(:remove).with(user.account, bob.id.to_s)
end end
context 'without an authorization header' do context 'without an authorization header' do

View File

@ -29,14 +29,12 @@ describe Scheduler::FollowRecommendationsScheduler do
it 'creates recommendations' do it 'creates recommendations' do
expect { scheduled_run }.to change(FollowRecommendation, :count).from(0).to(target_accounts.size) expect { scheduled_run }.to change(FollowRecommendation, :count).from(0).to(target_accounts.size)
expect(redis.zrange('follow_recommendations:en', 0, -1)).to match_array(target_accounts.pluck(:id).map(&:to_s))
end end
end end
context 'when there are no accounts to recommend' do context 'when there are no accounts to recommend' do
it 'does not create follow recommendations' do it 'does not create follow recommendations' do
expect { scheduled_run }.to_not change(FollowRecommendation, :count) expect { scheduled_run }.to_not change(FollowRecommendation, :count)
expect(redis.zrange('follow_recommendations:en', 0, -1)).to be_empty
end end
end end
end end