From bae258925c654160600c4bc39e2b364c3b425f6a Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Mon, 30 Jun 2025 15:39:36 +0200 Subject: [PATCH] Persist follow recommendations from FASP (#35218) --- app/models/account_suggestions.rb | 1 + app/models/account_suggestions/fasp_source.rb | 17 ++++++++++++++ app/models/fasp/follow_recommendation.rb | 22 ++++++++++++++++++ .../fasp/follow_recommendation_worker.rb | 13 +++++++++-- ...follow_recommendation_cleanup_scheduler.rb | 13 +++++++++++ config/sidekiq.yml | 4 ++++ ...2728_create_fasp_follow_recommendations.rb | 12 ++++++++++ db/schema.rb | 21 +++++++++++++---- .../fasp/follow_recommendation_fabricator.rb | 6 +++++ .../account_suggestions/fasp_source_spec.rb | 23 +++++++++++++++++++ .../fasp/follow_recommendation_worker_spec.rb | 10 +++++++- ...w_recommendation_cleanup_scheduler_spec.rb | 17 ++++++++++++++ 12 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 app/models/account_suggestions/fasp_source.rb create mode 100644 app/models/fasp/follow_recommendation.rb create mode 100644 app/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler.rb create mode 100644 db/migrate/20250627132728_create_fasp_follow_recommendations.rb create mode 100644 spec/fabricators/fasp/follow_recommendation_fabricator.rb create mode 100644 spec/models/account_suggestions/fasp_source_spec.rb create mode 100644 spec/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler_spec.rb diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb index 98ccf4ad4..715a19f76 100644 --- a/app/models/account_suggestions.rb +++ b/app/models/account_suggestions.rb @@ -8,6 +8,7 @@ class AccountSuggestions AccountSuggestions::FriendsOfFriendsSource, AccountSuggestions::SimilarProfilesSource, AccountSuggestions::GlobalSource, + AccountSuggestions::FaspSource, ].freeze BATCH_SIZE = 40 diff --git a/app/models/account_suggestions/fasp_source.rb b/app/models/account_suggestions/fasp_source.rb new file mode 100644 index 000000000..245eb9d71 --- /dev/null +++ b/app/models/account_suggestions/fasp_source.rb @@ -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 diff --git a/app/models/fasp/follow_recommendation.rb b/app/models/fasp/follow_recommendation.rb new file mode 100644 index 000000000..567c24a9a --- /dev/null +++ b/app/models/fasp/follow_recommendation.rb @@ -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 diff --git a/app/workers/fasp/follow_recommendation_worker.rb b/app/workers/fasp/follow_recommendation_worker.rb index 4772da404..5e760491b 100644 --- a/app/workers/fasp/follow_recommendation_worker.rb +++ b/app/workers/fasp/follow_recommendation_worker.rb @@ -23,10 +23,19 @@ class Fasp::FollowRecommendationWorker Fasp::Request.new(provider).get("/follow_recommendation/v0/accounts?#{params}").each do |uri| next if Account.where(uri:).any? - account = fetch_service.call(uri) - async_refresh.increment_result_count(by: 1) if account.present? + new_account = fetch_service.call(uri) + + 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 + + # 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 # Nothing to be done ensure diff --git a/app/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler.rb b/app/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler.rb new file mode 100644 index 000000000..76275dec3 --- /dev/null +++ b/app/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler.rb @@ -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 diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 9bfc7e998..97eaa55ce 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -68,3 +68,7 @@ interval: 1 hour class: Scheduler::AutoCloseRegistrationsScheduler queue: scheduler + fasp_follow_recommendation_cleanup_scheduler: + interval: 1 day + class: Scheduler::Fasp::FollowRecommendationsScheduler + queue: scheduler diff --git a/db/migrate/20250627132728_create_fasp_follow_recommendations.rb b/db/migrate/20250627132728_create_fasp_follow_recommendations.rb new file mode 100644 index 000000000..7cbe26a31 --- /dev/null +++ b/db/migrate/20250627132728_create_fasp_follow_recommendations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1064d8862..0237b4764 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 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.integer "avatar_storage_schema_version" t.integer "header_storage_schema_version" - t.integer "suspension_origin" t.datetime "sensitized_at", precision: nil + t.integer "suspension_origin" t.boolean "trendable" t.datetime "reviewed_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" 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| t.boolean "confirmed", default: false, null: false t.string "name", null: false @@ -604,12 +613,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do end 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.integer "severity", default: 0, null: false + t.datetime "expires_at", precision: nil 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 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 "fasp_backfill_requests", "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 "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade diff --git a/spec/fabricators/fasp/follow_recommendation_fabricator.rb b/spec/fabricators/fasp/follow_recommendation_fabricator.rb new file mode 100644 index 000000000..6c4025fdb --- /dev/null +++ b/spec/fabricators/fasp/follow_recommendation_fabricator.rb @@ -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 diff --git a/spec/models/account_suggestions/fasp_source_spec.rb b/spec/models/account_suggestions/fasp_source_spec.rb new file mode 100644 index 000000000..9d46b761f --- /dev/null +++ b/spec/models/account_suggestions/fasp_source_spec.rb @@ -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 diff --git a/spec/workers/fasp/follow_recommendation_worker_spec.rb b/spec/workers/fasp/follow_recommendation_worker_spec.rb index cd06a63d3..baa647aa0 100644 --- a/spec/workers/fasp/follow_recommendation_worker_spec.rb +++ b/spec/workers/fasp/follow_recommendation_worker_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Fasp::FollowRecommendationWorker, feature: :fasp do let(:provider) { Fabricate(:follow_recommendation_fasp) } let(:account) { Fabricate(:account) } - let(:fetch_service) { instance_double(ActivityPub::FetchRemoteActorService, call: true) } + let(:fetch_service) { instance_double(ActivityPub::FetchRemoteActorService) } let!(:stubbed_request) do account_uri = ActivityPub::TagManager.instance.uri_for(account) @@ -23,6 +23,8 @@ RSpec.describe Fasp::FollowRecommendationWorker, feature: :fasp do before do 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 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 end + + it 'persists the results' do + expect do + described_class.new.perform(account.id) + end.to change(Fasp::FollowRecommendation, :count).by(2) + end end diff --git a/spec/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler_spec.rb b/spec/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler_spec.rb new file mode 100644 index 000000000..e851b97af --- /dev/null +++ b/spec/workers/scheduler/fasp/follow_recommendation_cleanup_scheduler_spec.rb @@ -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