Persist follow recommendations from FASP (#35218)
This commit is contained in:
		@@ -8,6 +8,7 @@ class AccountSuggestions
 | 
				
			|||||||
    AccountSuggestions::FriendsOfFriendsSource,
 | 
					    AccountSuggestions::FriendsOfFriendsSource,
 | 
				
			||||||
    AccountSuggestions::SimilarProfilesSource,
 | 
					    AccountSuggestions::SimilarProfilesSource,
 | 
				
			||||||
    AccountSuggestions::GlobalSource,
 | 
					    AccountSuggestions::GlobalSource,
 | 
				
			||||||
 | 
					    AccountSuggestions::FaspSource,
 | 
				
			||||||
  ].freeze
 | 
					  ].freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  BATCH_SIZE = 40
 | 
					  BATCH_SIZE = 40
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								app/models/account_suggestions/fasp_source.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/models/account_suggestions/fasp_source.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountSuggestions::FaspSource < AccountSuggestions::Source
 | 
				
			||||||
 | 
					  def get(account, limit: DEFAULT_LIMIT)
 | 
				
			||||||
 | 
					    return [] unless Mastodon::Feature.fasp_enabled?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    base_account_scope(account).where(id: fasp_follow_recommendations_for(account)).limit(limit).pluck(:id).map do |account_id|
 | 
				
			||||||
 | 
					      [account_id, :fasp]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def fasp_follow_recommendations_for(account)
 | 
				
			||||||
 | 
					    Fasp::FollowRecommendation.for_account(account).newest_first.select(:recommended_account_id)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										22
									
								
								app/models/fasp/follow_recommendation.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/models/fasp/follow_recommendation.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# == Schema Information
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Table name: fasp_follow_recommendations
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  id                     :bigint(8)        not null, primary key
 | 
				
			||||||
 | 
					#  created_at             :datetime         not null
 | 
				
			||||||
 | 
					#  updated_at             :datetime         not null
 | 
				
			||||||
 | 
					#  recommended_account_id :bigint(8)        not null
 | 
				
			||||||
 | 
					#  requesting_account_id  :bigint(8)        not null
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					class Fasp::FollowRecommendation < ApplicationRecord
 | 
				
			||||||
 | 
					  MAX_AGE = 1.day.freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  belongs_to :requesting_account, class_name: 'Account'
 | 
				
			||||||
 | 
					  belongs_to :recommended_account, class_name: 'Account'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  scope :outdated, -> { where(created_at: ...(MAX_AGE.ago)) }
 | 
				
			||||||
 | 
					  scope :for_account, ->(account) { where(requesting_account: account) }
 | 
				
			||||||
 | 
					  scope :newest_first, -> { order(created_at: :desc) }
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -23,10 +23,19 @@ class Fasp::FollowRecommendationWorker
 | 
				
			|||||||
      Fasp::Request.new(provider).get("/follow_recommendation/v0/accounts?#{params}").each do |uri|
 | 
					      Fasp::Request.new(provider).get("/follow_recommendation/v0/accounts?#{params}").each do |uri|
 | 
				
			||||||
        next if Account.where(uri:).any?
 | 
					        next if Account.where(uri:).any?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        account = fetch_service.call(uri)
 | 
					        new_account = fetch_service.call(uri)
 | 
				
			||||||
        async_refresh.increment_result_count(by: 1) if account.present?
 | 
					
 | 
				
			||||||
 | 
					        if new_account.present?
 | 
				
			||||||
 | 
					          Fasp::FollowRecommendation.find_or_create_by(requesting_account: account, recommended_account: new_account)
 | 
				
			||||||
 | 
					          async_refresh.increment_result_count(by: 1)
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Invalidate follow recommendation cache so it does not
 | 
				
			||||||
 | 
					    # take up to 15 minutes for the new recommendations to
 | 
				
			||||||
 | 
					    # show up
 | 
				
			||||||
 | 
					    Rails.cache.delete("follow_recommendations/#{account.id}")
 | 
				
			||||||
  rescue ActiveRecord::RecordNotFound
 | 
					  rescue ActiveRecord::RecordNotFound
 | 
				
			||||||
    # Nothing to be done
 | 
					    # Nothing to be done
 | 
				
			||||||
  ensure
 | 
					  ensure
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Scheduler::Fasp::FollowRecommendationCleanupScheduler
 | 
				
			||||||
 | 
					  include Sidekiq::Worker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.day.to_i
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def perform
 | 
				
			||||||
 | 
					    return unless Mastodon::Feature.fasp_enabled?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Fasp::FollowRecommendation.outdated.delete_all
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -68,3 +68,7 @@
 | 
				
			|||||||
      interval: 1 hour
 | 
					      interval: 1 hour
 | 
				
			||||||
      class: Scheduler::AutoCloseRegistrationsScheduler
 | 
					      class: Scheduler::AutoCloseRegistrationsScheduler
 | 
				
			||||||
      queue: scheduler
 | 
					      queue: scheduler
 | 
				
			||||||
 | 
					    fasp_follow_recommendation_cleanup_scheduler:
 | 
				
			||||||
 | 
					      interval: 1 day
 | 
				
			||||||
 | 
					      class: Scheduler::Fasp::FollowRecommendationsScheduler
 | 
				
			||||||
 | 
					      queue: scheduler
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CreateFaspFollowRecommendations < ActiveRecord::Migration[8.0]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    create_table :fasp_follow_recommendations do |t|
 | 
				
			||||||
 | 
					      t.references :requesting_account, null: false, foreign_key: { to_table: :accounts }
 | 
				
			||||||
 | 
					      t.references :recommended_account, null: false, foreign_key: { to_table: :accounts }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      t.timestamps
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										21
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								db/schema.rb
									
									
									
									
									
								
							@@ -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[8.0].define(version: 2025_06_05_110215) do
 | 
					ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) 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 "pg_catalog.plpgsql"
 | 
					  enable_extension "pg_catalog.plpgsql"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -191,8 +191,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
 | 
				
			|||||||
    t.boolean "hide_collections"
 | 
					    t.boolean "hide_collections"
 | 
				
			||||||
    t.integer "avatar_storage_schema_version"
 | 
					    t.integer "avatar_storage_schema_version"
 | 
				
			||||||
    t.integer "header_storage_schema_version"
 | 
					    t.integer "header_storage_schema_version"
 | 
				
			||||||
    t.integer "suspension_origin"
 | 
					 | 
				
			||||||
    t.datetime "sensitized_at", precision: nil
 | 
					    t.datetime "sensitized_at", precision: nil
 | 
				
			||||||
 | 
					    t.integer "suspension_origin"
 | 
				
			||||||
    t.boolean "trendable"
 | 
					    t.boolean "trendable"
 | 
				
			||||||
    t.datetime "reviewed_at", precision: nil
 | 
					    t.datetime "reviewed_at", precision: nil
 | 
				
			||||||
    t.datetime "requested_review_at", precision: nil
 | 
					    t.datetime "requested_review_at", precision: nil
 | 
				
			||||||
@@ -465,6 +465,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
 | 
				
			|||||||
    t.index ["fasp_provider_id"], name: "index_fasp_debug_callbacks_on_fasp_provider_id"
 | 
					    t.index ["fasp_provider_id"], name: "index_fasp_debug_callbacks_on_fasp_provider_id"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  create_table "fasp_follow_recommendations", force: :cascade do |t|
 | 
				
			||||||
 | 
					    t.bigint "requesting_account_id", null: false
 | 
				
			||||||
 | 
					    t.bigint "recommended_account_id", null: false
 | 
				
			||||||
 | 
					    t.datetime "created_at", null: false
 | 
				
			||||||
 | 
					    t.datetime "updated_at", null: false
 | 
				
			||||||
 | 
					    t.index ["recommended_account_id"], name: "index_fasp_follow_recommendations_on_recommended_account_id"
 | 
				
			||||||
 | 
					    t.index ["requesting_account_id"], name: "index_fasp_follow_recommendations_on_requesting_account_id"
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create_table "fasp_providers", force: :cascade do |t|
 | 
					  create_table "fasp_providers", force: :cascade do |t|
 | 
				
			||||||
    t.boolean "confirmed", default: false, null: false
 | 
					    t.boolean "confirmed", default: false, null: false
 | 
				
			||||||
    t.string "name", null: false
 | 
					    t.string "name", null: false
 | 
				
			||||||
@@ -604,12 +613,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create_table "ip_blocks", force: :cascade do |t|
 | 
					  create_table "ip_blocks", force: :cascade do |t|
 | 
				
			||||||
    t.datetime "created_at", precision: nil, null: false
 | 
					 | 
				
			||||||
    t.datetime "updated_at", precision: nil, null: false
 | 
					 | 
				
			||||||
    t.datetime "expires_at", precision: nil
 | 
					 | 
				
			||||||
    t.inet "ip", default: "0.0.0.0", null: false
 | 
					    t.inet "ip", default: "0.0.0.0", null: false
 | 
				
			||||||
    t.integer "severity", default: 0, null: false
 | 
					    t.integer "severity", default: 0, null: false
 | 
				
			||||||
 | 
					    t.datetime "expires_at", precision: nil
 | 
				
			||||||
    t.text "comment", default: "", null: false
 | 
					    t.text "comment", default: "", null: false
 | 
				
			||||||
 | 
					    t.datetime "created_at", precision: nil, null: false
 | 
				
			||||||
 | 
					    t.datetime "updated_at", precision: nil, null: false
 | 
				
			||||||
    t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
 | 
					    t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1367,6 +1376,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
 | 
				
			|||||||
  add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
 | 
					  add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "fasp_backfill_requests", "fasp_providers"
 | 
					  add_foreign_key "fasp_backfill_requests", "fasp_providers"
 | 
				
			||||||
  add_foreign_key "fasp_debug_callbacks", "fasp_providers"
 | 
					  add_foreign_key "fasp_debug_callbacks", "fasp_providers"
 | 
				
			||||||
 | 
					  add_foreign_key "fasp_follow_recommendations", "accounts", column: "recommended_account_id"
 | 
				
			||||||
 | 
					  add_foreign_key "fasp_follow_recommendations", "accounts", column: "requesting_account_id"
 | 
				
			||||||
  add_foreign_key "fasp_subscriptions", "fasp_providers"
 | 
					  add_foreign_key "fasp_subscriptions", "fasp_providers"
 | 
				
			||||||
  add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
 | 
					  add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
 | 
				
			||||||
  add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
 | 
					  add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Fabricator(:fasp_follow_recommendation, from: 'Fasp::FollowRecommendation') do
 | 
				
			||||||
 | 
					  requesting_account { Fabricate.build(:account) }
 | 
				
			||||||
 | 
					  recommended_account { Fabricate.build(:account, domain: 'fedi.example.com') }
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										23
									
								
								spec/models/account_suggestions/fasp_source_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								spec/models/account_suggestions/fasp_source_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe AccountSuggestions::FaspSource do
 | 
				
			||||||
 | 
					  describe '#get', feature: :fasp do
 | 
				
			||||||
 | 
					    subject { described_class.new }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let(:bob) { Fabricate(:account) }
 | 
				
			||||||
 | 
					    let(:alice) { Fabricate(:account, domain: 'fedi.example.com') }
 | 
				
			||||||
 | 
					    let(:eve) { Fabricate(:account, domain: 'fedi.example.com') }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      [alice, eve].each do |recommended_account|
 | 
				
			||||||
 | 
					        Fasp::FollowRecommendation.create!(requesting_account: bob, recommended_account:)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns recommendations obtained by FASP' do
 | 
				
			||||||
 | 
					      expect(subject.get(bob)).to contain_exactly([alice.id, :fasp], [eve.id, :fasp])
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -7,7 +7,7 @@ RSpec.describe Fasp::FollowRecommendationWorker, feature: :fasp do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  let(:provider) { Fabricate(:follow_recommendation_fasp) }
 | 
					  let(:provider) { Fabricate(:follow_recommendation_fasp) }
 | 
				
			||||||
  let(:account) { Fabricate(:account) }
 | 
					  let(:account) { Fabricate(:account) }
 | 
				
			||||||
  let(:fetch_service) { instance_double(ActivityPub::FetchRemoteActorService, call: true) }
 | 
					  let(:fetch_service) { instance_double(ActivityPub::FetchRemoteActorService) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let!(:stubbed_request) do
 | 
					  let!(:stubbed_request) do
 | 
				
			||||||
    account_uri = ActivityPub::TagManager.instance.uri_for(account)
 | 
					    account_uri = ActivityPub::TagManager.instance.uri_for(account)
 | 
				
			||||||
@@ -23,6 +23,8 @@ RSpec.describe Fasp::FollowRecommendationWorker, feature: :fasp do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  before do
 | 
					  before do
 | 
				
			||||||
    allow(ActivityPub::FetchRemoteActorService).to receive(:new).and_return(fetch_service)
 | 
					    allow(ActivityPub::FetchRemoteActorService).to receive(:new).and_return(fetch_service)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    allow(fetch_service).to receive(:call).and_invoke(->(_) { Fabricate(:account, domain: 'fedi.example.com') })
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it "sends the requesting account's uri to provider and fetches received account uris" do
 | 
					  it "sends the requesting account's uri to provider and fetches received account uris" do
 | 
				
			||||||
@@ -48,4 +50,10 @@ RSpec.describe Fasp::FollowRecommendationWorker, feature: :fasp do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    expect(async_refresh.reload.result_count).to eq 2
 | 
					    expect(async_refresh.reload.result_count).to eq 2
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it 'persists the results' do
 | 
				
			||||||
 | 
					    expect do
 | 
				
			||||||
 | 
					      described_class.new.perform(account.id)
 | 
				
			||||||
 | 
					    end.to change(Fasp::FollowRecommendation, :count).by(2)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe Scheduler::Fasp::FollowRecommendationCleanupScheduler do
 | 
				
			||||||
 | 
					  let(:worker) { described_class.new }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe '#perform', feature: :fasp do
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      Fabricate(:fasp_follow_recommendation, created_at: 2.days.ago)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'deletes outdated recommendations' do
 | 
				
			||||||
 | 
					      expect { worker.perform }.to change(Fasp::FollowRecommendation, :count).by(-1)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
		Reference in New Issue
	
	Block a user