diff --git a/CHANGELOG.md b/CHANGELOG.md index 721080646..4541e82dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ [Full changelog][unreleased] +- Anonymise users background job + ## Release 164 – 2025-01-30 [Full changelog][164] diff --git a/Gemfile b/Gemfile index 1316545bd..47c2ae438 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,7 @@ gem "wicked" gem "strip_attributes" gem "breadcrumbs_on_rails" gem "sprockets-rails" +gem "sidekiq-scheduler" # Authentication gem "devise" diff --git a/Gemfile.lock b/Gemfile.lock index 132cb323c..f7db5d1f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,6 +169,8 @@ GEM encryptor (3.0.0) erubi (1.13.1) erubis (2.7.0) + et-orbi (1.2.11) + tzinfo factory_bot (6.5.0) activesupport (>= 5.0.0) factory_bot_rails (6.4.4) @@ -182,6 +184,9 @@ GEM ffi (1.17.0-x86_64-darwin) ffi (1.17.0-x86_64-linux) foreman (0.88.1) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) govuk_design_system_formbuilder (5.8.0) @@ -332,6 +337,7 @@ GEM rspec-expectations (~> 3.12) rspec-mocks (~> 3.12) rspec-support (~> 3.12) + raabro (1.4.0) racc (1.8.1) rack (2.2.10) rack-attack (6.7.0) @@ -455,6 +461,8 @@ GEM ruby_parser (3.19.1) sexp_processor (~> 4.16) rubyzip (2.4.1) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) selenium-webdriver (4.28.0) base64 (~> 0.2) logger (~> 1.4) @@ -469,6 +477,10 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) + sidekiq-scheduler (5.0.6) + rufus-scheduler (~> 3.2) + sidekiq (>= 6, < 8) + tilt (>= 1.4.0, < 3) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -616,6 +628,7 @@ DEPENDENCIES selenium-webdriver shoulda-matchers sidekiq (~> 7) + sidekiq-scheduler simplecov (~> 0.22.0) simplecov-lcov (~> 0.8.0) sprockets-rails diff --git a/app/jobs/anonymise_deactivated_users_job.rb b/app/jobs/anonymise_deactivated_users_job.rb new file mode 100644 index 000000000..c38091e82 --- /dev/null +++ b/app/jobs/anonymise_deactivated_users_job.rb @@ -0,0 +1,9 @@ +class AnonymiseDeactivatedUsersJob + include Sidekiq::Job + + def perform + User.deactivated.where("deactivated_at < ?", 5.years.ago).each do |user| + AnonymiseUser.new(user:).call + end + end +end diff --git a/config/routes.rb b/config/routes.rb index f52cb12f1..81db61c07 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "sidekiq/web" +require "sidekiq-scheduler/web" Rails.application.routes.draw do devise_scope :user do diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 234d1d14c..83ad6bded 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -2,3 +2,8 @@ :queues: - default - mailers +:scheduler: + :schedule: + anonymise_deactivated_users: + cron: '0 0 * * 0' # Every Sunday at midnight + class: AnonymiseDeactivatedUsersJob diff --git a/doc/architecture/decisions/0040-use-sidekiq-scheduler-for-scheduled-jobs.md b/doc/architecture/decisions/0040-use-sidekiq-scheduler-for-scheduled-jobs.md new file mode 100644 index 000000000..e05b645e7 --- /dev/null +++ b/doc/architecture/decisions/0040-use-sidekiq-scheduler-for-scheduled-jobs.md @@ -0,0 +1,33 @@ +# 40. Use sidekiq-scheduler for scheduled jobs + +Date: 2025-02-03 + +## Status + +Accepted + +## Context + +We needed a mechanism for running scheduled background jobs in order to +anonymise users who have been inactive for more than five years. There is no +mechanism currently in place in RODA to handle scheduled background jobs, but +we do have Sidekiq already available to us for running jobs in the background. + +## Decision + +We have decided to use [sidekiq-scheduler][1] as a lightweight scheduled job +solution. A small amount of research suggested that this gem offered a decent +implementation with a standard and well-understood interface (the time-honoured +cron syntax combined with Sidekiq's jobs (_né_ workers)). We also briefly +explored [sidekiq-cron][2] which does almost exactly the same thing, but had +slightly worse documentation. + +## Consequences + +With the addition of this gem, we have a small amount of configuration overhead +and, of course, one additional dependency. Neither of these is particularly +onerous. We now benefit from having a standardised solution for our current +and future scheduled background job requirements. + +[1]: https://github.com/sidekiq-scheduler/sidekiq-scheduler +[2]: https://github.com/sidekiq-cron/sidekiq-cron diff --git a/spec/config/sidekiq_scheduler_spec.rb b/spec/config/sidekiq_scheduler_spec.rb new file mode 100644 index 000000000..c6c513eed --- /dev/null +++ b/spec/config/sidekiq_scheduler_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" +require "fugit" + +RSpec.describe "sidekiq-scheduler" do + sidekiq_file = File.join(Rails.root, "config", "sidekiq.yml") + schedule = YAML.load_file(sidekiq_file)[:scheduler][:schedule] + + describe "cron syntax" do + schedule.each do |job_name, values| + cron = values["cron"] + it "#{job_name} has correct cron syntax" do + expect { Fugit.do_parse(cron) }.not_to raise_error + end + end + end + + describe "job classes" do + schedule.each do |job_name, values| + klass = values["class"] + it "#{job_name} has #{klass} class in /jobs" do + expect { klass.constantize }.not_to raise_error + end + end + end + + describe "job names" do + schedule.each do |job_name, values| + klass = values["class"] + it "#{job_name} has correct name" do + expect(klass.underscore).to start_with(job_name) + end + end + end +end diff --git a/spec/jobs/anonymise_deactivated_users_job_spec.rb b/spec/jobs/anonymise_deactivated_users_job_spec.rb new file mode 100644 index 000000000..3d77c2bdd --- /dev/null +++ b/spec/jobs/anonymise_deactivated_users_job_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +RSpec.describe AnonymiseDeactivatedUsersJob, type: :job do + describe "the job" do + subject(:job) { AnonymiseDeactivatedUsersJob.perform_async } + + it "is enqueued" do + expect { job }.to change(AnonymiseDeactivatedUsersJob.jobs, :size).by(1) + end + + it "is drained" do + job + AnonymiseDeactivatedUsersJob.drain + expect(AnonymiseDeactivatedUsersJob.jobs.size).to eq 0 + end + end + + describe "#perform" do + let(:the_recent_past) { 2.years.ago } + let(:the_distant_past) { 6.years.ago } + + it "anonymises a user who has been inactive for more than 5 years" do + create(:beis_user, deactivated_at: the_distant_past) + + described_class.new.perform + + expect(User.first.anonymised_at).not_to eq nil + end + + it "does not anonymise a user who has been inactive for less than 5 years" do + create(:beis_user, deactivated_at: the_recent_past) + + described_class.new.perform + + expect(User.first.anonymised_at).to eq nil + end + + it "anonymises a set of users who were deactivated in the distant past" do + 5.times { create(:beis_user, deactivated_at: the_distant_past) } + + described_class.new.perform + + expect(User.deactivated.count).to eq 0 + expect(User.where.not(anonymised_at: nil).count).to eq 5 + end + + it "does not anonymise a set of users who were deactivated in the recent past" do + 5.times { create(:beis_user, deactivated_at: the_recent_past) } + + described_class.new.perform + + expect(User.deactivated.count).to eq 5 + expect(User.where.not(anonymised_at: nil).count).to eq 0 + end + end +end diff --git a/spec/support/sidekiq.rb b/spec/support/sidekiq.rb new file mode 100644 index 000000000..987e1af69 --- /dev/null +++ b/spec/support/sidekiq.rb @@ -0,0 +1,2 @@ +require "sidekiq/testing" +Sidekiq::Testing.fake! # qv. https://github.com/sidekiq/sidekiq/wiki/Testing#testing-worker-queueing-fake